WPF 部署与自动更新
大约 16 分钟约 4776 字
WPF 部署与自动更新
简介
WPF 应用程序的部署和更新策略直接影响用户体验和运维效率。从传统的 ClickOnce 到现代的 Squirrel.Windows、MSIX 打包,每种方案都有其适用场景。本文将全面介绍 WPF 应用的部署方式、自动更新机制、安装包制作、代码签名、企业分发等内容,帮助开发者选择并实施最适合的部署方案。
特点
ClickOnce 部署
基本发布配置
<!-- ClickOnce 是 .NET 内置的轻量级部署方案 -->
<!-- 适合中小型应用,支持自动更新 -->
<!-- 在 .csproj 中配置 ClickOnce -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<!-- 启用 ClickOnce -->
<PublishDir>bin\Publish\</PublishDir>
<Install>true</Install>
<UpdateEnabled>true</UpdateEnabled>
<UpdateMode>Foreground</UpdateMode>
<UpdateInterval>7</UpdateInterval>
<PublisherName>MyCompany</PublisherName>
<ProductName>MyWpfApp</ProductName>
</PropertyGroup>
</Project>通过命令行发布 ClickOnce
# 使用 dotnet publish 发布 ClickOnce 应用
dotnet publish -c Release -r win-x64 --self-contained true
# 使用 Mage 工具生成 ClickOnce 清单
# Mage.exe 位于 Windows SDK 目录
mage -New Application -ToFile MyWpfApp.exe.manifest `
-Name "MyWpfApp" `
-Version "1.0.0.0" `
-Processor "msil" `
-FromDirectory ".\bin\Release\net8.0-windows\win-x64\publish\"
mage -New Deployment -ToFile MyWpfApp.application `
-AppManifest MyWpfApp.exe.manifest `
-Install true `
-ProviderUrl "https://myserver.com/deploy/MyWpfApp.application" `
-Version "1.0.0.0"
# 对清单签名
mage -Sign MyWpfApp.exe.manifest -CertFile mycert.pfx -Password certpass
mage -Sign MyWpfApp.application -CertFile mycert.pfx -Password certpassClickOnce 编程式更新检查
/// <summary>
/// ClickOnce 编程式更新管理
/// </summary>
public class ClickOnceUpdateManager
{
/// <summary>
/// 检查并安装更新
/// </summary>
public async Task<UpdateResult> CheckAndUpdateAsync(IProgress<string> progress)
{
// 仅在 ClickOnce 部署时可用
if (!ApplicationDeployment.IsNetworkDeployed)
{
return new UpdateResult { IsUpdateAvailable = false, Message = "非 ClickOnce 部署" };
}
var deployment = ApplicationDeployment.CurrentDeployment;
try
{
// 检查更新
progress?.Report("正在检查更新...");
bool hasUpdate = deployment.CheckForUpdate();
if (!hasUpdate)
{
return new UpdateResult { IsUpdateAvailable = false, Message = "已是最新版本" };
}
// 获取更新信息
var info = deployment.CheckForDetailedUpdate();
progress?.Report($"发现新版本: {info.AvailableVersion}");
// 下载并安装更新
deployment.UpdateCompleted += (s, e) =>
{
if (e.Error != null)
{
progress?.Report($"更新失败: {e.Error.Message}");
}
else
{
progress?.Report("更新完成,即将重启应用...");
}
};
deployment.UpdateProgressChanged += (s, e) =>
{
progress?.Report($"下载进度: {e.ProgressPercentage}%");
};
await Task.Run(() => deployment.Update());
return new UpdateResult
{
IsUpdateAvailable = true,
IsUpdated = true,
NewVersion = info.AvailableVersion.ToString(),
Message = "更新成功"
};
}
catch (Exception ex)
{
return new UpdateResult
{
IsUpdateAvailable = false,
Message = $"更新出错: {ex.Message}"
};
}
}
/// <summary>
/// 获取当前版本信息
/// </summary>
public Version GetCurrentVersion()
{
if (ApplicationDeployment.IsNetworkDeployed)
{
return ApplicationDeployment.CurrentDeployment.CurrentVersion;
}
return Assembly.GetExecutingAssembly().GetName().Version ?? new Version(1, 0);
}
}
public class UpdateResult
{
public bool IsUpdateAvailable { get; set; }
public bool IsUpdated { get; set; }
public string NewVersion { get; set; } = "";
public string Message { get; set; } = "";
}Squirrel.Windows 自动更新
Squirrel 集成
/// <summary>
/// Squirrel.Windows — 成熟的 WPF 自动更新框架
/// NuGet: Install-Package Squirrel.Windows
/// 或使用 Squirrel.UI:Install-Package Squirrel.Windows.Browser
/// </summary>
using Squirrel;
public class SquirrelUpdateService : IDisposable
{
private UpdateManager? _updateManager;
private readonly string _updateUrl;
private readonly ILogger _logger;
public SquirrelUpdateService(string updateUrl, ILogger logger)
{
_updateUrl = updateUrl;
_logger = logger;
}
/// <summary>
/// 初始化 UpdateManager
/// </summary>
public async Task InitializeAsync()
{
_updateManager = await UpdateManager.GitHubUpdateManager(
repoUrl: _updateUrl,
prerelease: false);
}
/// <summary>
/// 检查并安装更新
/// </summary>
public async Task<UpdateInfo?> CheckForUpdateAsync()
{
if (_updateManager == null) return null;
try
{
var updateInfo = await _updateManager.CheckForUpdate(ignoreDeltaUpdates: false);
if (updateInfo?.ReleasesToApply?.Count > 0)
{
_logger.Information(
$"发现 {updateInfo.ReleasesToApply.Count} 个更新");
return updateInfo;
}
_logger.Information("已使用最新版本");
return null;
}
catch (Exception ex)
{
_logger.Error(ex, "检查更新失败");
return null;
}
}
/// <summary>
/// 下载并应用更新
/// </summary>
public async Task<string?> DownloadAndApplyUpdateAsync(
UpdateInfo updateInfo,
IProgress<int>? progress = null)
{
if (_updateManager == null) return null;
try
{
// 下载更新(支持增量更新)
await _updateManager.DownloadReleases(
updateInfo.ReleasesToApply,
p => progress?.Report((int)(p / 100.0 * 50)));
// 应用更新
var releaseEntry = await _updateManager.ApplyReleases(
updateInfo,
p => progress?.Report(50 + (int)(p / 100.0 * 50)));
_logger.Information($"更新完成: {releaseEntry.Version}");
return releaseEntry.Version.ToString();
}
catch (Exception ex)
{
_logger.Error(ex, "应用更新失败");
return null;
}
}
/// <summary>
/// 重启应用以完成更新
/// </summary>
public void RestartApp()
{
UpdateManager.RestartApp();
}
/// <summary>
/// 应用启动时的更新处理
/// </summary>
public static async Task HandleStartupAsync(string updateUrl)
{
using var manager = await UpdateManager.GitHubUpdateManager(updateUrl);
// 检查是否是首次安装或更新
if (manager.IsInstalledApp)
{
// 创建桌面快捷方式
manager.CreateShortcutForThisExe();
// 创建开始菜单快捷方式
manager.CreateUninstallerRegistryEntry();
}
}
/// <summary>
/// 回滚到上一版本
/// </summary>
public async Task RollbackAsync()
{
if (_updateManager == null) return;
try
{
await _updateManager.FullUpdate(false);
_logger.Information("已回滚到上一版本");
}
catch (Exception ex)
{
_logger.Error(ex, "回滚失败");
}
}
public void Dispose()
{
_updateManager?.Dispose();
}
}Squirrel 更新 UI 集成
/// <summary>
/// WPF 中的更新 UI 交互
/// </summary>
public partial class UpdateWindow : Window
{
private readonly SquirrelUpdateService _updateService;
public UpdateWindow(SquirrelUpdateService updateService)
{
InitializeComponent();
_updateService = updateService;
Loaded += OnLoaded;
}
private async void OnLoaded(object sender, RoutedEventArgs e)
{
try
{
var progress = new Progress<int>(p =>
{
ProgressBar.Value = p;
StatusText.Text = p < 50 ? "正在下载更新..." : "正在安装更新...";
});
var updateInfo = await _updateService.CheckForUpdateAsync();
if (updateInfo == null)
{
StatusText.Text = "已使用最新版本";
CloseButton.IsEnabled = true;
return;
}
var latestVersion = updateInfo.ReleasesToApply
.OrderByDescending(r => r.Version)
.First();
VersionText.Text = $"新版本: {latestVersion.Version}";
ReleaseNotes.Text = latestVersion.GetReleaseNotes();
string? newVersion = await _updateService
.DownloadAndApplyUpdateAsync(updateInfo, progress);
if (newVersion != null)
{
StatusText.Text = "更新完成!请重启应用以完成升级。";
RestartButton.IsEnabled = true;
}
}
catch (Exception ex)
{
StatusText.Text = $"更新失败: {ex.Message}";
CloseButton.IsEnabled = true;
}
}
private void OnRestartClick(object sender, RoutedEventArgs e)
{
_updateService.RestartApp();
}
private void OnCloseClick(object sender, RoutedEventArgs e)
{
DialogResult = false;
}
}MSIX 打包部署
MSIX 打包项目配置
<!-- MSIX 是微软推荐的现代应用打包格式 -->
<!-- Windows 10 1709+ 支持 -->
<!-- 1. 添加 Windows Application Packaging Project -->
<!-- 2. 引用 WPF 项目 -->
<!-- Package.appxmanifest 配置 -->
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
IgnorableNamespaces="uap mp">
<Identity Name="MyCompany.MyWpfApp"
Publisher="CN=MyCompany"
Version="1.0.0.0" />
<Properties>
<DisplayName>My WPF Application</DisplayName>
<PublisherDisplayName>MyCompany</PublisherDisplayName>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop"
MinVersion="10.0.17763.0"
MaxVersionTested="10.0.22621.0" />
</Dependencies>
<Applications>
<Application Id="MyWpfApp"
Executable="MyWpfApp\MyWpfApp.exe"
EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements DisplayName="My WPF App"
Description="My WPF Application"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
<uap:SplashScreen Image="Assets\SplashScreen.png" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
<Capability Name="runFullTrust" />
<rescap:Capability Name="runFullTrust"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" />
</Capabilities>
</Package>MSIX 自动更新配置
/// <summary>
/// MSIX 应用自动更新 — 通过 AppInstaller 文件配置
/// </summary>
public static class MsixUpdateHelper
{
/// <summary>
/// 检查 MSIX 更新
/// </summary>
public static async Task CheckForMsixUpdateAsync(string appInstallerUri)
{
// MSIX 通过 .appinstaller 文件自动检查更新
// 配置示例见下方 appinstaller 文件
//
// 应用内可以主动触发检查:
var manager = Windows.ApplicationModel.Package.Current;
var packageUpdate = await manager.CheckUpdateAvailabilityAsync();
switch (packageUpdate.Availability)
{
case PackageUpdateAvailability.Available:
Console.WriteLine("有更新可用");
break;
case PackageUpdateAvailability.Required:
Console.WriteLine("必须更新");
break;
case PackageUpdateAvailability.NoUpdates:
Console.WriteLine("已使用最新版本");
break;
}
}
}
// AppInstaller 文件 (.appinstaller) 配置自动更新
// 放在 Web 服务器上,URL 填入 Package.appxmanifest 的 Uri 属性
/*
<?xml version="1.0" encoding="utf-8"?>
<AppInstaller Uri="https://myserver.com/MyWpfApp.appinstaller"
Version="1.0.0.0"
xmlns="http://schemas.microsoft.com/appx/appinstaller/2018">
<MainBundle Name="MyCompany.MyWpfApp"
Version="1.0.0.0"
Uri="https://myserver.com/MyWpfApp_1.0.0.0.msixbundle" />
<UpdateSettings>
<OnLaunch HoursBetweenUpdateChecks="12" />
<AutomaticBackgroundTask />
<ForceUpdateFromAnyVersion>false</ForceUpdateFromAnyVersion>
</UpdateSettings>
</AppInstaller>
*/安装包制作
WiX Toolset 安装包
<!-- WiX Toolset (v4) — 功能最强大的 Windows 安装包工具 -->
<!-- NuGet: Install-Package WixToolset.Firewall.wixext -->
<!--
Product.wxs — WiX 安装包定义文件
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package Name="My WPF App"
Manufacturer="MyCompany"
Version="1.0.0.0"
UpgradeCode="YOUR-GUID-HERE">
<!-- 安装介质 -->
<Media Id="1" Cabinet="app.cab" EmbedCab="yes" />
<!-- 安装目录结构 -->
<StandardDirectory Id="ProgramFilesFolder">
<Directory Id="INSTALLFOLDER" Name="MyWpfApp">
<Directory Id="BinFolder" Name="bin" />
</Directory>
</StandardDirectory>
<!-- 开始菜单快捷方式 -->
<StandardDirectory Id="ProgramMenuFolder">
<Directory Id="AppStartMenuDir" Name="My WPF App">
<Component Id="AppShortcut">
<Shortcut Id="AppShortcut"
Name="My WPF App"
Target="[BinFolder]MyWpfApp.exe"
WorkingDirectory="BinFolder"
Icon="AppIcon.exe" />
<RemoveFolder Id="AppStartMenuDir" On="uninstall" />
</Component>
</Directory>
</StandardDirectory>
<!-- 安装文件 -->
<Component Directory="BinFolder" Id="AppFiles">
<File Id="MainExe" Source="bin\Release\net8.0-windows\MyWpfApp.exe" />
<!-- 使用 heat.exe 自动生成文件列表 -->
</Component>
<!-- 注册表项(用于版本检测) -->
<Component Directory="BinFolder" Id="RegistryEntries">
<RegistryValue Root="HKLM"
Key="SOFTWARE\MyCompany\MyWpfApp"
Name="Version"
Value="[ProductVersion]"
Type="string" />
<RegistryValue Root="HKLM"
Key="SOFTWARE\MyCompany\MyWpfApp"
Name="InstallPath"
Value="[BinFolder]"
Type="string" />
</Component>
<!-- 安装特性 -->
<Feature Id="MainFeature" Level="1">
<ComponentRef Id="AppFiles" />
<ComponentRef Id="AppShortcut" />
<ComponentRef Id="RegistryEntries" />
</Feature>
<!-- UI 序列 -->
<UI>
<UIRef Id="WixUI_InstallDir" />
</UI>
<!-- 安装目录选择属性 -->
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />
</Package>
</Wix>Inno Setup 安装脚本
/// <summary>
/// Inno Setup — 简单易用的安装包制作工具
/// 使用 Pascal 脚本语言定义安装流程
/// </summary>
// MyWpfAppSetup.iss
/*
[Setup]
AppName=My WPF App
AppVersion=1.0.0
AppPublisher=MyCompany
AppPublisherURL=https://mycompany.com
DefaultDirName={autopf}\MyWpfApp
DefaultGroupName=My WPF App
OutputBaseFilename=MyWpfApp_Setup_1.0.0
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
; 支持自动更新前的版本检测
AppId={{YOUR-GUID-HERE}}
; 安装前关闭运行中的应用
CloseApplications=force
[Languages]
Name: "chinese"; MessagesFile: "compiler:Languages\ChineseSimplified.isl"
[Tasks]
Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription: "附加图标:"
[Files]
; 主程序
Source: "bin\Release\net8.0-windows\publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs
[Icons]
Name: "{group}\My WPF App"; Filename: "{app}\MyWpfApp.exe"
Name: "{group}\卸载 My WPF App"; Filename: "{uninstallexe}"
Name: "{autodesktop}\My WPF App"; Filename: "{app}\MyWpfApp.exe"; Tasks: desktopicon
[Run]
Filename: "{app}\MyWpfApp.exe"; Description: "启动 My WPF App"; Flags: nowait postinstall skipifsilent
[Code]
// 检查 .NET 运行时
function IsDotNetInstalled: Boolean;
var
ResultCode: Integer;
begin
Result := Exec('dotnet', '--list-runtimes', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;
// 安装前检查
function InitializeSetup: Boolean;
begin
Result := True;
if not IsDotNetInstalled then
begin
if MsgBox('未检测到 .NET 8 运行时。是否下载安装?', mbConfirmation, MB_YESNO) = IDYES then
begin
ShellExec('open', 'https://dotnet.microsoft.com/download/dotnet/8.0', '', '', SW_SHOWNORMAL, ewNoWait, ResultCode);
Result := False;
end;
end;
end;
*/自定义自动更新机制
版本检查与下载服务
/// <summary>
/// 自定义自动更新服务 — 不依赖第三方框架
/// 适合对更新流程有完全控制需求的场景
/// </summary>
public class CustomUpdateService
{
private readonly HttpClient _httpClient;
private readonly string _updateCheckUrl;
private readonly string _downloadBaseUrl;
private readonly ILogger _logger;
public CustomUpdateService(
string updateCheckUrl,
string downloadBaseUrl,
ILogger logger)
{
_httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
_updateCheckUrl = updateCheckUrl;
_downloadBaseUrl = downloadBaseUrl;
_logger = logger;
}
/// <summary>
/// 检查更新
/// </summary>
public async Task<UpdateCheckResult> CheckForUpdateAsync()
{
try
{
// 从服务器获取最新版本信息
string response = await _httpClient.GetStringAsync(_updateCheckUrl);
var versionInfo = JsonSerializer.Deserialize<VersionInfo>(response);
if (versionInfo == null)
return new UpdateCheckResult { HasError = true, Message = "无法解析版本信息" };
// 比较版本号
var currentVersion = Assembly.GetExecutingAssembly().GetName().Version;
var latestVersion = new Version(versionInfo.Version);
if (currentVersion == null || latestVersion > currentVersion)
{
return new UpdateCheckResult
{
HasUpdate = true,
LatestVersion = latestVersion.ToString(),
DownloadUrl = versionInfo.DownloadUrl,
ReleaseNotes = versionInfo.ReleaseNotes,
IsMandatory = versionInfo.IsMandatory,
FileSize = versionInfo.FileSize,
Sha256 = versionInfo.Sha256
};
}
return new UpdateCheckResult
{
HasUpdate = false,
Message = "已使用最新版本"
};
}
catch (Exception ex)
{
_logger.Error(ex, "检查更新失败");
return new UpdateCheckResult
{
HasError = true,
Message = $"检查更新失败: {ex.Message}"
};
}
}
/// <summary>
/// 下载更新包
/// </summary>
public async Task<string> DownloadUpdateAsync(
string downloadUrl,
string expectedSha256,
IProgress<DownloadProgress>? progress = null,
CancellationToken cancellationToken = default)
{
string tempDir = Path.Combine(Path.GetTempPath(), "MyWpfApp_Update");
Directory.CreateDirectory(tempDir);
string fileName = Path.GetFileName(new Uri(downloadUrl).LocalPath);
string filePath = Path.Combine(tempDir, fileName);
// 如果文件已下载,验证 SHA256
if (File.Exists(filePath))
{
string existingHash = ComputeFileSha256(filePath);
if (existingHash.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase))
{
_logger.Information("使用已下载的更新包");
return filePath;
}
}
// 下载文件
using var response = await _httpClient.GetAsync(
downloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
long totalBytes = response.Content.Headers.ContentLength ?? 0;
long downloadedBytes = 0;
using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None);
var buffer = new byte[81920];
int bytesRead;
while ((bytesRead = await contentStream.ReadAsync(buffer, cancellationToken)) > 0)
{
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
downloadedBytes += bytesRead;
progress?.Report(new DownloadProgress
{
TotalBytes = totalBytes,
DownloadedBytes = downloadedBytes,
PercentComplete = totalBytes > 0 ? (int)(downloadedBytes * 100 / totalBytes) : 0
});
}
// 验证文件完整性
string hash = ComputeFileSha256(filePath);
if (!hash.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase))
{
File.Delete(filePath);
throw new InvalidDataException("文件校验失败,SHA256 不匹配");
}
_logger.Information($"更新包下载完成: {filePath}");
return filePath;
}
/// <summary>
/// 应用更新(使用外部更新进程)
/// </summary>
public void ApplyUpdate(string updatePackagePath)
{
string appDir = AppDomain.CurrentDomain.BaseDirectory;
string updaterExe = Path.Combine(appDir, "Updater.exe");
if (!File.Exists(updaterExe))
{
throw new FileNotFoundException("找不到更新程序: Updater.exe");
}
// 启动更新程序并退出当前应用
var startInfo = new ProcessStartInfo
{
FileName = updaterExe,
Arguments = $"--package \"{updatePackagePath}\" --target \"{appDir}\" --restart",
UseShellExecute = false,
CreateNoWindow = true
};
Process.Start(startInfo);
// 退出当前应用
Application.Current.Shutdown();
}
private static string ComputeFileSha256(string filePath)
{
using var stream = File.OpenRead(filePath);
using var sha256 = System.Security.Cryptography.SHA256.Create();
byte[] hash = sha256.ComputeHash(stream);
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
}
public class VersionInfo
{
public string Version { get; set; } = "";
public string DownloadUrl { get; set; } = "";
public string ReleaseNotes { get; set; } = "";
public bool IsMandatory { get; set; }
public long FileSize { get; set; }
public string Sha256 { get; set; } = "";
}
public class UpdateCheckResult
{
public bool HasUpdate { get; set; }
public bool HasError { get; set; }
public string LatestVersion { get; set; } = "";
public string DownloadUrl { get; set; } = "";
public string ReleaseNotes { get; set; } = "";
public bool IsMandatory { get; set; }
public long FileSize { get; set; }
public string Sha256 { get; set; } = "";
public string Message { get; set; } = "";
}
public class DownloadProgress
{
public long TotalBytes { get; set; }
public long DownloadedBytes { get; set; }
public int PercentComplete { get; set; }
}独立更新程序
/// <summary>
/// Updater.exe — 独立更新程序
/// 负责替换文件并重启主应用
/// </summary>
public class UpdaterProgram
{
// Updater.exe 命令行参数:
// --package "C:\Temp\update.zip" --target "C:\Program Files\MyApp" --restart
public static int Main(string[] args)
{
string? packagePath = GetArgValue(args, "--package");
string? targetDir = GetArgValue(args, "--target");
bool restart = args.Contains("--restart");
if (string.IsNullOrEmpty(packagePath) || string.IsNullOrEmpty(targetDir))
{
Console.WriteLine("用法: Updater.exe --package <更新包> --target <目标目录> [--restart]");
return 1;
}
try
{
// 1. 等待主应用退出
WaitForAppExit("MyWpfApp");
// 2. 备份当前版本
string backupDir = Path.Combine(targetDir, "..", "MyWpfApp_backup");
BackupDirectory(targetDir, backupDir);
// 3. 解压并替换文件
ExtractAndUpdate(packagePath, targetDir);
// 4. 验证更新
string mainExe = Path.Combine(targetDir, "MyWpfApp.exe");
if (!File.Exists(mainExe))
{
// 回滚
Rollback(targetDir, backupDir);
Console.WriteLine("更新失败:主程序缺失,已回滚");
return 2;
}
// 5. 清理备份
try { Directory.Delete(backupDir, true); } catch { }
// 6. 重启应用
if (restart)
{
Process.Start(new ProcessStartInfo
{
FileName = mainExe,
UseShellExecute = true,
WorkingDirectory = targetDir
});
}
Console.WriteLine("更新完成");
return 0;
}
catch (Exception ex)
{
Console.WriteLine($"更新失败: {ex.Message}");
// 尝试回滚
try
{
string backupDir = Path.Combine(targetDir, "..", "MyWpfApp_backup");
if (Directory.Exists(backupDir))
{
Rollback(targetDir, backupDir);
Console.WriteLine("已回滚到上一版本");
}
}
catch { }
return 3;
}
}
private static void WaitForAppExit(string processName)
{
int maxWait = 30000; // 30秒超时
int waited = 0;
while (waited < maxWait)
{
var processes = Process.GetProcessesByName(processName);
if (processes.Length == 0) return;
foreach (var p in processes) p.Dispose();
Thread.Sleep(500);
waited += 500;
}
// 强制终止
foreach (var p in Process.GetProcessesByName(processName))
{
try { p.Kill(); } catch { }
p.Dispose();
}
}
private static void BackupDirectory(string source, string backup)
{
if (Directory.Exists(backup))
Directory.Delete(backup, true);
CopyDirectory(source, backup);
}
private static void ExtractAndUpdate(string packagePath, string targetDir)
{
// 解压 ZIP 更新包
using var archive = ZipFile.OpenRead(packagePath);
foreach (var entry in archive.Entries)
{
string destination = Path.Combine(targetDir, entry.FullName);
string? dir = Path.GetDirectoryName(destination);
if (dir != null) Directory.CreateDirectory(dir);
entry.ExtractToFile(destination, overwrite: true);
}
}
private static void Rollback(string target, string backup)
{
// 清理目标目录(保留配置文件)
foreach (var file in Directory.GetFiles(target))
{
if (!file.EndsWith(".config") && !file.EndsWith(".json"))
File.Delete(file);
}
// 从备份恢复
CopyDirectory(backup, target);
}
private static void CopyDirectory(string source, string target)
{
foreach (var dir in Directory.GetDirectories(source, "*", SearchOption.AllDirectories))
{
string relative = dir[source.Length..].TrimStart('\\');
Directory.CreateDirectory(Path.Combine(target, relative));
}
foreach (var file in Directory.GetFiles(source, "*", SearchOption.AllDirectories))
{
string relative = file[source.Length..].TrimStart('\\');
File.Copy(file, Path.Combine(target, relative), overwrite: true);
}
}
private static string? GetArgValue(string[] args, string key)
{
for (int i = 0; i < args.Length - 1; i++)
{
if (args[i] == key) return args[i + 1];
}
return null;
}
}代码签名
签名证书管理
/// <summary>
/// 代码签名 — 确保安装包来源可信
/// </summary>
public static class CodeSigningHelper
{
/// <summary>
/// 使用 signtool 签名
/// 需要安装 Windows SDK
/// </summary>
public static bool SignFile(string filePath, string certPath, string password)
{
// 使用 SHA256 签名(推荐)
string timestampUrl = "http://timestamp.digicert.com";
var startInfo = new ProcessStartInfo
{
FileName = "signtool.exe",
Arguments = $"sign /f \"{certPath}\" /p {password} " +
$"/tr {timestampUrl} /td sha256 /fd sha256 " +
$"\"{filePath}\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null) return false;
string output = process.StandardOutput.ReadToEnd();
process.WaitForExit();
return process.ExitCode == 0;
}
/// <summary>
/// 验证文件签名
/// </summary>
public static bool VerifySignature(string filePath)
{
var startInfo = new ProcessStartInfo
{
FileName = "signtool.exe",
Arguments = $"verify /pa \"{filePath}\"",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null) return false;
process.WaitForExit();
return process.ExitCode == 0;
}
}
// CI/CD 中的签名步骤
// Azure DevOps:
// - task: CodeSigning@2
// inputs:
// SecureFileId: 'my-cert.pfx'
// PasswordSecret: 'cert-password'
// FilesToSign: '**\*.exe;**\*.msix'企业部署 (SCCM/Intune)
SCCM 部署配置
/// <summary>
/// 企业环境下通过 SCCM/Intune 部署 WPF 应用
/// </summary>
public static class EnterpriseDeployment
{
/// <summary>
/// 检测方法脚本 — SCCM 用于判断应用是否已安装
/// </summary>
public static bool IsAppInstalled()
{
string installPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialField.ProgramFiles),
"MyCompany", "MyWpfApp");
string exePath = Path.Combine(installPath, "MyWpfApp.exe");
if (!File.Exists(exePath))
return false;
// 检查版本
var versionInfo = FileVersionInfo.GetVersionInfo(exePath);
var requiredVersion = new Version("1.0.0.0");
var currentVersion = new Version(
versionInfo.FileMajorPart,
versionInfo.FileMinorPart,
versionInfo.FileBuildPart,
versionInfo.FilePrivatePart);
return currentVersion >= requiredVersion;
}
/// <summary>
/// 静默安装参数
/// </summary>
public static string GetSilentInstallArgs()
{
// WiX 安装包
return "/quiet /norestart INSTALLDIR=\"C:\\Program Files\\MyCompany\\MyWpfApp\"";
// Inno Setup 安装包
// return "/VERYSILENT /NORESTART /DIR=\"C:\\Program Files\\MyCompany\\MyWpfApp\"";
// MSIX
// return "powershell -Command Add-AppxPackage -Path MyWpfApp.msixbundle";
}
}优点
缺点
性能注意事项
总结
WPF 部署方案的选择取决于应用规模和用户群体。小型应用可使用 ClickOnce 快速部署;中大型应用推荐 Squirrel.Windows 实现灵活的自动更新;面向 Microsoft Store 的应用使用 MSIX 打包;企业内部部署通过 SCCM/Intune 统一管理。代码签名是所有方案的基础,保证更新安全可信。
关键知识点
- ClickOnce 适合简单场景,.NET Framework 内置支持但功能有限
- Squirrel.Windows 提供增量更新、回滚、GitHub 集成等高级功能
- MSIX 是微软推荐的现代打包格式,支持自动更新和沙箱
- WiX 功能最强大但学习曲线陡峭,Inno Setup 更易上手
- 代码签名证书(EV/OV)是分发安全的基础保障
- 独立更新程序模式让主应用在更新时完全退出
- SHA256 校验确保下载的更新包完整无损
- SCCM/Intune 是企业大规模部署的标准方式
常见误区
- 误区:ClickOnce 已经过时,不应该使用
纠正:ClickOnce 在 .NET 6+ 中已被重新支持,适合简单部署场景 - 误区:自动更新只需要下载新的 exe 文件
纠正:需要考虑依赖项更新、配置文件迁移、数据库升级等 - 误区:MSIX 只能用于 UWP 应用
纠正:MSIX 支持 WPF、WinForms 等所有 Windows 桌面应用 - 误区:不需要代码签名也能正常分发
纠正:未签名的应用会被 SmartScreen 拦截,用户体验极差 - 误区:增量更新需要复杂的差异算法
纠正:Squirrel 等框架已内置增量更新支持
进阶路线
- 初级:使用 ClickOnce 一键发布,实现基本自动更新
- 中级:使用 Squirrel.Windows 或 Inno Setup,自定义安装流程
- 高级:MSIX 打包 + 自动更新,代码签名,企业分发
- 专家级:自定义更新服务,灰度发布,A/B 测试,回滚策略
适用场景
- 面向终端用户的桌面应用需要静默自动更新
- 企业内部工具通过 SCCM 统一部署
- 需要在 Microsoft Store 分发的应用
- 频繁迭代的 SaaS 桌面客户端
- 工业软件需要离线安装包和版本管理
落地建议
- 根据用户规模选择部署方案(<100 用户用 ClickOnce,>1000 用户用 Squirrel/MSIX)
- 购买代码签名证书作为项目基础投资
- 搭建版本发布 API:返回最新版本号、下载链接、SHA256
- CI/CD 中集成打包、签名、发布自动化
- 保留至少 2 个历史版本用于回滚
- 企业部署准备静默安装参数和检测脚本
排错清单
复盘问题
- 用户反馈"更新后设置丢失",原因可能是什么?
- 如何实现灰度发布(先更新 10% 用户,再逐步扩大)?
- ClickOnce 和 Squirrel 的更新体验有什么差异?
- 如何在无网络环境下部署和更新 WPF 应用?
- MSIX 打包的 WPF 应用如何访问本地文件?
- 如何让用户在更新前看到完整的更新日志?
- 企业防火墙环境下如何配置更新服务器?
- 如何实现"跳过此版本"功能?
