本文从设计到实践,实现了.NET客服端自动检查更新的过程,其中应用到了中间件Nginx。文中的示例在实际的场景 Linux服务器---Linux客户端中 经过验证完全可行。
1. 框架设计
1.1 设计图

1.2 服务器配置文件
cs
{
"version": "1.0.0.2025111705",
"downloadUrl": "http://你的服务器IP:82/你的压缩包的名称.zip",
"sha256": "a1b2c3d4e5f678901234567890abcdef1234567890abcdef1234567890abcd",
"releaseNotes": "版本更新测试",
"isCritical": false,
"releaseDate": "2025-11-14T10:00:00Z",
"minOsVersion": "ubuntu18.04",
"fileSize": 209808384
}
2. 代码实现设计
2.1 新建版本信息模型及更新操作的结果模型
cs
/// <summary>
/// 版本信息模型,用于描述服务器上的版本信息和更新详情
/// 这个类会从服务器的version.json文件反序列化得到
/// </summary>
public class VersionInfo
{
/// <summary>
/// 版本号,格式为"主版本.次版本.修订号",例如:"1.2.3"
/// 客户端会使用这个字段与本地版本进行比较
/// </summary>
[JsonPropertyName("version")]
public string Version { get; set; } = "1.0.0";
/// <summary>
/// 新版本程序的下载地址,可以是完整的HTTP/HTTPS URL
/// 客户端会从这个URL下载更新文件
/// </summary>
[JsonPropertyName("downloadUrl")]
public string DownloadUrl { get; set; } = "";
/// <summary>
/// 新版本程序的SHA256哈希值,用于验证下载文件的完整性
/// 防止文件在下载过程中被篡改或损坏
/// </summary>
[JsonPropertyName("sha256")]
public string Sha256Hash { get; set; } = "";
/// <summary>
/// 版本发布说明,描述新版本的变更内容、修复的问题等
/// 可以在UI中显示给用户看
/// </summary>
[JsonPropertyName("releaseNotes")]
public string ReleaseNotes { get; set; } = "";
/// <summary>
/// 是否为关键更新,如果是true,客户端应该强制用户更新
/// 非关键更新用户可以跳过
/// </summary>
[JsonPropertyName("isCritical")]
public bool IsCritical { get; set; }
/// <summary>
/// 版本发布日期,用于在UI中显示更新时间
/// </summary>
[JsonPropertyName("releaseDate")]
public DateTime ReleaseDate { get; set; }
/// <summary>
/// 最低要求的操作系统版本,用于兼容性检查
/// 例如:"ubuntu18.04" 或 "windows10"
/// </summary>
[JsonPropertyName("minOsVersion")]
public string MinOsVersion { get; set; } = "";
/// <summary>
/// 更新文件的大小(字节数),用于显示下载进度和预估时间
/// </summary>
[JsonPropertyName("fileSize")]
public long FileSize { get; set; }
}
// Models/UpdateResult.cs
/// <summary>
/// 更新操作的结果模型,包含更新操作的成功状态和详细信息
/// 每个更新相关的方法都会返回这个类型的结果
/// </summary>
public class UpdateResult
{
/// <summary>
/// 操作是否成功完成
/// true表示成功,false表示失败
/// </summary>
public bool Success { get; set; }
/// <summary>
/// 操作的详细描述信息,可以是成功信息或错误信息
/// 用于在UI中显示给用户或在日志中记录
/// </summary>
public string Message { get; set; } = "";
/// <summary>
/// 如果发现有新版本,这个字段包含新版本的详细信息
/// 如果没有新版本或检查失败,这个字段为null
/// </summary>
public VersionInfo? NewVersion { get; set; }
/// <summary>
/// 是否需要重启应用程序来完成更新
/// 如果为true,应用程序应该在合适的时机重启
/// </summary>
public bool RequiresRestart { get; set; }
/// <summary>
/// 如果操作过程中发生异常,这个字段包含异常信息
/// 用于调试和错误分析
/// </summary>
public Exception? Error { get; set; }
}
// Models/DownloadProgress.cs
/// <summary>
/// 下载进度模型,用于实时报告文件下载的进度信息
/// 通过IProgress接口传递给UI层进行进度显示
/// </summary>
public class DownloadProgress
{
/// <summary>
/// 文件的总大小,单位是字节
/// 如果服务器没有提供文件大小,这个值可能是-1
/// </summary>
public long TotalBytes { get; set; }
/// <summary>
/// 已经下载的字节数
/// 这个值会从0逐渐增加到TotalBytes
/// </summary>
public long BytesDownloaded { get; set; }
/// <summary>
/// 下载进度百分比,范围是0到100
/// 计算公式:BytesDownloaded / TotalBytes * 100
/// </summary>
public double ProgressPercentage { get; set; }
/// <summary>
/// 预估的剩余下载时间
/// 根据当前的下载速度计算得出
/// </summary>
public TimeSpan EstimatedTimeRemaining { get; set; }
/// <summary>
/// 当前的下载速度,单位是KB/秒
/// 用于在UI中显示实时网速
/// </summary>
public double DownloadSpeed { get; set; }
}
2.2 文件下载器类
cs
/// <summary>
/// 文件下载器类,负责从网络下载文件并支持进度报告和重试机制
/// 这个类实现了IFileDownloader接口,支持依赖注入
/// </summary>
public class FileDownloader : IFileDownloader
{
/// <summary>
/// HTTP客户端实例,用于执行网络请求
/// 使用静态实例可以复用TCP连接,提高性能
/// </summary>
private readonly HttpClient _httpClient;
/// <summary>
/// 临时文件存储目录,用于存放下载中的文件
/// 下载完成后,文件会被移动到最终位置
/// </summary>
private readonly string _tempDirectory;
/// <summary>
/// 默认构造函数,创建新的文件下载器实例
/// 会初始化HTTP客户端和临时目录
/// </summary>
public FileDownloader()
{
var handler = new HttpClientHandler();
// 配置HttpClient,设置适当的超时时间和默认请求头
_httpClient = new HttpClient(handler)
{
Timeout = TimeSpan.FromMinutes(10) // 设置10分钟超时,适合大文件下载
};
// 设置User-Agent,让服务器识别我们的应用程序
_httpClient.DefaultRequestHeaders.Add("User-Agent", "AvaloniaApp-Updater/1.0");
// 创建应用程序专用的临时目录
_tempDirectory = Path.Combine(Path.GetTempPath(), "AvaloniaApp", "downloads");
CrossPlatformService.EnsureDirectoryExists(_tempDirectory);
}
/// <summary>
/// 从指定URL下载文件到临时位置
/// </summary>
/// <param name="url">要下载的文件URL</param>
/// <param name="progress">进度报告器,用于实时报告下载进度</param>
/// <param name="cancellationToken">取消令牌,用于取消下载操作</param>
/// <returns>下载文件的临时路径</returns>
/// <exception cref="ArgumentException">当URL为空或无效时抛出</exception>
/// <exception cref="Exception">当下载过程中发生错误时抛出</exception>
public async Task<string> DownloadFileAsync(string url, IProgress<DownloadProgress> progress = null, CancellationToken cancellationToken = default)
{
// 参数验证
if (string.IsNullOrEmpty(url))
throw new ArgumentException("下载URL不能为空");
// 从URL中提取文件名,如果无法提取则使用GUID生成唯一文件名
var fileName = GetFileNameFromUrl(url) ?? $"update_{Guid.NewGuid():N}";
var tempFilePath = Path.Combine(_tempDirectory, fileName);
try
{
// 发送HTTP GET请求,使用流模式以便实时处理数据
using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
// 确保HTTP响应状态码表示成功
response.EnsureSuccessStatusCode();
// 获取文件总大小,用于进度计算
var totalBytes = response.Content.Headers.ContentLength ?? -1L;
var canReportProgress = totalBytes != -1 && progress != null;
// 获取响应流和文件流
using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var fileStream = new FileStream(tempFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
var buffer = new byte[8192]; // 8KB缓冲区
var totalBytesRead = 0L; // 已读取的总字节数
int bytesRead; // 每次读取的字节数
var stopwatch = System.Diagnostics.Stopwatch.StartNew(); // 用于计算下载速度
// 循环读取数据直到流结束
while ((bytesRead = await contentStream.ReadAsync(buffer, cancellationToken)) != 0)
{
// 检查是否请求取消操作
cancellationToken.ThrowIfCancellationRequested();
// 将数据写入文件
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
totalBytesRead += bytesRead;
// 如果支持进度报告,计算并报告当前进度
if (canReportProgress)
{
var elapsedSeconds = stopwatch.Elapsed.TotalSeconds;
var downloadSpeed = elapsedSeconds > 0 ? totalBytesRead / elapsedSeconds / 1024 : 0; // 计算下载速度(KB/s)
var remainingTime = elapsedSeconds > 0 ? TimeSpan.FromSeconds((totalBytes - totalBytesRead) / (totalBytesRead / elapsedSeconds)) : TimeSpan.Zero;
// 创建进度报告对象并报告进度
progress.Report(new DownloadProgress
{
TotalBytes = totalBytes,
BytesDownloaded = totalBytesRead,
ProgressPercentage = (double)totalBytesRead / totalBytes * 100,
DownloadSpeed = downloadSpeed,
EstimatedTimeRemaining = remainingTime
});
}
}
return tempFilePath;
}
catch (Exception ex)
{
// 如果下载失败,清理已下载的临时文件
SafeDeleteFile(tempFilePath);
throw new Exception($"文件下载失败: {ex.Message}", ex);
}
}
/// <summary>
/// 带重试机制的文件下载方法
/// 如果下载失败,会自动重试指定次数
/// </summary>
/// <param name="url">要下载的文件URL</param>
/// <param name="localPath">文件下载后的本地保存路径</param>
/// <param name="maxRetries">最大重试次数,默认3次</param>
/// <param name="progress">进度报告器</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>是否下载成功</returns>
public async Task<bool> DownloadFileWithRetryAsync(string url, string localPath, int maxRetries = 3, IProgress<DownloadProgress> progress = null, CancellationToken cancellationToken = default)
{
// 重试循环
for (int attempt = 0; attempt < maxRetries; attempt++)
{
try
{
// 尝试下载文件到临时位置
var tempPath = await DownloadFileAsync(url, progress, cancellationToken);
// 确保目标目录存在
var targetDir = Path.GetDirectoryName(localPath);
if (!string.IsNullOrEmpty(targetDir))
{
CrossPlatformService.EnsureDirectoryExists(targetDir);
}
// 将文件从临时位置移动到最终位置
File.Move(tempPath, localPath, true);
return true;
}
catch (Exception ex) when (attempt < maxRetries - 1) // 如果不是最后一次尝试
{
// 使用指数退避策略:等待时间随重试次数指数增长
// 第一次重试等待2秒,第二次4秒,第三次8秒...
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken);
continue; // 继续下一次重试
}
}
return false; // 所有重试都失败了
}
/// <summary>
/// 从URL中提取文件名
/// 例如:从 "https://example.com/files/app-v1.2.0.zip" 中提取 "app-v1.2.0.zip"
/// </summary>
/// <param name="url">文件URL</param>
/// <returns>文件名,如果无法提取则返回null</returns>
private string GetFileNameFromUrl(string url)
{
try
{
var uri = new Uri(url);
return Path.GetFileName(uri.LocalPath);
}
catch
{
return null; // URL格式无效,返回null
}
}
/// <summary>
/// 安全删除文件,忽略删除过程中可能出现的异常
/// 这个方法用于清理临时文件,即使删除失败也不应该影响主要逻辑
/// </summary>
/// <param name="filePath">要删除的文件路径</param>
private void SafeDeleteFile(string filePath)
{
try
{
if (File.Exists(filePath))
File.Delete(filePath);
}
catch
{
// 忽略所有删除异常,确保不会影响主流程
}
}
/// <summary>
/// 释放资源,主要是HttpClient和临时目录
/// 实现IDisposable接口
/// </summary>
public void Dispose()
{
_httpClient?.Dispose(); // 释放HttpClient
// 清理临时目录
try
{
if (Directory.Exists(_tempDirectory))
{
Directory.Delete(_tempDirectory, true); // 递归删除临时目录
}
}
catch
{
// 忽略清理过程中的异常
}
}
}
// Interfaces/IFileDownloader.cs
/// <summary>
/// 文件下载器接口,定义了文件下载服务的基本功能
/// 使用接口可以方便地进行单元测试和依赖注入
/// </summary>
public interface IFileDownloader : IDisposable
{
/// <summary>
/// 从指定URL下载文件到临时位置
/// </summary>
Task<string> DownloadFileAsync(string url, IProgress<DownloadProgress> progress = null, CancellationToken cancellationToken = default);
/// <summary>
/// 带重试机制的文件下载方法
/// </summary>
Task<bool> DownloadFileWithRetryAsync(string url, string localPath, int maxRetries = 3, IProgress<DownloadProgress> progress = null, CancellationToken cancellationToken = default);
}
2.3 跨平台服务及备份服务
cs
/// <summary>
/// 跨平台服务类,提供在不同操作系统上的兼容性支持
/// 这个类封装了所有与平台相关的操作,让其他代码可以跨平台运行
/// </summary>
public static class CrossPlatformService
{
/// <summary>
/// 检查当前是否运行在Linux操作系统上
/// </summary>
public static bool IsLinux => RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
/// <summary>
/// 检查当前是否运行在Windows操作系统上
/// </summary>
public static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
/// <summary>
/// 检查当前是否运行在macOS操作系统上
/// </summary>
public static bool IsOSX => RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
/// <summary>
/// 获取应用程序数据存储目录
/// 这个目录用于存储配置文件、备份文件等持久化数据
/// </summary>
/// <param name="appName">应用程序名称,用于创建子目录</param>
/// <returns>平台特定的应用程序数据目录路径</returns>
public static string GetAppDataPath(string appName)
{
string basePath;
if (IsLinux || IsOSX)
{
// Linux/macOS系统:使用 ~/.local/share/AppName 目录
// 这是Linux和macOS上存储应用程序数据的标准位置
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
basePath = Path.Combine(home, ".local", "share", appName);
}
else
{
// Windows系统:使用 AppData/Local/AppName 目录
// 这是Windows上存储应用程序数据的标准位置
basePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
appName);
}
// 确保目录存在,如果不存在则创建
EnsureDirectoryExists(basePath);
return basePath;
}
/// <summary>
/// 获取当前正在运行的应用程序的完整路径
/// 这个方法可以正确处理各种启动方式(直接运行、通过符号链接等)
/// </summary>
/// <returns>当前应用程序的可执行文件路径</returns>
/// <exception cref="FileNotFoundException">当无法确定应用程序路径时抛出</exception>
public static string GetApplicationPath()
{
// 首先尝试使用Environment.ProcessPath,这是.NET 5+推荐的方式
var processPath = Environment.ProcessPath;
if (!string.IsNullOrEmpty(processPath) && File.Exists(processPath))
return processPath;
// 在Linux系统上,可以通过/proc/self/exe获取当前进程的可执行文件路径
if (IsLinux && File.Exists("/proc/self/exe"))
{
return "/proc/self/exe";
}
throw new FileNotFoundException("无法确定应用程序路径");
}
/// <summary>
/// 获取当前应用程序所在的目录路径
/// 这个目录通常包含应用程序的可执行文件和依赖项
/// </summary>
/// <returns>应用程序目录路径</returns>
public static string GetApplicationDirectory()
{
return Path.GetDirectoryName(GetApplicationPath()) ?? AppContext.BaseDirectory;
}
/// <summary>
/// 获取当前应用程序的可执行文件名(不包含路径)
/// 例如:在Windows上返回"MyApp.exe",在Linux上返回"MyApp"
/// </summary>
/// <returns>应用程序文件名</returns>
public static string GetApplicationName()
{
return Path.GetFileName(GetApplicationPath());
}
/// <summary>
/// 确保指定的目录存在,如果不存在则创建它
/// 这个方法会递归创建所有必要的父目录
/// </summary>
/// <param name="path">要确保存在的目录路径</param>
public static void EnsureDirectoryExists(string path)
{
if (!Directory.Exists(path))
{
Directory.CreateDirectory(path);
}
}
/// <summary>
/// 设置文件为可执行权限(仅在Linux和macOS上有效)
/// 在Windows上,这个方法不会执行任何操作
/// </summary>
/// <param name="filePath">要设置权限的文件路径</param>
/// <returns>表示异步操作的任务</returns>
/// <exception cref="Exception">当设置权限失败时抛出</exception>
public static async Task SetFileExecutable(string filePath)
{
// 只在Linux和macOS上设置执行权限
if (IsLinux || IsOSX)
{
try
{
// 使用chmod命令设置文件为可执行
// 755权限表示:所有者可读可写可执行,组用户和其他用户可读可执行
using var process = new Process();
process.StartInfo.FileName = "chmod";
process.StartInfo.Arguments = $"+x \"{filePath}\"";
process.StartInfo.UseShellExecute = false; // 不使用系统shell
process.StartInfo.CreateNoWindow = true; // 不创建窗口
process.StartInfo.RedirectStandardOutput = true; // 重定向标准输出
process.StartInfo.RedirectStandardError = true; // 重定向错误输出
process.Start();
await process.WaitForExitAsync();
// 检查命令执行是否成功
if (process.ExitCode != 0)
{
var error = await process.StandardError.ReadToEndAsync();
throw new Exception($"设置文件权限失败: {error}");
}
}
catch (Exception ex)
{
throw new Exception($"设置执行权限失败: {ex.Message}");
}
}
}
/// <summary>
/// 获取当前平台的可执行文件扩展名
/// </summary>
/// <returns>平台特定的文件扩展名</returns>
public static string GetPlatformSpecificExtension()
{
if (IsWindows) return ".exe"; // Windows可执行文件扩展名
if (IsLinux) return ""; // Linux可执行文件通常没有扩展名
if (IsOSX) return ".app"; // macOS应用程序包扩展名
return "";
}
}
/// <summary>
/// 备份管理器
/// </summary>
public class BackupManager
{
private readonly string _appName;
private readonly string _backupDirectory;
private const int MaxBackups = 5;
public BackupManager(string appName)
{
_appName = appName;
_backupDirectory = Path.Combine(
CrossPlatformService.GetAppDataPath(appName),
"backups");
CrossPlatformService.EnsureDirectoryExists(_backupDirectory);
}
public async Task CreateBackupAsync()
{
var currentAppPath = CrossPlatformService.GetApplicationPath();
var backupPath = GetBackupPath("previous");
if (File.Exists(currentAppPath))
{
// 复制当前程序文件
File.Copy(currentAppPath, backupPath, true);
// 备份配置文件
await BackupConfigurationFilesAsync();
// 清理旧备份
await CleanupOldBackupsAsync();
}
}
public async Task<bool> RollbackAsync()
{
try
{
var backupPath = GetBackupPath("previous");
var currentAppPath = CrossPlatformService.GetApplicationPath();
if (File.Exists(backupPath))
{
// 恢复程序文件
File.Copy(backupPath, currentAppPath, true);
// 恢复配置文件
await RestoreConfigurationFilesAsync();
// 设置执行权限(Linux/macOS)
await CrossPlatformService.SetFileExecutable(currentAppPath);
return true;
}
return false;
}
catch (Exception ex)
{
throw new Exception($"回退失败: {ex.Message}", ex);
}
}
public string GetLatestBackupPath()
{
return GetBackupPath("previous");
}
private string GetBackupPath(string suffix)
{
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
return Path.Combine(_backupDirectory, $"{_appName}.{suffix}.{timestamp}");
}
private async Task BackupConfigurationFilesAsync()
{
var appDir = CrossPlatformService.GetApplicationDirectory();
var configFiles = Directory.GetFiles(appDir, "*.json")
.Concat(Directory.GetFiles(appDir, "*.config"))
.Concat(Directory.GetFiles(appDir, "*.xml"));
foreach (var configFile in configFiles)
{
var fileName = Path.GetFileName(configFile);
var backupPath = Path.Combine(_backupDirectory, fileName);
File.Copy(configFile, backupPath, true);
}
await Task.CompletedTask;
}
private async Task RestoreConfigurationFilesAsync()
{
var backupFiles = Directory.GetFiles(_backupDirectory)
.Where(f => !f.Contains(_appName)); // 排除程序备份文件
foreach (var backupFile in backupFiles)
{
var fileName = Path.GetFileName(backupFile);
var targetPath = Path.Combine(CrossPlatformService.GetApplicationDirectory(), fileName);
File.Copy(backupFile, targetPath, true);
}
await Task.CompletedTask;
}
private async Task CleanupOldBackupsAsync()
{
try
{
var backupFiles = Directory.GetFiles(_backupDirectory, $"{_appName}.*")
.Select(f => new FileInfo(f))
.OrderByDescending(f => f.CreationTime)
.ToList();
if (backupFiles.Count > MaxBackups)
{
foreach (var oldBackup in backupFiles.Skip(MaxBackups))
{
oldBackup.Delete();
}
}
await Task.CompletedTask;
}
catch
{
// 忽略清理错误
}
}
}
2.4 更新管理器
cs
/// <summary>
/// 更新管理器
/// </summary>
public class UpdateManager : IUpdateService
{
private readonly string _appName;
/// <summary>
/// 当前版本
/// </summary>
private readonly string _currentVersion;
private readonly string _updateBaseUrl;
private readonly BackupManager _backupManager;
private readonly IFileDownloader _fileDownloader;
public UpdateManager(string appName, string currentVersion, string updateBaseUrl, IFileDownloader fileDownloader = null)
{
_appName = appName;
_currentVersion = currentVersion;
_updateBaseUrl = updateBaseUrl;
_fileDownloader = fileDownloader ?? new FileDownloader();
_backupManager = new BackupManager(appName);
}
public async Task<UpdateResult> CheckForUpdatesAsync()
{
try
{
using var httpClient = new HttpClient();
var versionInfoUrl = _updateBaseUrl;
var response = await httpClient.GetStringAsync(versionInfoUrl);
var serverVersion = JsonSerializer.Deserialize<VersionInfo>(response);
if (serverVersion == null)
return new UpdateResult { Success = false, Message = "无法解析版本信息" };
// 验证版本格式
if (!Version.TryParse(serverVersion.Version, out var latestVersion))
return new UpdateResult { Success = false, Message = "服务器版本格式无效" };
if (!Version.TryParse(_currentVersion, out var currentVersion))
return new UpdateResult { Success = false, Message = "当前版本格式无效" };
if (latestVersion > currentVersion)
{
return new UpdateResult
{
Success = true,
NewVersion = serverVersion,
RequiresRestart = true,
Message = $"发现新版本: {serverVersion.Version}"
};
}
return new UpdateResult { Success = true, Message = "已是最新版本" };
}
catch (HttpRequestException ex)
{
return new UpdateResult { Success = false, Message = $"网络错误: {ex.Message}" };
}
catch (Exception ex)
{
return new UpdateResult { Success = false, Message = $"检查更新失败: {ex.Message}", Error = ex };
}
}
public async Task<UpdateResult> PerformUpdateAsync(VersionInfo versionInfo, IProgress<DownloadProgress> progress = null)
{
try
{
// 1. 备份当前版本
await _backupManager.CreateBackupAsync();
// 2. 下载新版本
string tempFilePath;
try
{
tempFilePath = await _fileDownloader.DownloadFileAsync(versionInfo.DownloadUrl, progress);
}
catch (Exception ex)
{
throw new Exception($"下载失败: {ex.Message}", ex);
}
// 3. 验证文件完整性
if (!string.IsNullOrEmpty(versionInfo.Sha256Hash) && !await VerifyFileHashAsync(tempFilePath, versionInfo.Sha256Hash))
{
throw new Exception("文件校验失败,文件可能已损坏或被篡改");
}
// 4. 准备更新
await PrepareUpdateAsync(tempFilePath);
return new UpdateResult
{
Success = true,
Message = "更新准备完成,重启后生效",
RequiresRestart = true
};
}
catch (Exception ex)
{
// 更新失败,尝试回退
try
{
await _backupManager.RollbackAsync();
}
catch (Exception rollbackEx)
{
throw new AggregateException($"更新失败: {ex.Message}",
new Exception($"回退也失败: {rollbackEx.Message}"));
}
throw new Exception($"更新失败,已回退到前一版本: {ex.Message}", ex);
}
}
private async Task<bool> VerifyFileHashAsync(string filePath, string expectedHash)
{
try
{
using var stream = File.OpenRead(filePath);
using var sha256 = SHA256.Create();
var hash = await sha256.ComputeHashAsync(stream);
var actualHash = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
return actualHash == expectedHash.ToLowerInvariant();
}
catch
{
return false;
}
}
private async Task PrepareUpdateAsync(string newAppPath)
{
var appDirectory = CrossPlatformService.GetApplicationDirectory();
var currentAppPath = CrossPlatformService.GetApplicationPath();
// 创建更新标记文件,包含新版本路径
var updateInfo = new
{
NewAppPath = newAppPath,
CurrentAppPath = currentAppPath,
BackupPath = _backupManager.GetLatestBackupPath(),
Timestamp = DateTime.UtcNow
};
var updateInfoPath = Path.Combine(appDirectory, "update_info.json");
await File.WriteAllTextAsync(updateInfoPath, JsonSerializer.Serialize(updateInfo, new JsonSerializerOptions { WriteIndented = true }));
// 创建重启标记
var restartFlagPath = Path.Combine(appDirectory, "restart.flag");
await File.WriteAllTextAsync(restartFlagPath, "true");
}
public void Dispose()
{
_fileDownloader?.Dispose();
}
}
// Interfaces/IUpdateService.cs
public interface IUpdateService : IDisposable
{
Task<UpdateResult> CheckForUpdatesAsync();
Task<UpdateResult> PerformUpdateAsync(VersionInfo versionInfo, IProgress<DownloadProgress> progress = null);
}
3. 实际开发过程调用
在启动程序时,检查当前版本是否为最新版本,然后选择是否更新
cs
private readonly UpdateManager _updateManager;
private bool _isCheckingForUpdates;
private string _updateStatus = "就绪";
private double _downloadProgress;
/// <summary>
/// 更新状态文本,用于在UI中显示当前更新状态
/// </summary>
public string UpdateStatus
{
get => _updateStatus;
set => SetProperty(ref _updateStatus, value);//这里继承自Prism.Mvvm的BindableBase类的方法,用于自动更新前端界面数据
}
/// <summary>
/// 下载进度(0-100),用于进度条显示
/// </summary>
public double DownloadProgress
{
get => _downloadProgress;
set => SetProperty(ref _downloadProgress, value);
}
private double _downloadSpeed;
/// <summary>
/// 下载速度(KB/s),用于显示实时下载速度
/// </summary>
public double DownloadSpeed
{
get => _downloadSpeed;
set => SetProperty(ref _downloadSpeed, value);
}
/// <summary>
/// 检查更新命令 - 用户点击"检查更新"按钮时执行
/// </summary>
public ReactiveCommand<System.Reactive.Unit, System.Reactive.Unit> CheckForUpdatesCommand { get; }
public ExecuteMainVM(IEventAggregator eventAggregator)
{
//假设这是你的主界面ViewModel
// 初始化更新管理器
// 参数说明:
// - "YourAvaloniaApp": 应用程序名称
// - GetCurrentVersion(): 当前版本号
// - "https://your-server.com/updates": 更新服务器地址
IsUpdate = true;
_updateManager = new UpdateManager(
"HYPMView.Desktop",
GetCurrentVersion(),
BaseClass.SYS.UpgradeUrl);
// 初始化检查更新命令
// 使用ReactiveCommand实现响应式UI
CheckForUpdatesCommand = ReactiveCommand.CreateFromTask(
execute: CheckForUpdatesAsync,
canExecute: this.WhenAnyValue(x => x._isCheckingForUpdates)
.Select(checking => !checking) // 正在检查时禁用按钮
);
//// 应用程序启动时自动检查更新(可选)
Observable.StartAsync(CheckForUpdatesAsync);
}
/// <summary>
/// 检查更新异步方法
/// 这个方法会在后台执行,不会阻塞UI
/// </summary>
private async Task CheckForUpdatesAsync()
{
if (_isCheckingForUpdates) return;
_isCheckingForUpdates = true;
UpdateStatus = "正在检查更新...";
try
{
// 调用更新管理器检查是否有新版本
var result = await _updateManager.CheckForUpdatesAsync();
if (result.Success && result.NewVersion != null)
{
// 发现新版本,显示更新对话框
UpdateStatus = $"发现新版本: {result.NewVersion.Version}";
// 包含版本号、更新说明、文件大小等信息
var message = $"发现新版本 {result.NewVersion.Version}\n\n" +
$"更新内容:\n{result.NewVersion.ReleaseNotes}\n\n" +
$"文件大小: {FormatFileSize(result.NewVersion.FileSize)}\n\n" +
$"是否立即下载并安装更新?";
MessageTipWindow messageTip = new MessageTipWindow();
messageTip.setVm(message, "25", "更新提醒!");
var resultUpdate= await messageTip.ShowDialog<bool>(ExecuteMainFrame.CurrentWindow);//这里是自定义的消息提示框,可以换成自己的提示框,后面类似弹窗做同样的处理
if (resultUpdate)
{
await DownloadAndInstallUpdateAsync(result.NewVersion);
}
else
{
UpdateStatus = "用户取消更新";
await Task.Delay(1000);
IsUpdate = false;
}
//await ShowUpdateDialogAsync(result.NewVersion);
}
else if (result.Success)
{
// 已经是最新版本
UpdateStatus = "已是最新版本";
await Task.Delay(1000);
IsUpdate = false;
}
else
{
// 检查更新失败
UpdateStatus = $"检查更新失败: {result.Message}";
IsUpdate = false;
}
}
catch (Exception ex)
{
UpdateStatus = $"检查更新时发生错误: {ex.Message}";
}
finally
{
_isCheckingForUpdates = false;
}
}
private async Task DownloadAndInstallUpdateAsync(HYPMView.AutoUpdate.VersionInfo versionInfo)
{
UpdateStatus = "开始下载更新...";
DownloadProgress = 0;
try
{
var currentDir = AppContext.BaseDirectory;
// 🎯 根据平台选择下载URL
var downloadUrl = GetPlatformSpecificDownloadUrl(versionInfo);
var packagePath = Path.Combine(currentDir, GetPackageFileName());
//这里是自定义的日志记录服务,应该替换成自己的日志记录服务或者直接控制台输出,后面类似的内容做同样的处理
BaseClass.Logger.Info($"开始下载更新包,URL: {downloadUrl}");
BaseClass.Logger.Info($"更新包保存路径: {packagePath}");
// 下载文件
using var httpClient = new HttpClient();
using var response = await httpClient.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
var totalBytes = response.Content.Headers.ContentLength ?? -1L;
var totalBytesRead = 0L;
// 🎯 添加下载速度计算相关变量
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var lastUpdateTime = DateTime.Now;
var lastBytesRead = 0L;
var speedUpdateInterval = TimeSpan.FromMilliseconds(300); // 每500ms更新一次速度
using (var contentStream = await response.Content.ReadAsStreamAsync())
using (var fileStream = new FileStream(packagePath, FileMode.Create, FileAccess.Write, FileShare.None))
{
var buffer = new byte[8192];
int bytesRead;
while ((bytesRead = await contentStream.ReadAsync(buffer)) > 0)
{
await fileStream.WriteAsync(buffer, 0, bytesRead);
totalBytesRead += bytesRead;
// 🎯 计算下载速度
var currentTime = DateTime.Now;
var timeElapsed = currentTime - lastUpdateTime;
if (timeElapsed >= speedUpdateInterval)
{
var bytesSinceLastUpdate = totalBytesRead - lastBytesRead;
var speedKBps = bytesSinceLastUpdate / timeElapsed.TotalSeconds / 1024.0;
DownloadSpeed = Math.Round(speedKBps, 1);
lastBytesRead = totalBytesRead;
lastUpdateTime = currentTime;
}
// 更新进度
if (totalBytes > 0)
{
var percentage = (double)totalBytesRead / totalBytes * 100;
DownloadProgress = percentage;
// 🎯 在状态信息中显示下载速度
UpdateStatus = $"下载中: {percentage:F1}% (速度: {DownloadSpeed} KB/s)";
}
}
}
BaseClass.Logger.Info($"更新包下载完成,大小: {totalBytesRead} 字节");
// 🎯 下载完成后直接创建解压脚本并退出程序
UpdateStatus = "下载完成,准备重启解压...";
DownloadProgress = 100;
BaseClass.Logger.Info("创建跨平台解压脚本...");
await CreateCrossPlatformExtractScript(packagePath, currentDir, versionInfo.Version);
BaseClass.Logger.Info("准备退出程序进行解压...");
await Task.Delay(1000);
// 🎯 调用重启方法执行解压脚本并退出程序
RestartApplication();
}
catch (Exception ex)
{
BaseClass.Logger.Error($"更新失败: {ex.Message}");
UpdateStatus = $"更新失败: {ex.Message}";
DownloadProgress = 0;
IsUpdate = false;
}
}
/// <summary>
/// 获取包文件名(根据平台)
/// </summary>
private string GetPackageFileName()
{
//if (OperatingSystem.IsWindows())
// return "update.zip";
//else
// return "update.tar.gz";
return "update.zip";
}
/// <summary>
/// 获取平台特定的下载URL
/// </summary>
private string GetPlatformSpecificDownloadUrl(HYPMView.AutoUpdate.VersionInfo versionInfo)
{
var platform = GetPlatformIdentifier();
// 如果版本信息已经有特定平台的URL,使用它
if (versionInfo is ExtendedVersionInfo extendedInfo && extendedInfo.PlatformUrls.ContainsKey(platform))
{
return extendedInfo.PlatformUrls[platform];
}
// 否则从基础URL构造
var baseUrl = versionInfo.DownloadUrl;
if (baseUrl.Contains("{platform}"))
{
return baseUrl
.Replace("{platform}", platform)
.Replace("{version}", versionInfo.Version);
}
// 如果URL不包含平台占位符,直接返回
return baseUrl;
}
/// <summary>
/// 获取平台标识符
/// </summary>
private string GetPlatformIdentifier()
{
if (OperatingSystem.IsWindows()) return "win-x64";
if (OperatingSystem.IsLinux()) return "linux-x64";
if (OperatingSystem.IsMacOS()) return "osx-x64";
if (OperatingSystem.IsAndroid()) return "android";
if (OperatingSystem.IsIOS()) return "ios";
return "unknown";
}
/// <summary>
/// 创建跨平台解压脚本
/// </summary>
private async Task CreateCrossPlatformExtractScript(string packagePath, string extractDir, string version)
{
try
{
BaseClass.Logger.Info($"创建解压脚本,版本: {version}, 包路径: {packagePath}");
if (OperatingSystem.IsWindows())
{
await CreateWindowsExtractScript(packagePath, extractDir, version);
}
else if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
{
await CreateLinuxExtractScript(packagePath, extractDir, version);
}
else
{
await CreateLinuxExtractScript(packagePath, extractDir, version);
}
BaseClass.Logger.Info("解压脚本创建成功");
}
catch (Exception ex)
{
BaseClass.Logger.Error($"创建解压脚本失败: {ex.Message}");
//throw;
}
}
/// <summary>
/// 创建Windows解压脚本
/// </summary>
private async Task CreateWindowsExtractScript(string packagePath, string extractDir, string version)
{
var batPath = Path.Combine(extractDir, "apply_update.bat");
var batContent = $@"
@echo off
chcp 65001 >nul
echo [$(date)] 开始应用更新 v{version}... >> update.log
echo 等待程序完全退出...
timeout /t 3 /nobreak >nul
echo 正在解压更新包...
echo [$(date)] 开始解压更新包 >> update.log
";
// 根据文件类型选择解压命令
if (packagePath.EndsWith(".zip"))
{
batContent += $@"tar -xf ""{packagePath}"" -C ""{extractDir}""";
}
else if (packagePath.EndsWith(".tar.gz"))
{
batContent += $@"tar -xzf ""{packagePath}"" -C ""{extractDir}""";
}
batContent += $@"
if %errorlevel% equ 0 (
echo [$(date)] 解压成功 >> update.log
echo 清理临时文件...
del ""{packagePath}""
del ""%~f0""
echo [$(date)] 临时文件清理完成 >> update.log
echo 启动应用程序...
echo [$(date)] 启动主程序 >> update.log
cd /d ""{extractDir}""
start """" ""{Environment.ProcessPath}""
) else (
echo [$(date)] 解压失败,错误代码: %errorlevel% >> update.log
echo 解压失败,请手动解压: {packagePath}
echo 到目录: {extractDir}
pause
)
";
await File.WriteAllTextAsync(batPath, batContent, Encoding.UTF8);
BaseClass.Logger.Info($"Windows解压脚本已创建: {batPath}");
}
/// <summary>
/// 简化版Linux解压脚本创建
/// </summary>
private async Task CreateLinuxExtractScript(string packagePath, string extractDir, string version)
{
var shPath = Path.Combine(extractDir, "apply_update.sh");
// 🎯 逐行构建脚本,确保Unix换行符
var scriptLines = new List<string>
{
"#!/bin/bash",
"",
"# 设置日志文件",
$"LOG_FILE=\"{extractDir}/update.log\"",
$"echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 开始应用更新 v{version}...\" >> $LOG_FILE",
"",
$"echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 当前目录: $(pwd)\" >> $LOG_FILE",
$"echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 包路径: {packagePath}\" >> $LOG_FILE",
$"echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 解压目录: {extractDir}\" >> $LOG_FILE",
"",
"# 等待程序完全退出",
$"echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 等待程序完全退出...\" >> $LOG_FILE",
"sleep 5",
"",
"# 检查包文件是否存在",
$"if [ ! -f \"{packagePath}\" ]; then",
$" echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 错误: 更新包不存在: {packagePath}\" >> $LOG_FILE",
" exit 1",
"fi",
"",
$"echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 开始解压更新包...\" >> $LOG_FILE",
"",
"# 解压zip文件",
$"echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 使用unzip解压zip文件\" >> $LOG_FILE",
$"unzip -o \"{packagePath}\" -d \"{extractDir}\"",
"EXIT_CODE=$?",
"",
$"echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 解压退出代码: $EXIT_CODE\" >> $LOG_FILE",
"",
"if [ $EXIT_CODE -eq 0 ]; then",
" echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 解压成功\" >> $LOG_FILE",
" ",
" echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 清理临时文件...\" >> $LOG_FILE",
$" rm -f \"{packagePath}\"",
" rm -f \"$0\"",
" ",
" echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 设置执行权限...\" >> $LOG_FILE",
$" chmod +x \"{extractDir}/HYPMView.Desktop\"",
" ",
" echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 启动应用程序...\" >> $LOG_FILE",
$" cd \"{extractDir}\"",
" ./HYPMView.Desktop",
" ",
" echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 应用程序已启动\" >> $LOG_FILE",
"else",
" echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 解压失败\" >> $LOG_FILE",
$" echo \"请手动解压: {packagePath}\" >> $LOG_FILE",
$" echo \"到目录: {extractDir}\" >> $LOG_FILE",
" exit 1",
"fi"
};
// 🎯 使用Unix换行符连接所有行
var shContent = string.Join("\n", scriptLines);
// 写入文件,使用UTF-8无BOM编码
await File.WriteAllTextAsync(shPath, shContent, new UTF8Encoding(false));
// 设置执行权限
await SetFileExecutable(shPath);
BaseClass.Logger.Info($"Linux解压脚本已创建: {shPath}");
}
/// <summary>
/// 设置文件可执行权限(Linux)
/// </summary>
private async Task SetFileExecutable(string filePath)
{
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
{
try
{
using var process = new Process();
process.StartInfo.FileName = "chmod";
process.StartInfo.Arguments = $"+x \"{filePath}\"";
process.StartInfo.UseShellExecute = false;
process.StartInfo.CreateNoWindow = true;
process.Start();
await process.WaitForExitAsync();
BaseClass.Logger.Info($"文件执行权限设置成功: {filePath}");
}
catch (Exception ex)
{
BaseClass.Logger.Error($"设置文件执行权限失败: {ex.Message}");
}
}
}
// 🎯 扩展版本信息类(如果需要)
public class ExtendedVersionInfo : HYPMView.AutoUpdate.VersionInfo
{
public Dictionary<string, string> PlatformUrls { get; set; } = new Dictionary<string, string>();
}
/// <summary>
/// 重启当前应用程序
/// </summary>
private void RestartApplication()
{
try
{
var currentDir = AppContext.BaseDirectory;
BaseClass.Logger.Info("开始执行重启流程");
if (OperatingSystem.IsWindows())
{
var batPath = Path.Combine(currentDir, "apply_update.bat");
if (File.Exists(batPath))
{
BaseClass.Logger.Info("执行Windows解压脚本");
Process.Start(new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/c \"{batPath}\"",
UseShellExecute = true,
CreateNoWindow = false
});
}
else
{
BaseClass.Logger.Warn("解压脚本不存在,直接重启");
Process.Start(new ProcessStartInfo
{
FileName = Environment.ProcessPath,
WorkingDirectory = currentDir,
UseShellExecute = true
});
}
}
else
{
var shPath = Path.Combine(currentDir, "apply_update.sh");
if (File.Exists(shPath))
{
BaseClass.Logger.Info("执行Linux解压脚本");
// 🎯 修复:使用bash直接执行脚本,而不是nohup
Process.Start(new ProcessStartInfo
{
FileName = "bash",
Arguments = $"\"{shPath}\"",
UseShellExecute = true, // 使用Shell执行
WorkingDirectory = currentDir
});
}
else
{
BaseClass.Logger.Warn("解压脚本不存在,直接重启");
Process.Start(new ProcessStartInfo
{
FileName = Environment.ProcessPath,
WorkingDirectory = currentDir,
UseShellExecute = true
});
}
}
BaseClass.Logger.Info("程序退出,等待解压脚本执行");
Environment.Exit(0);
}
catch (Exception ex)
{
BaseClass.Logger.Error($"重启失败: {ex.Message}");
UpdateStatus = $"重启失败,请手动重启应用。错误: {ex.Message}";
}
}
/// <summary>
/// 获取当前应用程序版本
/// </summary>
/// <returns>当前版本字符串</returns>
private string GetCurrentVersion()
{
return VersionDto.Version;
}
/// <summary>
/// 格式化文件大小显示
/// </summary>
/// <param name="bytes">字节数</param>
/// <returns>格式化的文件大小字符串</returns>
private string FormatFileSize(long bytes)
{
string[] sizes = { "B", "KB", "MB", "GB" };
int order = 0;
double len = bytes;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len = len / 1024;
}
return $"{len:0.##} {sizes[order]}";
}