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}">
-