diff --git a/App/Updates/CheckUpdateService.cs b/App/Updates/CheckUpdateService.cs new file mode 100644 index 00000000..aafdb035 --- /dev/null +++ b/App/Updates/CheckUpdateService.cs @@ -0,0 +1,152 @@ +using PCL.Core.App.Updates.Sources; +using PCL.Core.UI; +using System; +using System.IO; +using System.Threading.Tasks; + +namespace PCL.Core.App.Updates; + +[LifecycleService(LifecycleState.Running)] +[LifecycleScope("check-update", "检查更新")] +public sealed partial class CheckUpdateService +{ + private static readonly SourceController _SourceController = new([ + new UpdateMinioSource("https://s3.pysio.online/pcl2-ce/", "Pysio"), + new UpdateMinioSource("https://staticassets.naids.com/resources/pclce/", "Naids") + ]); + + public static VersionData? LatestVersion { get; private set; } + + public static bool IsUpdateDownloaded { get; private set; } + + [LifecycleStart] + private static async Task _Start() + { + if (Config.System.Update.UpdateMode == 3) + { + Context.Info("更新模式为禁用,跳过检查"); + return; + } + + Context.Info("检查更新中..."); + if (!await TryCheckUpdate() || LatestVersion is null) return; + + if (!LatestVersion.IsAvailable) + { + Context.Info("已经是最新版本,跳过更新"); + return; + } + + Context.Info($"发现新版本: {LatestVersion.Version.Code}, 准备更新"); + + if (Config.System.Update.UpdateMode == 2 && !_PromptUpdate()) return; + + if (!await TryDownloadUpdate()) return; + + if (Config.System.Update.UpdateMode == 1 && !_PromptInstall()) return; + + Context.Info("准备重启并安装..."); + UpdateHelper.Restart(true, true); + } + + #region Public Methods + + public static async Task TryCheckUpdate() + { + try + { + LatestVersion = await _SourceController.CheckUpdateAsync().ConfigureAwait(false); + return true; + } + catch (InvalidOperationException ex) + { + if (ex.Message.Contains("不可用")) + { + Context.Warn("所有更新源均不可用", ex); + HintWrapper.Show("所有更新源均不可用,可能是网络问题", HintTheme.Error); + } + else + { + Context.Warn("检查更新时发生未知异常", ex); + HintWrapper.Show("检查更新时发生未知异常,可能是网络问题", HintTheme.Error); + } + } + catch (Exception ex) + { + Context.Warn("检查更新时发生未知异常", ex); + HintWrapper.Show("检查更新时发生未知异常,可能是网络问题", HintTheme.Error); + } + return false; + } + + public static async Task TryDownloadUpdate() + { + Context.Info("下载更新包中..."); + try + { + var outputPath = Path.Combine( + Basics.ExecutableDirectory, + "PCL", + "Plain Craft Launcher Community Edition.exe"); + if (LatestVersion == null) return false; + await _SourceController.DownloadAsync(outputPath).ConfigureAwait(false); + Context.Info("更新包下载完成"); + IsUpdateDownloaded = true; + return true; + } + catch (InvalidOperationException ex) + { + if (ex.Message.Contains("不可用")) + { + Context.Warn("所有更新源均不可用", ex); + HintWrapper.Show("所有更新源均不可用,可能是网络问题", HintTheme.Error); + } + else + { + Context.Warn("下载更新包时发生未知异常", ex); + HintWrapper.Show("下载更新包时发生未知异常,可能是网络问题", HintTheme.Error); + } + } + catch (Exception ex) + { + Context.Warn("下载更新包时发生未知异常", ex); + HintWrapper.Show("下载更新包时发生未知异常,可能是网络问题", HintTheme.Error); + } + return false; + } + + #endregion + + #region Prompt Wrappers + + private static bool _PromptUpdate() + { + if (LatestVersion == null) return false; + + if (MsgBoxWrapper.Show( + $"启动器有新版本可用 ({Basics.VersionName} -> {LatestVersion.Version.Name})\r\n" + + $"是否立即下载并安装?\r\n" + + "你也可以稍后在 设置 -> 检查更新 界面中更新。", + "发现新版本", MsgBoxTheme.Info, true, "立刻更新", "以后再说") == 1) return true; + + Context.Info("用户取消更新"); + return false; + } + + private static bool _PromptInstall() + { + if (LatestVersion == null) return false; + if (!IsUpdateDownloaded) return false; + + if (MsgBoxWrapper.Show( + $"启动器有新版本可用 ({Basics.VersionName} -> {LatestVersion.Version.Name})\r\n" + + $"已自动下载,是否立即安装?\r\n" + + "你也可以稍后在 设置 -> 检查更新 界面中安装。", + "发现新版本", MsgBoxTheme.Info, true, "立刻更新", "以后再说") == 1) return true; + + Context.Info("用户取消安装"); + return false; + } + + #endregion +} \ No newline at end of file diff --git a/App/Updates/Sources/DataModel.cs b/App/Updates/Sources/DataModel.cs new file mode 100644 index 00000000..1466b5c2 --- /dev/null +++ b/App/Updates/Sources/DataModel.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; +using PCL.Core.Utils; + +namespace PCL.Core.App.Updates.Sources; + +public sealed record VersionInfoData( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("code")] int Code +); + +public sealed record VersionData ( + [property: JsonPropertyName("version")] VersionInfoData Version, + [property: JsonPropertyName("sha256")] string Sha256, + [property: JsonPropertyName("changelog")] string ChangeLog, + [property: JsonPropertyName("patches")] string[] Patches, + [property: JsonPropertyName("downloads")] string[] Downloads +) { + public bool IsAvailable => Version.Code > Basics.VersionCode && + SemVer.Parse(Version.Name) > SemVer.Parse(Basics.VersionName); +} + +public record AnnouncementsList( + [property: JsonPropertyName("content")] AnnouncementContent[] Contents +); + +public record AnnouncementContent( + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("detail")] string Detail, + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("date")] string Date, + [property: JsonPropertyName("btn1")] AnnouncementBtnInfo? Btn1, + [property: JsonPropertyName("btn2")] AnnouncementBtnInfo? Btn2 +); + +public record AnnouncementBtnInfo ( + [property: JsonPropertyName("text")] string Text, + [property: JsonPropertyName("command")] string Command, + [property: JsonPropertyName("command_paramter")] string CommandParameter +); \ No newline at end of file diff --git a/App/Updates/Sources/IUpdateSource.cs b/App/Updates/Sources/IUpdateSource.cs new file mode 100644 index 00000000..f5e2f297 --- /dev/null +++ b/App/Updates/Sources/IUpdateSource.cs @@ -0,0 +1,34 @@ +using System.Threading.Tasks; + +namespace PCL.Core.App.Updates.Sources; + +public interface IUpdateSource +{ + /// + /// 检查更新 + /// + /// 检查更新结果 + public Task CheckUpdateAsync(); + + /// + /// 获取版本公告列表 + /// + /// 版本公告列表 + public Task GetAnnouncementAsync(); + + /// + /// 下载更新文件 + /// + /// 输出路径 + public Task DownloadAsync(string outputPath); + + /// + /// 更新源名称 + /// + public string SourceName { get; } + + /// + /// 更新源是否可用 + /// + public bool IsAvailable { get; } +} \ No newline at end of file diff --git a/App/Updates/Sources/SourceController.cs b/App/Updates/Sources/SourceController.cs new file mode 100644 index 00000000..64b90f87 --- /dev/null +++ b/App/Updates/Sources/SourceController.cs @@ -0,0 +1,118 @@ +using PCL.Core.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace PCL.Core.App.Updates.Sources; + +/// +/// 管理多个更新源,尝试找到可用源并调用。 +/// +public sealed class SourceController +{ + private readonly List _availableSources; + + private readonly SemaphoreSlim _semaphore = new(1, 1); + + /// + /// 初始化并过滤出可用的更新源。 + /// + public SourceController(IEnumerable sources) + { + _availableSources = sources + .Where(s => s.IsAvailable) + .ToList(); + } + + /// + /// 尝试使用当前源处理操作,若失败则遍历其他可用源直至成功或无可用源。 + /// + /// 指定操作 + /// 返回类型 + /// 操作返回值 + /// 所有更新源均不可用时抛出 + private async Task _TryFindSourceAsync(Func> action) + { + await _semaphore.WaitAsync().ConfigureAwait(false); + try + { + foreach (var source in _availableSources) + { + try + { + var res = await action(source).ConfigureAwait(false); + _LogInfo($"源 {source.SourceName} 处理成功"); + return res; + } + catch (Exception ex) + { + _LogWarning($"源 {source.SourceName} 不可用,使用下一个源", ex); + } + } + + throw new InvalidOperationException("所有源均不可用"); + } + finally + { + _semaphore.Release(); + } + } + + /// + /// 尝试使用当前源处理操作,若失败则遍历其他可用源直至成功或无可用源。 + /// + /// 指定操作 + /// 所有更新源均不可用时抛出 + private async Task _TryFindSourceAsync(Func action) + { + await _TryFindSourceAsync(async s => + { + await action(s).ConfigureAwait(false); + return null; + }).ConfigureAwait(false); + } + + /// + /// 检查是否有新版本并返回结果。 + /// + public Task CheckUpdateAsync() => + _TryFindSourceAsync(s => s.CheckUpdateAsync()); + + /// + /// 获取公告列表。 + /// + public Task GetAnnouncementListAsync() => + _TryFindSourceAsync(s => s.GetAnnouncementAsync()); + + /// + /// 使用可用源下载到指定路径。 + /// + public Task DownloadAsync(string outputPath) => + _TryFindSourceAsync(s => s.DownloadAsync(outputPath)); + + #region Logger Wrapper + + private void _LogInfo(string msg) + { + LogWrapper.Info("Update", msg); + } + + private void _LogWarning(string msg, Exception? ex = null) + { + LogWrapper.Warn(ex, "Update", msg); + } + + private void _LogError(string msg, Exception? ex = null) + { + LogWrapper.Error(ex, "Update", msg); + } + + private void _LogTrace(string msg) + { + LogWrapper.Trace("Update", msg); + } + + #endregion +} diff --git a/App/Updates/Sources/UpdateMinioSource.cs b/App/Updates/Sources/UpdateMinioSource.cs new file mode 100644 index 00000000..5772d710 --- /dev/null +++ b/App/Updates/Sources/UpdateMinioSource.cs @@ -0,0 +1,239 @@ +using PCL.Core.IO; +using PCL.Core.Logging; +using PCL.Core.Utils; +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net.Http; +using System.Runtime.InteropServices; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using PCL.Core.Net.Downloader; +using PCL.Core.Net.Http.Client; +using PCL.Core.Utils.Diff; + +namespace PCL.Core.App.Updates.Sources; + +public class UpdateMinioSource(string baseUrl, string name = "Minio") : IUpdateSource +{ + private sealed record VersionAssetsDataModel( + [property: JsonPropertyName("assets")] VersionData[] Assets + ); + + public bool IsAvailable => !string.IsNullOrWhiteSpace(baseUrl); + + public string SourceName => name; + + private static readonly string _TempPath = Path.Combine(FileService.TempPath, "Cache", "Update"); + + private VersionData? _cachedVersionInfo; + + /// + /// 当版本信息为 null 时抛出 + /// 当获取版本信息失败时抛出 + public async Task CheckUpdateAsync() + { + try + { + _LogTrace("开始获取版本信息"); + var channelName = _GetChannelName(); + var assets = await _GetRemoteInfoByNameAsync($"updates-{channelName}", "updates/") + .ConfigureAwait(false); + _LogTrace("版本信息获取完成"); + + _cachedVersionInfo = assets?.Assets.FirstOrDefault(); + } + catch (Exception ex) + { + throw new HttpRequestException("从远程获取版本信息失败", ex); + } + + return _cachedVersionInfo ?? throw new InvalidDataException("未找到远程版本信息"); + } + + /// + /// 当公告信息为 null 时抛出 + /// 当获取公告信息失败时抛出 + public async Task GetAnnouncementAsync() + { + AnnouncementsList? ret; + try + { + _LogTrace("开始获取公告信息"); + ret = await _GetRemoteInfoByNameAsync("announcement") + .ConfigureAwait(false); + _LogTrace("公告信息获取完成"); + } + catch (Exception ex) + { + throw new HttpRequestException("从远程获取公告信息失败", ex); + } + + return ret ?? throw new InvalidDataException("未找到远程公告信息"); + } + + #region Download Workflow + + /// + /// 当版本信息未缓存时抛出 + public async Task DownloadAsync(string outputPath) + { + _LogInfo("准备下载更新文件"); + + var tempDownloadDir = _PrepareTempDirectory(); + + if (_cachedVersionInfo == null) await CheckUpdateAsync(); + var (task, isPatch) = + await _CreateDownloadTaskAsync(_cachedVersionInfo!, tempDownloadDir).ConfigureAwait(false); + + _LogInfo("开始下载更新文件"); + var manager = new DownloadManager(new FastMirrorSelector(new HttpClient())); + await manager.DownloadAsync(task, CancellationToken.None).ConfigureAwait(false); + + _LogInfo("下载完成,准备使用更新文件"); + await _UseUpdateFileAsync(task.TargetPath, outputPath, isPatch).ConfigureAwait(false); + _LogInfo("更新文件处理完成"); + } + + private async Task<(DownloadTask task, bool isPatch)> _CreateDownloadTaskAsync( + VersionData versionJson, + string tempDir) + { + var updateSha256 = versionJson.Sha256; + var selfSha256 = await Files.GetFileSHA256Async(Basics.ExecutablePath).ConfigureAwait(false); + + var patchFileName = $"{selfSha256}_{updateSha256}.patch"; + var patches = versionJson.Patches; + + if (patches.Contains(patchFileName)) + { + _LogInfo("发现可用的差分更新,准备使用差分更新"); + var tempPath = Path.Combine(tempDir, patchFileName); + + return (new DownloadTask( + new Uri($"{baseUrl}static/patch/{patchFileName}"), + tempPath + ), true); + } + + _LogInfo("未发现可用的差分更新,准备使用完整更新包"); + + var downloads = versionJson.Downloads; + if (downloads is null || downloads.Length == 0) + { + throw new InvalidDataException("未找到可用的下载链接"); + } + + return (new DownloadTask( + new Uri(RandomUtils.PickRandom(downloads)), + Path.Combine(tempDir, $"{updateSha256}.bin")), + false); + } + + private static async Task _UseUpdateFileAsync(string updateFilePath, string outputPath, bool isPatch) + { + if (isPatch) + { + var diff = new BsDiff(); + var baseBytes = await Files.ReadAllBytesOrEmptyAsync(Basics.ExecutablePath).ConfigureAwait(false); + var patchBytes = await Files.ReadAllBytesOrEmptyAsync(updateFilePath).ConfigureAwait(false); + var newFile = await diff.ApplyAsync(baseBytes, patchBytes).ConfigureAwait(false); + await Files.WriteFileAsync(outputPath, newFile).ConfigureAwait(false); + return; + } + + await using var fs = new FileStream(updateFilePath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var zip = new ZipArchive(fs, ZipArchiveMode.Read, leaveOpen: false); + + var entry = _FindExecutableEntry(zip); + if (entry is null) + throw new InvalidDataException("更新包中未找到可执行文件"); + + entry.ExtractToFile(outputPath, overwrite: true); + } + + + private static ZipArchiveEntry? _FindExecutableEntry(ZipArchive zip) => + zip.Entries.FirstOrDefault(e => + e.Name.Contains("Plain Craft Launcher Community Edition.exe", StringComparison.OrdinalIgnoreCase)) ?? + zip.Entries.FirstOrDefault(e => + e.Name.Contains("Plain Craft Launcher", StringComparison.OrdinalIgnoreCase)) ?? + zip.Entries.FirstOrDefault(e => + e.Name.Contains("Launcher", StringComparison.OrdinalIgnoreCase)) ?? + zip.Entries.FirstOrDefault(e => + e.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)); + + private static string _PrepareTempDirectory() + { + var path = Path.Combine(_TempPath, "Download"); + Directory.CreateDirectory(path); + + return path; + } + + #endregion + + /// + /// 通过名称获取远程信息 + /// + /// 保存路径 + /// 名称 + /// 远程信息 + private async Task _GetRemoteInfoByNameAsync(string versionName, string path = "") + { + _LogTrace("拉取远程信息..."); + + var builder = HttpRequestBuilder.Create($"{baseUrl}apiv2/{path}{versionName}.json", HttpMethod.Get); + var result = await builder.SendAsync().ConfigureAwait(false); + var remoteJson = await result.AsJsonAsync().ConfigureAwait(false); + + _LogTrace("远程信息拉取完成"); + + return remoteJson; + } + + /// + /// 获取通道名称 + /// + /// 通道名称 + private static string _GetChannelName() + { + var channelName = string.Empty; + channelName += Config.System.Update.UpdateChannel switch + { + 0 => "sr", + 1 => "fr", + _ => "sr" + }; + + channelName += RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "arm64" : "x64"; + + return channelName; + } + + #region Logger Wrapper + + private void _LogInfo(string msg) + { + LogWrapper.Info("Update", msg); + } + + private void _LogWarning(string msg, Exception? ex = null) + { + LogWrapper.Warn(ex, "Update", msg); + } + + private void _LogError(string msg, Exception? ex = null) + { + LogWrapper.Error(ex, "Update", msg); + } + + private void _LogTrace(string msg) + { + LogWrapper.Trace("Update", msg); + } + + #endregion +} diff --git a/App/UpdateHelper.cs b/App/Updates/UpdateHelper.cs similarity index 52% rename from App/UpdateHelper.cs rename to App/Updates/UpdateHelper.cs index 33b84138..238f3202 100644 --- a/App/UpdateHelper.cs +++ b/App/Updates/UpdateHelper.cs @@ -1,8 +1,10 @@ -using System; +using PCL.Core.Logging; +using System; +using System.Diagnostics; using System.IO; using System.Threading; -namespace PCL.Core.App; +namespace PCL.Core.App.Updates; public static class UpdateHelper { @@ -44,4 +46,50 @@ public static class UpdateHelper if (File.Exists(backup)) File.Delete(backup); // 删除备份文件 return lastEx; } -} + + /// + /// 启动更新程序并重启当前程序。 + /// + /// 是否在启动更新程序后结束当前程序。 + /// 是否为更新重启。 + public static void Restart(bool triggerRestartAndByEnd, bool isUpdateRestart = false) + { + try + { + var fileName = Path.GetFileName(Environment.ProcessPath); + + if (!File.Exists(fileName)) + { + LogWrapper.Warn("Update", "更新启动器文件不存在,无法启动更新程序"); + return; + } + + + var startInfo = new ProcessStartInfo(fileName) + { + ArgumentList = + { + "update", + Environment.ProcessId.ToString(), + $"{Basics.ExecutablePath}", + $"{fileName}", + isUpdateRestart ? "true" : "false" + }, + WindowStyle = ProcessWindowStyle.Hidden, + CreateNoWindow = true + }; + + Process.Start(startInfo); + LogWrapper.Info("Update", "已尝试启动更新程序,参数: " + string.Join(" ", startInfo.ArgumentList)); + + if (!triggerRestartAndByEnd) return; + + LogWrapper.Info("Update", "已由于更新结束程序"); + Lifecycle.Shutdown(); + } + catch (Exception ex) + { + LogWrapper.Warn(ex, "Update", "启动更新程序失败"); + } + } +} \ No newline at end of file diff --git a/App/UpdateService.cs b/App/Updates/UpdateService.cs similarity index 88% rename from App/UpdateService.cs rename to App/Updates/UpdateService.cs index 4e475d8d..0f5d58ef 100644 --- a/App/UpdateService.cs +++ b/App/Updates/UpdateService.cs @@ -1,19 +1,17 @@ -using System; +using PCL.Core.Utils.Exts; +using System; using System.Diagnostics; using System.IO; -using PCL.Core.Utils.Exts; +using System.Threading.Tasks; -namespace PCL.Core.App; +namespace PCL.Core.App.Updates; [LifecycleService(LifecycleState.BeforeLoading)] -public sealed class UpdateService : GeneralService +[LifecycleScope("update", "处理更新参数")] +public sealed partial class UpdateService { - private static LifecycleContext? _context; - private static LifecycleContext Context => _context!; - - private UpdateService() : base("update", "更新", false) { _context = ServiceContext; } - - public override void Start() + [LifecycleStart] + private static async Task _Start() { var args = Basics.CommandLineArguments; @@ -54,7 +52,7 @@ public override void Start() { var oldProcess = Process.GetProcessById(oldProcessId); Context.Debug("正在等待旧版本进程退出"); - oldProcess.WaitForExit(); + await oldProcess.WaitForExitAsync(); Context.Trace("旧版本进程已退出"); } catch @@ -87,4 +85,4 @@ public override void Start() Context.RequestExit(); } -} +} \ No newline at end of file