From 97fe24e5a0f81849b97a0415a690b5fbee01d0fb Mon Sep 17 00:00:00 2001 From: Zero <1270128439@qq.com> Date: Sun, 26 Jul 2026 22:45:00 +0800 Subject: [PATCH] Confirm updates with release notes; drop the in-app geo update button The self-update flow used to go straight from menu click to download and restart with no confirmation. It now shows a ContentDialog first, with user-facing release notes pulled from a small website-hosted changelog feed (xrayui.site/changelog.json) and resolved to the active UI language by ChangelogSelector. Notes are best-effort: any fetch failure still shows the confirm dialog, just without notes. Also removes the in-app update-geo-routing-data button and GeoDataUpdateService from the custom rules window. The updater overwrites the whole install directory on every update anyway, so a manually refreshed geoip/geosite.dat was silently rolled back on the next release; geo data now ships as part of the versioned release bundle instead. --- Helpers/AppVersion.cs | 12 ++ Helpers/L.cs | 17 +- Models/ChangelogFeed.cs | 28 ++++ Services/AppJsonSerializerContext.cs | 2 + Services/ChangelogSelector.cs | 82 ++++++++++ Services/DialogService.cs | 199 +++++++++++------------ Services/GeoDataUpdateService.cs | 209 ------------------------- Services/IDialogService.cs | 12 +- Services/IUpdateService.cs | 10 ++ Services/UpdateService.cs | 59 +++++-- Strings/en-US/Resources.resw | 63 ++------ Strings/zh-CN/Resources.resw | 63 ++------ ViewModels/ControlPanelViewModel.cs | 20 +-- ViewModels/CustomRulesViewModel.cs | 98 +----------- ViewModels/MainViewModel.cs | 11 +- Views/CustomRulesWindow.xaml | 8 - Views/CustomRulesWindow.xaml.cs | 12 +- XrayUI.Tests/ChangelogSelectorTests.cs | 183 ++++++++++++++++++++++ XrayUI.Tests/XrayUI.Tests.csproj | 3 + updater-rs/src/main.rs | 4 +- 20 files changed, 541 insertions(+), 554 deletions(-) create mode 100644 Models/ChangelogFeed.cs create mode 100644 Services/ChangelogSelector.cs delete mode 100644 Services/GeoDataUpdateService.cs create mode 100644 XrayUI.Tests/ChangelogSelectorTests.cs diff --git a/Helpers/AppVersion.cs b/Helpers/AppVersion.cs index 6b0d392..e637343 100644 --- a/Helpers/AppVersion.cs +++ b/Helpers/AppVersion.cs @@ -12,5 +12,17 @@ public static class AppVersion // so dev iteration never tries to "upgrade" to the latest public release. public static bool IsDevBuild => Current.Major == 0 && Current.Minor == 0 && Current.Build == 0; + + /// + /// Orders two versions with absent components treated as zero. System.Version + /// reports missing parts as -1, so a feed's "1.18" would otherwise compare + /// below an installed "1.18.0" — every version comparison in the update flow + /// goes through here so they all agree. + /// + public static int CompareNormalized(Version a, Version b) => + Normalize(a).CompareTo(Normalize(b)); + + private static (int, int, int, int) Normalize(Version v) => + (v.Major, v.Minor, Math.Max(v.Build, 0), Math.Max(v.Revision, 0)); } } diff --git a/Helpers/L.cs b/Helpers/L.cs index c7207ea..7b3161a 100644 --- a/Helpers/L.cs +++ b/Helpers/L.cs @@ -106,6 +106,13 @@ public static class L // ── Startup / Update / TUN ───────────────────────────────────────────── public static string Startup_SetFailed => Loc.GetString("Startup_SetFailed"); public static string Update_Updating => Loc.GetString("Update_Updating"); + public static string Update_ConfirmNotesHeader => Loc.GetString("Update_ConfirmNotesHeader"); + /// Language code for picking a branch of the website changelog feed ("zh" / "en"). + /// Resolved through the resource loader so it follows the active UI language automatically, + /// including the "follow system" case — no locale parsing needed. + public static string Update_ChangelogLanguage => Loc.GetString("Update_ChangelogLanguage"); + public static string Update_ConfirmNow => Loc.GetString("Update_ConfirmNow"); + public static string Update_ConfirmLater => Loc.GetString("Update_ConfirmLater"); public static string Tun_EnableMsg => Loc.GetString("Tun_EnableMsg.Text"); // ── ChainProxy ───────────────────────────────────────────────────────── @@ -163,7 +170,6 @@ public static class L // ── CustomRules / AddRule ────────────────────────────────────────────── public static string CustomRules_Title => Loc.GetString("CustomRules_Title"); - public static string CustomRules_UpdateGeoTooltip => Loc.GetString("CustomRules_UpdateGeoTooltip"); public static string CustomRules_AdvancedEditorTooltip => Loc.GetString("CustomRules_AdvancedEditorTooltip"); public static string CustomRules_EditRowTooltip => Loc.GetString("CustomRules_EditRowTooltip"); public static string CustomRules_DeleteRowTooltip => Loc.GetString("CustomRules_DeleteRowTooltip"); @@ -182,15 +188,6 @@ public static class L public static string AddRule_HintIp => Loc.GetString("AddRule_HintIp"); public static string AddRule_HintProcess => Loc.GetString("AddRule_HintProcess"); - public static string GeoUpdate_Updating => Loc.GetString("GeoUpdate_Updating"); - public static string GeoUpdate_AlreadyLatest => Loc.GetString("GeoUpdate_AlreadyLatest"); - public static string GeoUpdate_AlreadyLatestMsg => Loc.GetString("GeoUpdate_AlreadyLatestMsg"); - public static string GeoUpdate_TunRestart => Loc.GetString("GeoUpdate_TunRestart"); - public static string GeoUpdate_ReloadedOk => Loc.GetString("GeoUpdate_ReloadedOk"); - public static string GeoUpdate_RestartRequired => Loc.GetString("GeoUpdate_RestartRequired"); - public static string GeoUpdate_NextStart => Loc.GetString("GeoUpdate_NextStart"); - public static string GeoUpdate_Success => Loc.GetString("GeoUpdate_Success"); - // ── LogWindow ────────────────────────────────────────────────────────── public static string Log_Title => Loc.GetString("Log_Title"); public static string Log_Running => Loc.GetString("Log_Running"); diff --git a/Models/ChangelogFeed.cs b/Models/ChangelogFeed.cs new file mode 100644 index 0000000..3e808e4 --- /dev/null +++ b/Models/ChangelogFeed.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace XrayUI.Models +{ + /// + /// Shape of https://www.xrayui.site/changelog.json — user-facing release notes, + /// maintained on the website rather than in the GitHub release body so the release + /// page can stay a plain technical PR list. One entry per version, both languages + /// side by side in the same file (one request, and a missing translation is visible + /// at a glance while editing). + /// + internal sealed class ChangelogFeed + { + [JsonPropertyName("versions")] public List? Versions { get; set; } + } + + internal sealed class ChangelogVersion + { + [JsonPropertyName("version")] public string? Version { get; set; } + [JsonPropertyName("zh")] public List? Zh { get; set; } + [JsonPropertyName("en")] public List? En { get; set; } + } + + /// One version's notes, already resolved to a single language. + public sealed record ChangelogEntry(Version Version, IReadOnlyList Lines); +} diff --git a/Services/AppJsonSerializerContext.cs b/Services/AppJsonSerializerContext.cs index 25e8a39..6fc07ba 100644 --- a/Services/AppJsonSerializerContext.cs +++ b/Services/AppJsonSerializerContext.cs @@ -23,6 +23,8 @@ namespace XrayUI.Services; [JsonSerializable(typeof(PresetSettings))] [JsonSerializable(typeof(GhRelease))] [JsonSerializable(typeof(GhAsset))] +[JsonSerializable(typeof(ChangelogFeed))] +[JsonSerializable(typeof(ChangelogVersion))] internal partial class AppJsonSerializerContext : JsonSerializerContext { // Write-side options that emit CJK / emoji as literal UTF-8 instead of \uXXXX escapes. diff --git a/Services/ChangelogSelector.cs b/Services/ChangelogSelector.cs new file mode 100644 index 0000000..499e7c5 --- /dev/null +++ b/Services/ChangelogSelector.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using XrayUI.Helpers; +using XrayUI.Models; + +namespace XrayUI.Services +{ + /// + /// Picks the release notes to show for an upgrade: every version newer than the + /// installed one up to and including the target, resolved to one language. + /// Pure — no I/O, no dispatcher — so it is unit-tested directly. + /// + internal static class ChangelogSelector + { + /// + /// Most version blocks to return, newest first. An install left stale for a + /// long time would otherwise pile a dozen blocks into a small dialog. + /// + internal const int MaxVersions = 4; + + /// + /// UI language code from the resources ("zh" / "en"). When the + /// preferred language has no lines for a version, the other one is used — + /// a half-translated feed still shows something rather than a blank gap. + /// + public static List Select( + ChangelogFeed? feed, Version currentVersion, Version targetVersion, string? language) + { + var result = new List(); + if (feed?.Versions is null) return result; + + var preferZh = language is not null + && language.StartsWith("zh", StringComparison.OrdinalIgnoreCase); + + foreach (var version in feed.Versions) + { + if (version is null) continue; + if (!Version.TryParse(version.Version, out var parsed)) continue; + + // Skip what the user already has, and anything beyond this upgrade — + // the feed may already list versions newer than the target release. + if (AppVersion.CompareNormalized(parsed, currentVersion) <= 0) continue; + if (AppVersion.CompareNormalized(parsed, targetVersion) > 0) continue; + + var lines = PickLines(version, preferZh); + if (lines.Count == 0) continue; + + result.Add(new ChangelogEntry(parsed, lines)); + } + + result.Sort((a, b) => AppVersion.CompareNormalized(b.Version, a.Version)); // newest first + + // Trim after sorting, so the cap keeps the newest versions rather than + // whatever order the feed happened to list them in. + if (result.Count > MaxVersions) + result.RemoveRange(MaxVersions, result.Count - MaxVersions); + + return result; + } + + private static List PickLines(ChangelogVersion version, bool preferZh) + { + var preferred = Clean(preferZh ? version.Zh : version.En); + return preferred.Count > 0 + ? preferred + : Clean(preferZh ? version.En : version.Zh); + } + + private static List Clean(List? lines) + { + var result = new List(); + if (lines is null) return result; + + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) continue; + result.Add(line.Trim()); + } + return result; + } + } +} diff --git a/Services/DialogService.cs b/Services/DialogService.cs index 65d8ebf..d93ef82 100644 --- a/Services/DialogService.cs +++ b/Services/DialogService.cs @@ -4,6 +4,7 @@ using System.ComponentModel; using System.Collections.Generic; using Windows.ApplicationModel.DataTransfer; +using Microsoft.UI.Xaml.Automation; using XrayUI.Controls; using XrayUI.Helpers; using XrayUI.Models; @@ -734,102 +735,6 @@ public async Task ShowErrorAsync(string title, string message, XamlRoot? xamlRoo // ── Progress ────────────────────────────────────────────────────────── - public async Task ShowProgressDialogAsync(string title, Func, CancellationToken, Task> work, - XamlRoot? xamlRoot = null) - { - using var cts = new CancellationTokenSource(); - - var statusText = new TextBlock - { - Text = L.Dialog_Preparing, - TextWrapping = TextWrapping.Wrap, - MaxWidth = 320, - HorizontalAlignment = HorizontalAlignment.Center, - }; - - var ring = new ProgressRing - { - IsActive = true, - Width = 36, - Height = 36, - }; - - var dialog = CreateDialog(xamlRoot); - dialog.Title = title; - dialog.CloseButtonText = L.Dialog_Cancel; - dialog.Content = new StackPanel - { - Spacing = 16, - MinWidth = 320, - HorizontalAlignment = HorizontalAlignment.Center, - Children = { ring, statusText } - }; - - // Progress captures the current SynchronizationContext — since we're on the UI - // thread here, reports from the worker thread are marshalled back automatically. - var progress = new Progress(s => statusText.Text = s); - - Exception? error = null; - int workFinished = 0; - - dialog.Opened += (_, _) => - { - if (Volatile.Read(ref workFinished) == 1) - { - try - { - dialog.Hide(); - } - catch - { - } - } - }; - - var workTask = Task.Run(async () => - { - try - { - await work(progress, cts.Token); - } - catch (OperationCanceledException) when (cts.IsCancellationRequested) - { - // Real user cancel — swallow here, we rethrow a fresh OCE below based on cts state. - // Any *other* OperationCanceledException (e.g. HttpClient.Timeout throwing - // TaskCanceledException with its own internal token) must not be swallowed — - // it falls through to the generic catch so the caller can surface the failure. - } - catch (Exception ex) - { - error = ex; - } - finally - { - Volatile.Write(ref workFinished, 1); - dialog.DispatcherQueue.TryEnqueue(() => - { - try - { - dialog.Hide(); - } - catch - { - } - }); - } - }); - - await dialog.ShowAsync(); - - // If the dialog closed because the user clicked Cancel (work still running), signal it. - if (Volatile.Read(ref workFinished) == 0) cts.Cancel(); - - await workTask; - - if (error != null) throw error; - if (cts.IsCancellationRequested) throw new OperationCanceledException(cts.Token); - } - public async Task ShowProgressBarDialogAsync(string title, Func, CancellationToken, Task> work, XamlRoot? xamlRoot = null) { @@ -1098,6 +1003,108 @@ public async Task ShowShareLinkDialogAsync(string serverName, string link) return (toggle.IsOn, checkBox.IsChecked == true); } + // ── App update confirm ──────────────────────────────────────────────── + + public async Task ShowUpdateConfirmDialogAsync( + Version newVersion, IReadOnlyList notes) + { + var dialog = CreateDialog(); + dialog.Title = Loc.Format("Update_ConfirmTitle", newVersion); + dialog.PrimaryButtonText = L.Update_ConfirmNow; + dialog.CloseButtonText = L.Update_ConfirmLater; + dialog.DefaultButton = ContentDialogButton.Primary; + + // No notes → no Content at all: the dialog stays a compact title + buttons. + if (notes.Count > 0) + { + // Grid root, not StackPanel: a StackPanel root breaks the measure chain and + // ContentDialog clips tall content instead of letting the notes list scroll. + // Fixed width keeps the dialog compact — without it the longest note line + // stretches it toward ContentDialog's max width. + var root = new Grid { Width = 380 }; + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); // notes header + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); // notes list + + // Opacity instead of TextFillColorSecondaryBrush: Application.Current.Resources + // resolves theme brushes against the app-level theme (never set here), which goes + // stale under the Personalize theme override — see Views/LogWindow.xaml. + var notesHeader = new TextBlock + { + Text = L.Update_ConfirmNotesHeader, + FontSize = 12, + Opacity = 0.65, + Margin = new Thickness(0, 0, 0, 6), + }; + Grid.SetRow(notesHeader, 0); + root.Children.Add(notesHeader); + + var list = new StackPanel { Spacing = 4 }; + foreach (var entry in notes) + { + // Only label versions when the upgrade spans more than one release — + // for the common single-version case the dialog title already says it. + if (notes.Count > 1) + { + list.Children.Add(new TextBlock + { + Text = entry.Version.ToString(), + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + Margin = new Thickness(0, list.Children.Count == 0 ? 0 : 8, 0, 2), + }); + } + + foreach (var line in entry.Lines) + list.Children.Add(BuildNoteLine(line)); + } + + var scroller = new ScrollViewer + { + Content = list, + MaxHeight = 220, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + HorizontalScrollMode = ScrollMode.Disabled, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + }; + Grid.SetRow(scroller, 1); + root.Children.Add(scroller); + + dialog.Content = root; + } + + return await dialog.ShowAsync() == ContentDialogResult.Primary; + } + + /// + /// One bullet as a two-column Grid rather than a "• "-prefixed string, so wrapped + /// lines keep a hanging indent instead of running back under the bullet. + /// + private static Grid BuildNoteLine(string text) + { + var row = new Grid { ColumnSpacing = 6 }; + row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var bullet = new TextBlock + { + Text = "•", + FontSize = 13, + Opacity = 0.65, + VerticalAlignment = VerticalAlignment.Top, + }; + + var body = new TextBlock + { + Text = text, + FontSize = 13, + TextWrapping = TextWrapping.Wrap, + }; + Grid.SetColumn(body, 1); + + row.Children.Add(bullet); + row.Children.Add(body); + return row; + } + // ── DNS settings ────────────────────────────────────────────────────── public async Task ShowDnsSettingsDialogAsync(AppSettings settings, bool isTunMode) diff --git a/Services/GeoDataUpdateService.cs b/Services/GeoDataUpdateService.cs deleted file mode 100644 index cb5e6d8..0000000 --- a/Services/GeoDataUpdateService.cs +++ /dev/null @@ -1,209 +0,0 @@ -using System; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Security.Cryptography; -using System.Threading; -using System.Threading.Tasks; -using XrayUI.Helpers; - -namespace XrayUI.Services -{ - /// - /// Downloads geoip.dat / geosite.dat from Loyalsoldier/v2ray-rules-dat. - /// Optimized over v2rayN: fetches the tiny .sha256sum first and skips the big download - /// when the local file already matches. The hash also verifies downloaded data integrity. - /// - public class GeoDataUpdateService - { - private const string UrlTemplate = - "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/{0}.dat"; - - private static readonly string[] Files = { "geosite", "geoip" }; - - /// Result of an update run. AnyUpdated == true iff at least one .dat was actually replaced. - public readonly record struct UpdateResult(int UpdatedCount, int SkippedCount) - { - public bool AnyUpdated => UpdatedCount > 0; - } - - /// - /// Optional proxy, e.g. "socks5://127.0.0.1:16890". When set, all HTTP traffic - /// (both the tiny .sha256sum fetch and the .dat download) is tunnelled through it. - /// Caller typically passes the running xray's local SOCKS port; null for direct. - /// - public async Task UpdateAsync(IProgress progress, string? proxyUrl, CancellationToken ct) - { - using var handler = new HttpClientHandler(); - if (!string.IsNullOrEmpty(proxyUrl)) - { - handler.Proxy = new WebProxy(proxyUrl); - handler.UseProxy = true; - progress.Report(Loc.Format("GeoUpdate_ViaProxy", proxyUrl)); - } - else - { - // Explicitly disable — .NET's default picks up WinHTTP proxy config which is - // rarely what the user expects from an xray UI. Be direct and predictable. - handler.UseProxy = false; - } - - using var client = new HttpClient(handler); - client.Timeout = TimeSpan.FromMinutes(5); - client.DefaultRequestHeaders.UserAgent.ParseAdd("XrayUI"); - - Directory.CreateDirectory(XrayService.RulesDir); - - int updated = 0; - int skipped = 0; - - foreach (var name in Files) - { - ct.ThrowIfCancellationRequested(); - - var url = string.Format(UrlTemplate, name); - var sumUrl = url + ".sha256sum"; - var target = Path.Combine(XrayService.RulesDir, $"{name}.dat"); - - progress.Report(Loc.Format("GeoUpdate_Checking", name)); - - // If the hash fetch fails (404, network), fall through to unconditional download — v2rayN parity. - string? remoteHash = await TryFetchRemoteHashAsync(client, sumUrl, ct); - - if (remoteHash != null && File.Exists(target)) - { - var localHash = await ComputeSha256Async(target, ct); - if (string.Equals(localHash, remoteHash, StringComparison.OrdinalIgnoreCase)) - { - progress.Report(Loc.Format("GeoUpdate_UpToDate", name)); - skipped++; - continue; - } - } - - var tmp = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".dat"); - try - { - await DownloadToFileAsync(client, url, tmp, $"{name}.dat", progress, ct); - - if (remoteHash != null) - { - var downloadedHash = await ComputeSha256Async(tmp, ct); - if (!string.Equals(downloadedHash, remoteHash, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidDataException( - Loc.Format("GeoUpdate_ChecksumFailed", name)); - } - } - - File.Move(tmp, target, overwrite: true); - updated++; - } - catch - { - try { File.Delete(tmp); } catch { } - throw; - } - } - - return new UpdateResult(updated, skipped); - } - - private static async Task TryFetchRemoteHashAsync(HttpClient client, string sumUrl, CancellationToken ct) - { - try - { - var text = await client.GetStringAsync(sumUrl, ct); - return ParseSha256SumLine(text); - } - catch (OperationCanceledException) - { - throw; - } - catch - { - // Sum file missing or unreachable — not fatal, caller falls back to blind download. - return null; - } - } - - /// - /// Parses a sha256sum line: "<64-hex> [*]filename". Accepts raw-hash-only too. - /// Returns null if the content doesn't look like a valid SHA256. - /// - private static string? ParseSha256SumLine(string content) - { - var line = content.Trim(); - if (line.Length == 0) return null; - - // Take the first whitespace-delimited token. - int sep = 0; - while (sep < line.Length && !char.IsWhiteSpace(line[sep])) sep++; - var token = line[..sep]; - - if (token.Length != 64) return null; - foreach (var c in token) - { - if (!char.IsAsciiHexDigit(c)) return null; - } - return token.ToLowerInvariant(); - } - - private static async Task ComputeSha256Async(string path, CancellationToken ct) - { - await using var stream = new FileStream( - path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true); - using var sha = SHA256.Create(); - var hash = await sha.ComputeHashAsync(stream, ct); - return Convert.ToHexString(hash).ToLowerInvariant(); - } - - private static async Task DownloadToFileAsync( - HttpClient client, - string url, - string destPath, - string displayName, - IProgress progress, - CancellationToken ct) - { - using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); - response.EnsureSuccessStatusCode(); - - var total = response.Content.Headers.ContentLength; - progress.Report(FormatProgress(displayName, 0, total)); - - await using var src = await response.Content.ReadAsStreamAsync(ct); - await using var dst = new FileStream( - destPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true); - - var buffer = new byte[81920]; - long received = 0; - long lastReport = 0; - - while (true) - { - var read = await src.ReadAsync(buffer.AsMemory(0, buffer.Length), ct); - if (read == 0) break; - - await dst.WriteAsync(buffer.AsMemory(0, read), ct); - received += read; - - if (received - lastReport >= 512 * 1024) - { - progress.Report(FormatProgress(displayName, received, total)); - lastReport = received; - } - } - - progress.Report(FormatProgress(displayName, received, total)); - } - - private static string FormatProgress(string name, long received, long? total) - { - var mbReceived = received / 1024.0 / 1024.0; - return total.HasValue - ? Loc.Format("GeoUpdate_Downloading", name, mbReceived, total.Value / 1024.0 / 1024.0) - : Loc.Format("GeoUpdate_DownloadingNoTotal", name, mbReceived); - } - } -} diff --git a/Services/IDialogService.cs b/Services/IDialogService.cs index 3466f21..c1ae2b0 100644 --- a/Services/IDialogService.cs +++ b/Services/IDialogService.cs @@ -25,11 +25,15 @@ public interface IDialogService Task<(bool enabled, bool autoConnect)?> ShowStartupDialogAsync(bool currentEnabled, bool currentAutoConnect); /// - /// Shows a modal dialog with a progress ring + status text while runs. - /// Throws if the user cancels; rethrows any other exception from the work. + /// Confirmation shown before an app update starts. Returns true when the + /// user chose to update now. /// - /// Override which window the dialog is rooted in. Null = MainWindow. - Task ShowProgressDialogAsync(string title, Func, CancellationToken, Task> work, XamlRoot? xamlRoot = null); + /// + /// Release notes to show in the dialog body. Empty leaves the dialog a + /// compact title + buttons confirm. + /// + Task ShowUpdateConfirmDialogAsync( + Version newVersion, IReadOnlyList notes); /// /// Shows a modal dialog with a progress bar + status text while runs. diff --git a/Services/IUpdateService.cs b/Services/IUpdateService.cs index 3864ac5..138e32b 100644 --- a/Services/IUpdateService.cs +++ b/Services/IUpdateService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using XrayUI.Models; @@ -30,6 +31,15 @@ public interface IUpdateService Task DownloadVerifyAndExtractAsync( UpdateInfo info, string? proxyUrl, IProgress progress, CancellationToken ct); + /// + /// Fetches the user-facing release notes for this upgrade from the website feed. + /// Best-effort decoration: any failure (offline, feed not updated yet, malformed) + /// returns an empty list instead of throwing, so the update flow never depends on it. + /// + /// UI language code, e.g. "zh" or "en". + Task> FetchChangelogAsync( + UpdateInfo info, string? language, string? proxyUrl, CancellationToken ct); + /// /// Spawns the staged updater with handoff arguments. Caller is responsible /// for shutting the app down (via App.RequestShutdown) immediately after. diff --git a/Services/UpdateService.cs b/Services/UpdateService.cs index ba723e8..0801ee0 100644 --- a/Services/UpdateService.cs +++ b/Services/UpdateService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.IO.Compression; @@ -18,17 +19,17 @@ namespace XrayUI.Services Checks GitHub Releases for a newer XrayUI build, downloads +verifies the matching zip asset, extracts and validates it, then hands off to the standalone XrayUI.Updater.exe to overwrite the install directory. - - HTTP / SHA256 / progress patterns are intentionally cloned from - rather than refactored into a shared - helper — they are ~50 LOC each and the two services have different - failure-policy requirements. */ public sealed class UpdateService : IUpdateService { private const string ReleaseApiUrl = "https://api.github.com/repos/PhoenixNil/XrayUI-dev/releases/latest"; + // User-facing release notes live on the website, not in the GitHub release body — + // the release page stays a plain technical PR list, and the notes shown in-app can + // be written (and translated) for end users. + private const string ChangelogUrl = "https://www.xrayui.site/changelog.json"; + private const string AppExeName = "XrayUI-dev.exe"; private const string UpdaterExeName = "XrayUI.Updater.exe"; @@ -52,7 +53,7 @@ public sealed class UpdateService : IUpdateService // skip so dev iteration never tries to "upgrade" to the latest public release. if (AppVersion.IsDevBuild) return null; - using var client = CreateHttpClient(proxyUrl, TimeSpan.FromSeconds(20)); + using var client = CreateGithubClient(proxyUrl, TimeSpan.FromSeconds(20)); GhRelease? release; try @@ -71,7 +72,7 @@ public sealed class UpdateService : IUpdateService var tag = (release.TagName ?? string.Empty).TrimStart('v'); if (!Version.TryParse(tag, out var remoteVersion)) return null; - if (remoteVersion <= AppVersion.Current) return null; + if (AppVersion.CompareNormalized(remoteVersion, AppVersion.Current) <= 0) return null; var rid = CurrentRid(); if (rid is null) return null; @@ -97,6 +98,34 @@ public sealed class UpdateService : IUpdateService return new UpdateInfo(remoteVersion, release.TagName!, zipUrl, shaUrl, zipName); } + public async Task> FetchChangelogAsync( + UpdateInfo info, string? language, string? proxyUrl, CancellationToken ct) + { + try + { + using var client = CreateHttpClient(proxyUrl, TimeSpan.FromSeconds(10)); + + // Cache-buster: an edge node still holding the previous file would otherwise + // hide the notes for a release that just went out. + var url = $"{ChangelogUrl}?v={info.NewVersion}"; + + var feed = await client.GetFromJsonAsync( + url, AppJsonSerializerContext.Default.ChangelogFeed, ct); + + return ChangelogSelector.Select(feed, AppVersion.Current, info.NewVersion, language); + } + // Only a real caller cancellation propagates. HttpClient.Timeout also raises + // OperationCanceledException (as TaskCanceledException) with ct untouched, and + // rethrowing that would let a slow changelog host suppress the whole update + // notification — the notes are decoration, they must never do that. + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) + { + Debug.WriteLine($"[Update] Changelog fetch failed: {ex.Message}"); + return Array.Empty(); + } + } + public async Task DownloadVerifyAndExtractAsync( UpdateInfo info, string? proxyUrl, IProgress progress, CancellationToken ct) { @@ -115,7 +144,7 @@ public async Task DownloadVerifyAndExtractAsync( Directory.CreateDirectory(extractDir); Directory.CreateDirectory(runnerDir); - using var client = CreateHttpClient(proxyUrl, TimeSpan.FromMinutes(10)); + using var client = CreateGithubClient(proxyUrl, TimeSpan.FromMinutes(10)); // ── 1. .sha256 first (small, fail-fast on bad release) ───────────────── progress.Report(new ProgressDialogUpdate(Loc.GetString("Update_FetchingChecksum"))); @@ -165,7 +194,7 @@ public async Task DownloadVerifyAndExtractAsync( var actualFileVersion = FileVersionInfo.GetVersionInfo(newAppExe).FileVersion; if (string.IsNullOrEmpty(actualFileVersion) || !Version.TryParse(actualFileVersion, out var parsedFv) || - NormalizeForCompare(parsedFv) != NormalizeForCompare(info.NewVersion)) + AppVersion.CompareNormalized(parsedFv, info.NewVersion) != 0) { throw new InvalidDataException( Loc.Format("Update_VersionMismatch", info.NewVersion, actualFileVersion)); @@ -237,10 +266,6 @@ public void CleanupOldStagingDirs() _ => null, }; - // System.Version normalizes missing components to -1; align so 1.2.3 == 1.2.3.0. - private static (int, int, int, int) NormalizeForCompare(Version v) => - (v.Major, v.Minor, Math.Max(v.Build, 0), Math.Max(v.Revision, 0)); - private static HttpClient CreateHttpClient(string? proxyUrl, TimeSpan timeout) { var handler = new HttpClientHandler(); @@ -255,11 +280,17 @@ private static HttpClient CreateHttpClient(string? proxyUrl, TimeSpan timeout) } var client = new HttpClient(handler) { Timeout = timeout }; - client.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json"); client.DefaultRequestHeaders.UserAgent.ParseAdd($"XrayUI/{AppVersion.Current}"); return client; } + private static HttpClient CreateGithubClient(string? proxyUrl, TimeSpan timeout) + { + var client = CreateHttpClient(proxyUrl, timeout); + client.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json"); + return client; + } + private static string? ParseSha256SumLine(string content) { var line = content.Trim(); diff --git a/Strings/en-US/Resources.resw b/Strings/en-US/Resources.resw index fc0b0e6..706c359 100644 --- a/Strings/en-US/Resources.resw +++ b/Strings/en-US/Resources.resw @@ -302,9 +302,6 @@ Custom Routing Rules - - Update GeoFile routing data - Add Rule @@ -608,6 +605,21 @@ Updating XrayUI + + XrayUI {0} is available + + + What's new + + + en + + + Update now + + + Later + Enable TUN Mode @@ -620,33 +632,6 @@ Not connected - - Updating routing data - - - Already Up to Date - - - geoip.dat and geosite.dat are already the latest version. No download needed. - - - Update Successful - - - Updated. Please restart manually in TUN mode for changes to take effect. - - - Updated and xray reloaded. - - - Data files updated, but xray restart failed: {0} - - - Updated. Please restart xray for changes to take effect. - - - Updated. Changes will take effect on next xray start. - Fetching checksum file… @@ -1303,24 +1288,6 @@ Path: {0} Could not find wintun.dll Path: {0} - - Downloading via local proxy ({0}) … - - - Checking {0}.dat … - - - {0}.dat is up to date - - - {0}.dat checksum failed: the downloaded file's SHA256 does not match the server's. - - - Downloading {0} … {1:0.0} / {2:0.0} MB - - - Downloading {0} … {1:0.0} MB - Tcping Latency diff --git a/Strings/zh-CN/Resources.resw b/Strings/zh-CN/Resources.resw index 9a3db25..9d0b27d 100644 --- a/Strings/zh-CN/Resources.resw +++ b/Strings/zh-CN/Resources.resw @@ -302,9 +302,6 @@ 自定义路由规则 - - 更新geoFile路由数据 - 添加规则 @@ -608,6 +605,21 @@ 正在更新 XrayUI + + XrayUI {0} 已发布 + + + 更新内容 + + + zh + + + 立即更新 + + + 稍后 + 开启TUN模式 @@ -620,33 +632,6 @@ 未连接 - - 正在更新路由数据 - - - 已是最新 - - - geoip.dat 和 geosite.dat 都已是最新版本,无需下载。 - - - 更新成功 - - - 已更新。TUN 模式下请手动重启以生效。 - - - 已更新并重新加载 xray。 - - - 已更新数据文件,但重启 xray 失败:{0} - - - 已更新。请重启 xray 以生效。 - - - 已更新。下次启动 xray 时生效。 - 正在获取校验文件… @@ -1306,24 +1291,6 @@ 找不到 wintun.dll 路径:{0} - - 通过本地代理下载({0})… - - - 正在检查 {0}.dat … - - - {0}.dat 已是最新 - - - {0}.dat 校验失败:下载文件的 SHA256 与服务器公布的不一致。 - - - 正在下载 {0} … {1:0.0} / {2:0.0} MB - - - 正在下载 {0} … {1:0.0} MB - Tcping延迟 diff --git a/ViewModels/ControlPanelViewModel.cs b/ViewModels/ControlPanelViewModel.cs index 64b5014..a058c8a 100644 --- a/ViewModels/ControlPanelViewModel.cs +++ b/ViewModels/ControlPanelViewModel.cs @@ -16,9 +16,9 @@ public partial class ControlPanelViewModel : ObservableObject private readonly XrayService _xray; private readonly TunService _tunService; private readonly StartupService _startupService; - private readonly GeoDataUpdateService _geoUpdate = new(); private readonly IUpdateService _update; private UpdateInfo? _availableUpdate; + private IReadOnlyList _availableUpdateNotes = Array.Empty(); // Guards OnIsTunModeChanged from firing the dialog when we update internally private bool _isTunInternalUpdate; @@ -645,14 +645,8 @@ private void ShowCustomRules() var vm = new CustomRulesViewModel( _settings, _xray, - _geoUpdate, _dialogs, - ReapplyRoutingAsync, - () => IsTunMode, - // In TUN mode the local SOCKS port still proxies traffic for non-TUN-captured - // processes (including ourselves), so routing the download through it is fine. - // When xray is stopped, null = direct connection. - () => _xray.IsRunning ? $"socks5://127.0.0.1:{LocalPort}" : null); + ReapplyRoutingAsync); ShowCustomRulesRequested?.Invoke(this, vm); } @@ -836,10 +830,13 @@ private async Task TrySaveSettingsAsync(AppSettings settings, string scenario) public string UpdateMenuText => Loc.Format("ControlPanel_UpdateFound", _availableUpdate?.NewVersion); /// Called from MainViewModel after the background check completes. - /// Pass null to clear (e.g. after a failed update attempt). - public void SetAvailableUpdate(UpdateInfo? info) + /// Pass a null to clear (e.g. after a failed update + /// attempt). is the already-fetched release notes + /// shown on the confirm dialog; empty means none. + public void SetAvailableUpdate(UpdateInfo? info, IReadOnlyList notes) { _availableUpdate = info; + _availableUpdateNotes = notes; IsUpdateAvailable = info is not null; } @@ -849,6 +846,9 @@ private async Task UpdateAppAsync() var info = _availableUpdate; if (info is null) return; + if (!await _dialogs.ShowUpdateConfirmDialogAsync(info.NewVersion, _availableUpdateNotes)) + return; + // Route the download through xray when it's running so users behind GFW // can still reach github.com / objects.githubusercontent.com. var proxy = IsRunning ? $"socks5://127.0.0.1:{LocalPort}" : null; diff --git a/ViewModels/CustomRulesViewModel.cs b/ViewModels/CustomRulesViewModel.cs index 27fda84..5b150b3 100644 --- a/ViewModels/CustomRulesViewModel.cs +++ b/ViewModels/CustomRulesViewModel.cs @@ -13,11 +13,8 @@ public partial class CustomRulesViewModel : ObservableObject { private readonly SettingsService _settings; private readonly XrayService _xray; - private readonly GeoDataUpdateService _geoUpdate; private readonly IDialogService _dialogs; private readonly Func? _reapplyRouting; - private readonly Func? _isTunMode; - private readonly Func? _getProxyUrl; public ObservableCollection Rules { get; } = new(); @@ -48,27 +45,21 @@ public partial class CustomRulesViewModel : ObservableObject /// /// Returns the XamlRoot of the hosting CustomRulesWindow. Set by the View in its ctor. - /// Used so dialogs raised from this VM (progress, success/error toasts) render on the - /// CustomRulesWindow instead of behind it on MainWindow. + /// Used so error dialogs raised from this VM render on the CustomRulesWindow instead + /// of behind it on MainWindow. /// public Func? GetXamlRoot { get; set; } public CustomRulesViewModel( SettingsService settings, XrayService xray, - GeoDataUpdateService geoUpdate, IDialogService dialogs, - Func? reapplyRouting, - Func? isTunMode, - Func? getProxyUrl = null) + Func? reapplyRouting) { _settings = settings; _xray = xray; - _geoUpdate = geoUpdate; _dialogs = dialogs; _reapplyRouting = reapplyRouting; - _isTunMode = isTunMode; - _getProxyUrl = getProxyUrl; } public async Task LoadAsync() @@ -197,88 +188,5 @@ await _dialogs.ShowErrorAsync( xamlRoot); } } - - // ── Update geo data ────────────────────────────────────────────────── - - /// - /// Invoked directly when the user clicks the refresh button. - /// Shows a modal progress dialog, downloads (or skips if already latest), restarts xray - /// if something actually changed, then surfaces the result. All dialogs are rooted in - /// the CustomRulesWindow via . - /// - [RelayCommand] - private async Task UpdateGeoData() - { - var xamlRoot = GetXamlRoot?.Invoke(); - - // Route through xray's local SOCKS5 port when it's running — in mainland China - // GitHub's releases CDN is often unreachable or painfully slow, and the user - // already has a working tunnel up. Null = direct connection. - var proxyUrl = _getProxyUrl?.Invoke(); - - GeoDataUpdateService.UpdateResult result = default; - - try - { - await _dialogs.ShowProgressDialogAsync( - L.GeoUpdate_Updating, - async (progress, ct) => result = await _geoUpdate.UpdateAsync(progress, proxyUrl, ct), - xamlRoot); - } - catch (OperationCanceledException ex) when (ex.GetType() == typeof(OperationCanceledException)) - { - // DialogService throws exactly `OperationCanceledException` for user cancel. - // Any subclass (e.g. TaskCanceledException from HttpClient.Timeout) falls through - // to the generic Exception catch below so the failure is surfaced, not swallowed. - return; - } - catch (Exception ex) - { - await _dialogs.ShowErrorAsync(L.Error_UpdateFailed, ex.Message, xamlRoot); - return; - } - - // Everything was already current — don't bother restarting xray. - if (!result.AnyUpdated) - { - await _dialogs.ShowErrorAsync( - L.GeoUpdate_AlreadyLatest, - L.GeoUpdate_AlreadyLatestMsg, - xamlRoot); - return; - } - - // At least one file changed — decide whether to reload xray. - string message; - if (_xray.IsRunning) - { - if (_isTunMode?.Invoke() == true) - { - message = L.GeoUpdate_TunRestart; - } - else if (_reapplyRouting != null) - { - try - { - await _reapplyRouting(); - message = L.GeoUpdate_ReloadedOk; - } - catch (Exception ex) - { - message = Loc.Format("GeoUpdate_ReloadFailed", ex.Message); - } - } - else - { - message = L.GeoUpdate_RestartRequired; - } - } - else - { - message = L.GeoUpdate_NextStart; - } - - await _dialogs.ShowErrorAsync(L.GeoUpdate_Success, message, xamlRoot); - } } } diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index a2ead9e..d972cd2 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -279,7 +280,15 @@ private void QueueUpdateCheck(string? proxyUrl) _updateCheckQueued = false; return; } - _uiDispatcher?.TryEnqueue(() => ControlPanel.SetAvailableUpdate(info)); + + // Fetch the notes here, off the UI thread, so the confirm dialog opens + // instantly with them already in hand. Best-effort per the + // IUpdateService contract: a failed fetch returns an empty list + // rather than throwing, so it can never cost the notification. + var notes = await _updateService.FetchChangelogAsync( + info, L.Update_ChangelogLanguage, proxyUrl, CancellationToken.None); + + _uiDispatcher?.TryEnqueue(() => ControlPanel.SetAvailableUpdate(info, notes)); } catch { diff --git a/Views/CustomRulesWindow.xaml b/Views/CustomRulesWindow.xaml index e875ffc..da99816 100644 --- a/Views/CustomRulesWindow.xaml +++ b/Views/CustomRulesWindow.xaml @@ -37,14 +37,6 @@ Command="{x:Bind ViewModel.OpenAdvancedEditorCommand}"> -