From d79e1297240be19b165299c66a55d43a3d0accef Mon Sep 17 00:00:00 2001 From: denglihong2007 <98096191+denglihong2007@users.noreply.github.com> Date: Sat, 30 Aug 2025 14:52:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CRSim.Core/Abstractions/INetworkService.cs | 1 + CRSim.Core/Models/JsonSerializationContext.cs | 1 + CRSim.Core/Models/UpdateInfo.cs | 170 ++++++++++++++++++ CRSim.Core/Services/NetworkService.cs | 15 ++ CRSim.Core/Utils/FileHashHelper.cs | 32 ++++ CRSim/ViewModels/SettingsPageViewModel.cs | 103 ++++++++++- CRSim/Views/SettingsPage.xaml | 6 +- Directory.Build.props | 2 +- 8 files changed, 323 insertions(+), 7 deletions(-) create mode 100644 CRSim.Core/Models/UpdateInfo.cs create mode 100644 CRSim.Core/Utils/FileHashHelper.cs diff --git a/CRSim.Core/Abstractions/INetworkService.cs b/CRSim.Core/Abstractions/INetworkService.cs index 2613533..13975b1 100644 --- a/CRSim.Core/Abstractions/INetworkService.cs +++ b/CRSim.Core/Abstractions/INetworkService.cs @@ -8,5 +8,6 @@ namespace CRSim.Core.Abstractions Task?> GetTimeTableAsync(string number); Task?> GetTrainNumbersAsync(string name); List? GetOnlinePlugins(string uri); + Task GetUpdateAsync(string uri); } } diff --git a/CRSim.Core/Models/JsonSerializationContext.cs b/CRSim.Core/Models/JsonSerializationContext.cs index 6e431a4..640adbf 100644 --- a/CRSim.Core/Models/JsonSerializationContext.cs +++ b/CRSim.Core/Models/JsonSerializationContext.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; namespace CRSim.Core.Models { [JsonSerializable(typeof(Json))] + [JsonSerializable(typeof(UpdateInfo))] [JsonSourceGenerationOptions(WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] public partial class JsonContext : JsonSerializerContext { diff --git a/CRSim.Core/Models/UpdateInfo.cs b/CRSim.Core/Models/UpdateInfo.cs new file mode 100644 index 0000000..280a9b8 --- /dev/null +++ b/CRSim.Core/Models/UpdateInfo.cs @@ -0,0 +1,170 @@ +using System.Text.Json.Serialization; + +namespace CRSim.Core.Models +{ + public class UpdateInfo + { + [JsonPropertyName("url")] + public string? Url { get; set; } + [JsonPropertyName("assets_url")] + public string? AssetsUrl { get; set; } + [JsonPropertyName("upload_url")] + public string? UploadUrl { get; set; } + [JsonPropertyName("html_url")] + public string? HtmlUrl { get; set; } + [JsonPropertyName("id")] + public long? Id { get; set; } + [JsonPropertyName("author")] + public AuthorEntity? Author { get; set; } + [JsonPropertyName("node_id")] + public string? NodeId { get; set; } + [JsonPropertyName("tag_name")] + public string? TagName { get; set; } + [JsonPropertyName("target_commitish")] + public string? TargetCommitish { get; set; } + [JsonPropertyName("name")] + public string? Name { get; set; } + [JsonPropertyName("draft")] + public bool? Draft { get; set; } + [JsonPropertyName("immutable")] + public bool? Immutable { get; set; } + [JsonPropertyName("prerelease")] + public bool? Prerelease { get; set; } + [JsonPropertyName("created_at")] + public string? CreatedAt { get; set; } + [JsonPropertyName("updated_at")] + public string? UpdatedAt { get; set; } + [JsonPropertyName("published_at")] + public string? PublishedAt { get; set; } + [JsonPropertyName("assets")] + public List? Assets { get; set; } + [JsonPropertyName("tarball_url")] + public string? TarballUrl { get; set; } + [JsonPropertyName("zipball_url")] + public string? ZipballUrl { get; set; } + [JsonPropertyName("body")] + public string? Body { get; set; } + [JsonPropertyName("mentions_count")] + public long? MentionsCount { get; set; } + + } + + public class AuthorEntity + { + [JsonPropertyName("login")] + public string? Login { get; set; } + [JsonPropertyName("id")] + public long? Id { get; set; } + [JsonPropertyName("node_id")] + public string? NodeId { get; set; } + [JsonPropertyName("avatar_url")] + public string? AvatarUrl { get; set; } + [JsonPropertyName("gravatar_id")] + public string? GravatarId { get; set; } + [JsonPropertyName("url")] + public string? Url { get; set; } + [JsonPropertyName("html_url")] + public string? HtmlUrl { get; set; } + [JsonPropertyName("followers_url")] + public string? FollowersUrl { get; set; } + [JsonPropertyName("following_url")] + public string? FollowingUrl { get; set; } + [JsonPropertyName("gists_url")] + public string? GistsUrl { get; set; } + [JsonPropertyName("starred_url")] + public string? StarredUrl { get; set; } + [JsonPropertyName("subscriptions_url")] + public string? SubscriptionsUrl { get; set; } + [JsonPropertyName("organizations_url")] + public string? OrganizationsUrl { get; set; } + [JsonPropertyName("repos_url")] + public string? ReposUrl { get; set; } + [JsonPropertyName("events_url")] + public string? EventsUrl { get; set; } + [JsonPropertyName("received_events_url")] + public string? ReceivedEventsUrl { get; set; } + [JsonPropertyName("type")] + public string? Type { get; set; } + [JsonPropertyName("user_view_type")] + public string? UserViewType { get; set; } + [JsonPropertyName("site_admin")] + public bool? SiteAdmin { get; set; } + + } + + public class AssetsEntity + { + [JsonPropertyName("url")] + public string? Url { get; set; } + [JsonPropertyName("id")] + public long? Id { get; set; } + [JsonPropertyName("node_id")] + public string? NodeId { get; set; } + [JsonPropertyName("name")] + public string? Name { get; set; } + [JsonPropertyName("label")] + public string? Label { get; set; } + [JsonPropertyName("uploader")] + public UploaderEntity? Uploader { get; set; } + [JsonPropertyName("content_type")] + public string? ContentType { get; set; } + [JsonPropertyName("state")] + public string? State { get; set; } + [JsonPropertyName("size")] + public long? Size { get; set; } + [JsonPropertyName("digest")] + public string? Digest { get; set; } + [JsonPropertyName("download_count")] + public long? DownloadCount { get; set; } + [JsonPropertyName("created_at")] + public string? CreatedAt { get; set; } + [JsonPropertyName("updated_at")] + public string? UpdatedAt { get; set; } + [JsonPropertyName("browser_download_url")] + public string? BrowserDownloadUrl { get; set; } + + } + + public class UploaderEntity + { + [JsonPropertyName("login")] + public string? Login { get; set; } + [JsonPropertyName("id")] + public long? Id { get; set; } + [JsonPropertyName("node_id")] + public string? NodeId { get; set; } + [JsonPropertyName("avatar_url")] + public string? AvatarUrl { get; set; } + [JsonPropertyName("gravatar_id")] + public string? GravatarId { get; set; } + [JsonPropertyName("url")] + public string? Url { get; set; } + [JsonPropertyName("html_url")] + public string? HtmlUrl { get; set; } + [JsonPropertyName("followers_url")] + public string? FollowersUrl { get; set; } + [JsonPropertyName("following_url")] + public string? FollowingUrl { get; set; } + [JsonPropertyName("gists_url")] + public string? GistsUrl { get; set; } + [JsonPropertyName("starred_url")] + public string? StarredUrl { get; set; } + [JsonPropertyName("subscriptions_url")] + public string? SubscriptionsUrl { get; set; } + [JsonPropertyName("organizations_url")] + public string? OrganizationsUrl { get; set; } + [JsonPropertyName("repos_url")] + public string? ReposUrl { get; set; } + [JsonPropertyName("events_url")] + public string? EventsUrl { get; set; } + [JsonPropertyName("received_events_url")] + public string? ReceivedEventsUrl { get; set; } + [JsonPropertyName("type")] + public string? Type { get; set; } + [JsonPropertyName("user_view_type")] + public string? UserViewType { get; set; } + [JsonPropertyName("site_admin")] + public bool? SiteAdmin { get; set; } + + } +} diff --git a/CRSim.Core/Services/NetworkService.cs b/CRSim.Core/Services/NetworkService.cs index 34bbaee..9a91b78 100644 --- a/CRSim.Core/Services/NetworkService.cs +++ b/CRSim.Core/Services/NetworkService.cs @@ -151,5 +151,20 @@ namespace CRSim.Core.Services } return null; } + + public async Task GetUpdateAsync(string uri) + { + try + { + var client = new HttpClient(); + client.DefaultRequestHeaders.UserAgent.ParseAdd("CRSim"); + var response = await client.GetStringAsync(uri); + return JsonSerializer.Deserialize(response, JsonContext.Default.UpdateInfo); + } + catch + { + } + return null; + } } } diff --git a/CRSim.Core/Utils/FileHashHelper.cs b/CRSim.Core/Utils/FileHashHelper.cs new file mode 100644 index 0000000..278ea79 --- /dev/null +++ b/CRSim.Core/Utils/FileHashHelper.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +namespace CRSim.Core.Utils +{ + public class FileHashHelper + { + /// + /// 计算文件 SHA256 值 + /// + public static string ComputeSHA256(string filePath) + { + using var sha256 = SHA256.Create(); + using var stream = File.OpenRead(filePath); + var hashBytes = sha256.ComputeHash(stream); + return Convert.ToHexStringLower(hashBytes); + } + + /// + /// 验证文件 SHA256 + /// + public static bool VerifySHA256(string filePath, string expectedHash) + { + var fileHash = ComputeSHA256(filePath); + return string.Equals(fileHash, expectedHash, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/CRSim/ViewModels/SettingsPageViewModel.cs b/CRSim/ViewModels/SettingsPageViewModel.cs index c43928f..1700494 100644 --- a/CRSim/ViewModels/SettingsPageViewModel.cs +++ b/CRSim/ViewModels/SettingsPageViewModel.cs @@ -1,4 +1,7 @@ -using System.Diagnostics.Metrics; +using Downloader; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.IO.Packaging; using System.Reflection; namespace CRSim.ViewModels @@ -12,13 +15,18 @@ namespace CRSim.ViewModels public partial string AppVersion { get; set; } = ""; public ObservableCollection Apis { get; } = [ - new InfoItem { Title = "官方插件源", Detail = "http://47.122.74.193:25565" }, - new InfoItem { Title = "镜像站插件源", Detail = "https://crsim.com.cn/api" }, + new InfoItem { Title = "官方源", Detail = "http://47.122.74.193:25565" }, + new InfoItem { Title = "镜像站源", Detail = "https://crsim.com.cn/api" }, ]; private Settings _settings; private readonly ISettingsService _settingsService; private readonly IDatabaseService _databaseService; + private readonly INetworkService _networkService; private readonly IDialogService _dialogService; + + [ObservableProperty] + public partial int UpdateProgress { get; set; } + #region 偏好设置 [ObservableProperty] public partial string DepartureCheckInAdvanceDuration { get; set; } @@ -53,14 +61,16 @@ namespace CRSim.ViewModels [ObservableProperty] public partial bool ReopenUnclosedScreensOnLoad { get; set; } #endregion - public SettingsPageViewModel(ISettingsService settingsService, IDatabaseService databaseService, IDialogService dialogService) + public SettingsPageViewModel(ISettingsService settingsService, IDatabaseService databaseService, IDialogService dialogService, INetworkService networkService) { _settingsService = settingsService; _databaseService = databaseService; _dialogService = dialogService; + _networkService = networkService; var version = Assembly.GetExecutingAssembly().GetName().Version; AppVersion = $"Version {version}"; LoadSettings(); + } private void LoadSettings() @@ -95,7 +105,7 @@ namespace CRSim.ViewModels _settings.ApiUri = ApiUri.Detail; _settingsService.SaveSettings(); } - private void UpdateSettings(string input, bool allowNegative, Action updateAction) + private static void UpdateSettings(string input, bool allowNegative, Action updateAction) { if (int.TryParse(input, out int value) && (allowNegative || value >= 0)) { @@ -111,6 +121,89 @@ namespace CRSim.ViewModels await _databaseService.SaveData(); } [RelayCommand] + public async Task CheckUpdate() + { + var update = ApiUri.Title == "官方源" ? + await _networkService.GetUpdateAsync("https://api.github.com/repos/denglihong2007/CRSim/releases/latest") : + await _networkService.GetUpdateAsync("https://crsim.com.cn/api/version"); + if (update is null) + { + await _dialogService.ShowMessageAsync("错误", "检查更新失败。"); + } + else if (update.Name == AppVersion.Split(' ')[1]) + { + await _dialogService.ShowMessageAsync("信息", "当前已经是最新版本。"); + } + else + { + await _dialogService.ShowTextAsync("发现新版本 " + update.Name, update.Body + "\n系统即将下载安装新版本。"); + DispatcherQueue dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + var downloader = new DownloadService(); + UpdateProgress = 0; + try + { + int lastProgress = 0; + var lastUpdate = DateTime.MinValue; + downloader.DownloadProgressChanged += (s, e) => + { + var now = DateTime.Now; + if ((e.ProgressPercentage - lastProgress >= 1) || (now - lastUpdate).TotalMilliseconds >= 100) + { + lastProgress = (int)e.ProgressPercentage; + lastUpdate = now; + + dispatcherQueue.TryEnqueue(() => + { + UpdateProgress = (int)e.ProgressPercentage; + }); + } + }; + var path = Path.Combine(AppPaths.TempPath, "update.zip"); + await downloader.DownloadFileTaskAsync(update.Assets[0].BrowserDownloadUrl, path); + UpdateProgress = 0; + + if (!FileHashHelper.VerifySHA256(path, update.Assets[0].Digest.Split(':')[1])) + { + await _dialogService.ShowMessageAsync("错误", "下载的文件校验失败,请重试。"); + return; + } + + var programDirectory = AppDomain.CurrentDomain.BaseDirectory; + var appExePath = Process.GetCurrentProcess().MainModule.FileName; + var appName = Path.GetFileName(appExePath); + string batchScript = $@" + @echo off + taskkill /F /IM ""{appName}"" >nul 2>&1 + timeout /t 2 >nul + cd /d ""{programDirectory}"" + powershell -Command ""Expand-Archive -Path '{path}' -DestinationPath '{programDirectory}' -Force"" + del /f /q ""{path}"" + start """" ""{appExePath}"" + exit /b 0 + "; + string batchFilePath = Path.Combine(Path.GetTempPath(), "CRSimUpdate.bat"); + File.WriteAllText(batchFilePath, batchScript); + var processInfo = new ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/c \"{batchFilePath}\"", + WindowStyle = ProcessWindowStyle.Hidden, + UseShellExecute = false, + CreateNoWindow = true + }; + + Process.Start(processInfo); + Application.Current.Exit(); + } + catch (Exception e) + { + await _dialogService.ShowTextAsync("错误", "更新失败。\n" + e); + UpdateProgress = 0; + return; + } + } + } + [RelayCommand] public async Task ExportData() { string? path = _dialogService.SaveFile(".json", "data"); diff --git a/CRSim/Views/SettingsPage.xaml b/CRSim/Views/SettingsPage.xaml index 534525e..631ef77 100644 --- a/CRSim/Views/SettingsPage.xaml +++ b/CRSim/Views/SettingsPage.xaml @@ -42,10 +42,14 @@ - + + +