From 43342854217d233097195c728cf920160abc1244 Mon Sep 17 00:00:00 2001 From: Zero <1270128439@qq.com> Date: Mon, 27 Jul 2026 21:23:08 +0800 Subject: [PATCH 1/2] Show only the latest release notes, and gate releases on them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The update confirm dialog used to assemble every version between the installed one and the target, capped at four blocks, with per-version headers when the span was greater than one. In practice the span is almost always one release, and the multi-version path bought a version range comparison, a ChangelogEntry record and a sort for a case users rarely hit. ChangelogSelector.Select is now SelectLatest: it resolves versions[0] of the feed to one language and returns plain strings. ChangelogEntry is gone, and the dialog renders a flat list. The cost of that simplification is that nothing matches the feed entry against the version being installed any more. A website changelog that was not updated before the tag went out no longer degrades to an empty notes section — it shows the previous release's notes labelled as the new version, which is worse than showing nothing. So release.yml grows a changelog-gate job that blocks the run unless changelog.json already has the tag as versions[0] with both zh and en non-empty. It runs before the four AOT builds, so a miss costs seconds; fix the site and re-run the failed job, no need to delete and re-push the tag. The tag check is a step-level early exit rather than a job level `if:`, because a skipped job would take the publish jobs that need it down with it and break workflow_dispatch test runs. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 52 +++++++++ Models/ChangelogFeed.cs | 10 +- Services/ChangelogSelector.cs | 52 ++------- Services/DialogService.cs | 21 +--- Services/IDialogService.cs | 2 +- Services/IUpdateService.cs | 2 +- Services/UpdateService.cs | 6 +- ViewModels/ControlPanelViewModel.cs | 4 +- XrayUI.Tests/ChangelogSelectorTests.cs | 143 ++++++------------------- 9 files changed, 102 insertions(+), 190 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dfe64c8..c30ac43 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,8 +10,60 @@ permissions: contents: write jobs: + # Blocks the release before any build work when the website changelog does not + # already carry this tag's notes. Worth a hard gate because a stale feed does not + # degrade to an empty changelog: the in-app confirm dialog reads versions[0] + # without matching on the version (ChangelogSelector.SelectLatest), so users would + # see the *previous* release's notes presented as this one's. + # + # Order of operations: publish the website entry first, then push the tag. If this + # fails, fix the site and hit "Re-run failed jobs" — the tag does not need to be + # deleted and re-pushed. + changelog-gate: + name: changelog lists ${{ github.ref_name }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Verify changelog.json lists this version first + shell: bash + run: | + # workflow_dispatch runs carry a branch ref, not a version — nothing to check. + # This is a step-level early exit rather than a job-level `if:` on purpose: + # a skipped job would take the `publish` jobs that need it down with it. + case "$GITHUB_REF" in + refs/tags/*) ;; + *) echo "Not a tag ref ($GITHUB_REF) — skipping changelog check."; exit 0 ;; + esac + + tag="${GITHUB_REF_NAME#v}" + + # Cache-buster + no-cache: an edge node still holding the previous file would + # otherwise fake a pass (or a fail) here. Same reason UpdateService appends + # ?v={version} on the client side. + if ! json=$(curl -fsSL --retry 3 --retry-delay 5 -H 'Cache-Control: no-cache' \ + "https://www.xrayui.site/changelog.json?ci=$GITHUB_RUN_ID"); then + echo "::error::changelog.json could not be fetched — the site may be down." + exit 1 + fi + + # Must be versions[0], not "some entry matches": the dialog only ever reads + # the first entry, so a $tag block sitting further down still ships wrong notes. + latest=$(jq -r '.versions[0].version // ""' <<<"$json") + if [ "$latest" != "$tag" ]; then + echo "::error::changelog.json starts with '$latest', expected '$tag'. Put the $tag entry at the top of the feed, then re-run this job." + exit 1 + fi + + if ! jq -e '.versions[0] | (.zh | length > 0) and (.en | length > 0)' <<<"$json" >/dev/null; then + echo "::error::The $tag entry has no zh or no en lines — the dialog would silently fall back to the other language." + exit 1 + fi + + echo "OK: changelog.json lists $tag with both languages." + publish: name: publish ${{ matrix.rid }}${{ matrix.wasdk_sc && '-wasdk' || '' }} (AOT) + needs: changelog-gate runs-on: windows-latest timeout-minutes: 30 strategy: diff --git a/Models/ChangelogFeed.cs b/Models/ChangelogFeed.cs index 3e808e4..3c86640 100644 --- a/Models/ChangelogFeed.cs +++ b/Models/ChangelogFeed.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Text.Json.Serialization; @@ -7,9 +6,9 @@ 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). + /// page can stay a plain technical PR list. The first entry is the latest published + /// release; both languages live side by side so one request is enough and a missing + /// translation is visible at a glance while editing. /// internal sealed class ChangelogFeed { @@ -22,7 +21,4 @@ internal sealed class ChangelogVersion [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/ChangelogSelector.cs b/Services/ChangelogSelector.cs index 499e7c5..f056193 100644 --- a/Services/ChangelogSelector.cs +++ b/Services/ChangelogSelector.cs @@ -1,69 +1,31 @@ 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. + /// Resolves the feed's first (latest) release 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. + /// preferred language has no lines, the other one is used. /// - public static List Select( - ChangelogFeed? feed, Version currentVersion, Version targetVersion, string? language) + public static List SelectLatest(ChangelogFeed? feed, string? language) { - var result = new List(); - if (feed?.Versions is null) return result; + if (feed?.Versions is not { Count: > 0 }) return []; 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); + var latest = feed.Versions[0]; + var preferred = Clean(preferZh ? latest.Zh : latest.En); return preferred.Count > 0 ? preferred - : Clean(preferZh ? version.En : version.Zh); + : Clean(preferZh ? latest.En : latest.Zh); } private static List Clean(List? lines) diff --git a/Services/DialogService.cs b/Services/DialogService.cs index d93ef82..256f6cb 100644 --- a/Services/DialogService.cs +++ b/Services/DialogService.cs @@ -1006,7 +1006,7 @@ public async Task ShowShareLinkDialogAsync(string serverName, string link) // ── App update confirm ──────────────────────────────────────────────── public async Task ShowUpdateConfirmDialogAsync( - Version newVersion, IReadOnlyList notes) + Version newVersion, IReadOnlyList notes) { var dialog = CreateDialog(); dialog.Title = Loc.Format("Update_ConfirmTitle", newVersion); @@ -1039,23 +1039,8 @@ public async Task ShowUpdateConfirmDialogAsync( 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)); - } + foreach (var line in notes) + list.Children.Add(BuildNoteLine(line)); var scroller = new ScrollViewer { diff --git a/Services/IDialogService.cs b/Services/IDialogService.cs index c1ae2b0..8d2232e 100644 --- a/Services/IDialogService.cs +++ b/Services/IDialogService.cs @@ -33,7 +33,7 @@ public interface IDialogService /// compact title + buttons confirm. /// Task ShowUpdateConfirmDialogAsync( - Version newVersion, IReadOnlyList notes); + 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 138e32b..dcc013a 100644 --- a/Services/IUpdateService.cs +++ b/Services/IUpdateService.cs @@ -37,7 +37,7 @@ Task DownloadVerifyAndExtractAsync( /// 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( + Task> FetchChangelogAsync( UpdateInfo info, string? language, string? proxyUrl, CancellationToken ct); /// diff --git a/Services/UpdateService.cs b/Services/UpdateService.cs index 0801ee0..7617753 100644 --- a/Services/UpdateService.cs +++ b/Services/UpdateService.cs @@ -98,7 +98,7 @@ public sealed class UpdateService : IUpdateService return new UpdateInfo(remoteVersion, release.TagName!, zipUrl, shaUrl, zipName); } - public async Task> FetchChangelogAsync( + public async Task> FetchChangelogAsync( UpdateInfo info, string? language, string? proxyUrl, CancellationToken ct) { try @@ -112,7 +112,7 @@ public async Task> FetchChangelogAsync( var feed = await client.GetFromJsonAsync( url, AppJsonSerializerContext.Default.ChangelogFeed, ct); - return ChangelogSelector.Select(feed, AppVersion.Current, info.NewVersion, language); + return ChangelogSelector.SelectLatest(feed, language); } // Only a real caller cancellation propagates. HttpClient.Timeout also raises // OperationCanceledException (as TaskCanceledException) with ct untouched, and @@ -122,7 +122,7 @@ public async Task> FetchChangelogAsync( catch (Exception ex) { Debug.WriteLine($"[Update] Changelog fetch failed: {ex.Message}"); - return Array.Empty(); + return Array.Empty(); } } diff --git a/ViewModels/ControlPanelViewModel.cs b/ViewModels/ControlPanelViewModel.cs index 5a4c0eb..9039af0 100644 --- a/ViewModels/ControlPanelViewModel.cs +++ b/ViewModels/ControlPanelViewModel.cs @@ -18,7 +18,7 @@ public partial class ControlPanelViewModel : ObservableObject private readonly StartupService _startupService; private readonly IUpdateService _update; private UpdateInfo? _availableUpdate; - private IReadOnlyList _availableUpdateNotes = Array.Empty(); + private IReadOnlyList _availableUpdateNotes = Array.Empty(); // Guards OnIsTunModeChanged from firing the dialog when we update internally private bool _isTunInternalUpdate; @@ -872,7 +872,7 @@ private async Task TrySaveSettingsAsync(AppSettings settings, string scenario) /// 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) + public void SetAvailableUpdate(UpdateInfo? info, IReadOnlyList notes) { _availableUpdate = info; _availableUpdateNotes = notes; diff --git a/XrayUI.Tests/ChangelogSelectorTests.cs b/XrayUI.Tests/ChangelogSelectorTests.cs index c296f5a..5b1dfb0 100644 --- a/XrayUI.Tests/ChangelogSelectorTests.cs +++ b/XrayUI.Tests/ChangelogSelectorTests.cs @@ -19,111 +19,53 @@ private static ChangelogVersion V(string version, string[]? zh = null, string[]? [Fact] public void PicksChineseWhenLanguageIsChinese() { - var result = ChangelogSelector.Select( + var result = ChangelogSelector.SelectLatest( Feed(V("1.18", zh: ["中文条目"], en: ["English line"])), - new Version(1, 17), new Version(1, 18), "zh"); + "zh"); - Assert.Single(result); - Assert.Equal(["中文条目"], result[0].Lines); + Assert.Equal(["中文条目"], result); } [Fact] public void PicksEnglishForNonChineseLanguage() { - var result = ChangelogSelector.Select( + var result = ChangelogSelector.SelectLatest( Feed(V("1.18", zh: ["中文条目"], en: ["English line"])), - new Version(1, 17), new Version(1, 18), "en"); + "en"); - Assert.Equal(["English line"], result[0].Lines); + Assert.Equal(["English line"], result); } [Fact] public void FallsBackToOtherLanguageWhenPreferredMissing() { - var zhOnly = ChangelogSelector.Select( + var zhOnly = ChangelogSelector.SelectLatest( Feed(V("1.18", zh: ["只有中文"])), - new Version(1, 17), new Version(1, 18), "en"); - Assert.Equal(["只有中文"], zhOnly[0].Lines); + "en"); + Assert.Equal(["只有中文"], zhOnly); - var enOnly = ChangelogSelector.Select( + var enOnly = ChangelogSelector.SelectLatest( Feed(V("1.18", en: ["English only"])), - new Version(1, 17), new Version(1, 18), "zh"); - Assert.Equal(["English only"], enOnly[0].Lines); + "zh"); + Assert.Equal(["English only"], enOnly); } [Fact] - public void ExcludesVersionsAlreadyInstalled() + public void ReadsOnlyTheFirstVersion() { - var result = ChangelogSelector.Select( - Feed(V("1.17", en: ["old"]), V("1.18", en: ["new"])), - new Version(1, 17), new Version(1, 18), "en"); + var result = ChangelogSelector.SelectLatest( + Feed(V("1.18", en: ["latest"]), V("1.17", en: ["older"])), + "en"); - Assert.Single(result); - Assert.Equal(new Version(1, 18), result[0].Version); + Assert.Equal(["latest"], result); } [Fact] - public void ExcludesVersionsBeyondTheTarget() + public void DoesNotFallBackToAnOlderVersionWhenLatestHasNoNotes() { - // The feed can already list a release newer than the one being offered - // (e.g. notes pushed ahead of the GitHub release). - var result = ChangelogSelector.Select( - Feed(V("1.18", en: ["target"]), V("1.19", en: ["future"])), - new Version(1, 17), new Version(1, 18), "en"); - - Assert.Single(result); - Assert.Equal(new Version(1, 18), result[0].Version); - } - - [Fact] - public void SpansEveryVersionInRangeNewestFirst() - { - var result = ChangelogSelector.Select( - Feed(V("1.18", en: ["a"]), V("1.20", en: ["c"]), V("1.19", en: ["b"])), - new Version(1, 17), new Version(1, 20), "en"); - - Assert.Equal( - [new Version(1, 20), new Version(1, 19), new Version(1, 18)], - result.Select(e => e.Version)); - } - - [Fact] - public void CapsAtMaxVersionsKeepingTheNewest() - { - // Feed deliberately out of order so this also proves the cap runs after sorting. - var result = ChangelogSelector.Select( - Feed( - V("1.11", en: ["oldest"]), - V("1.15", en: ["e"]), - V("1.12", en: ["b"]), - V("1.14", en: ["d"]), - V("1.16", en: ["newest"]), - V("1.13", en: ["c"])), - new Version(1, 10), new Version(1, 16), "en"); - - Assert.Equal(ChangelogSelector.MaxVersions, result.Count); - Assert.Equal( - [new Version(1, 16), new Version(1, 15), new Version(1, 14), new Version(1, 13)], - result.Select(e => e.Version)); - } - - [Fact] - public void DoesNotPadWhenFewerVersionsThanTheCap() - { - var result = ChangelogSelector.Select( - Feed(V("1.18", en: ["only one"])), - new Version(1, 17), new Version(1, 18), "en"); - - Assert.Single(result); - } - - [Fact] - public void TreatsMissingVersionComponentsAsZero() - { - // "1.18" from the feed must not read as newer than an installed 1.18.0. - var result = ChangelogSelector.Select( - Feed(V("1.18", en: ["same version"])), - new Version(1, 18, 0), new Version(1, 19), "en"); + var result = ChangelogSelector.SelectLatest( + Feed(V("1.18", en: [""]), V("1.17", en: ["older"])), + "en"); Assert.Empty(result); } @@ -131,53 +73,28 @@ public void TreatsMissingVersionComponentsAsZero() [Fact] public void DropsBlankLinesAndTrims() { - var result = ChangelogSelector.Select( + var result = ChangelogSelector.SelectLatest( Feed(V("1.18", en: [" padded ", "", " "])), - new Version(1, 17), new Version(1, 18), "en"); - - Assert.Equal(["padded"], result[0].Lines); - } - - [Fact] - public void SkipsVersionsWithNoUsableLines() - { - var result = ChangelogSelector.Select( - Feed(V("1.18", zh: [""], en: []), V("1.19", en: ["real"])), - new Version(1, 17), new Version(1, 19), "zh"); + "en"); - Assert.Single(result); - Assert.Equal(new Version(1, 19), result[0].Version); - } - - [Fact] - public void SkipsUnparseableVersionStrings() - { - var result = ChangelogSelector.Select( - Feed(V("not-a-version", en: ["junk"]), V("1.18", en: ["good"])), - new Version(1, 17), new Version(1, 18), "en"); - - Assert.Single(result); - Assert.Equal(["good"], result[0].Lines); + Assert.Equal(["padded"], result); } [Fact] public void ReturnsEmptyForMissingOrEmptyFeed() { - var current = new Version(1, 17); - var target = new Version(1, 18); - - Assert.Empty(ChangelogSelector.Select(null, current, target, "en")); - Assert.Empty(ChangelogSelector.Select(new ChangelogFeed(), current, target, "en")); - Assert.Empty(ChangelogSelector.Select(Feed(), current, target, "en")); + Assert.Empty(ChangelogSelector.SelectLatest(null, "en")); + Assert.Empty(ChangelogSelector.SelectLatest(new ChangelogFeed(), "en")); + Assert.Empty(ChangelogSelector.SelectLatest(Feed(), "en")); } [Fact] public void TreatsNullLanguageAsEnglish() { - var result = ChangelogSelector.Select( + var result = ChangelogSelector.SelectLatest( Feed(V("1.18", zh: ["中文"], en: ["English"])), - new Version(1, 17), new Version(1, 18), null); + null); - Assert.Equal(["English"], result[0].Lines); + Assert.Equal(["English"], result); } } From 3dfc09ef83708d45b1480e9142b53135553fd4c1 Mon Sep 17 00:00:00 2001 From: Zero <1270128439@qq.com> Date: Mon, 27 Jul 2026 21:34:24 +0800 Subject: [PATCH 2/2] Look the target version up in the feed instead of taking the first entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Taking versions[0] was safe only while the feed trailed the releases. The changelog-gate added in the previous commit inverts that: a version's notes now have to be on the site before its tag is pushed, so from that moment until the release goes live — and forever if the release is abandoned — the newest entry is not the version clients are offered. A client still on an older build would read those notes and see the unreleased version's features under the name of the release it is actually being offered. That is the same silently-wrong-content failure the gate was written to prevent, just pointing the other way. SelectLatest becomes SelectForVersion: scan for the entry whose version matches the one being installed, return empty when there is none. The dialog already drops its notes section on an empty list, so a feed that has not caught up degrades to a bare confirm prompt. Comparison goes through AppVersion.CompareNormalized so "1.19" and "1.19.0" match. The shape of the previous commit's simplification is untouched — still one version, still flat strings, no ChangelogEntry and no cap. With the client matching by name, feed order carries no meaning, so the gate drops its versions[0] requirement and just looks the tag up. Its job is now to catch a release whose update dialog would ship blank, rather than one that would ship wrong. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 20 +++---- Models/ChangelogFeed.cs | 7 ++- Services/ChangelogSelector.cs | 36 ++++++++--- Services/UpdateService.cs | 2 +- XrayUI.Tests/ChangelogSelectorTests.cs | 82 ++++++++++++++++++-------- 5 files changed, 100 insertions(+), 47 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c30ac43..5ba0c38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,10 +11,10 @@ permissions: jobs: # Blocks the release before any build work when the website changelog does not - # already carry this tag's notes. Worth a hard gate because a stale feed does not - # degrade to an empty changelog: the in-app confirm dialog reads versions[0] - # without matching on the version (ChangelogSelector.SelectLatest), so users would - # see the *previous* release's notes presented as this one's. + # already carry this tag's notes. Correctness does not rest on this job — the client + # looks its own version up in the feed (ChangelogSelector.SelectForVersion) and shows + # nothing when it is absent — but "nothing" means shipping a release whose update + # dialog is silently blank, which is what this catches while it is still cheap. # # Order of operations: publish the website entry first, then push the tag. If this # fails, fix the site and hit "Re-run failed jobs" — the tag does not need to be @@ -46,15 +46,15 @@ jobs: exit 1 fi - # Must be versions[0], not "some entry matches": the dialog only ever reads - # the first entry, so a $tag block sitting further down still ships wrong notes. - latest=$(jq -r '.versions[0].version // ""' <<<"$json") - if [ "$latest" != "$tag" ]; then - echo "::error::changelog.json starts with '$latest', expected '$tag'. Put the $tag entry at the top of the feed, then re-run this job." + # Position in the feed is irrelevant — the client matches on the version name. + entry=$(jq --arg t "$tag" '[.versions[]? | select(.version == $t)][0] // empty' <<<"$json") + if [ -z "$entry" ]; then + have=$(jq -r '[.versions[]?.version] | join(", ")' <<<"$json") + echo "::error::changelog.json has no entry for '$tag' (found: $have). Publish it, then re-run this job." exit 1 fi - if ! jq -e '.versions[0] | (.zh | length > 0) and (.en | length > 0)' <<<"$json" >/dev/null; then + if ! jq -e '(.zh | length > 0) and (.en | length > 0)' <<<"$entry" >/dev/null; then echo "::error::The $tag entry has no zh or no en lines — the dialog would silently fall back to the other language." exit 1 fi diff --git a/Models/ChangelogFeed.cs b/Models/ChangelogFeed.cs index 3c86640..77deb4c 100644 --- a/Models/ChangelogFeed.cs +++ b/Models/ChangelogFeed.cs @@ -6,9 +6,10 @@ 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. The first entry is the latest published - /// release; both languages live side by side so one request is enough and a missing - /// translation is visible at a glance while editing. + /// page can stay a plain technical PR list. One entry per version — order is not + /// meaningful, the client looks its target version up by name — and both languages + /// live side by side so one request is enough and a missing translation is visible + /// at a glance while editing. /// internal sealed class ChangelogFeed { diff --git a/Services/ChangelogSelector.cs b/Services/ChangelogSelector.cs index f056193..773f99b 100644 --- a/Services/ChangelogSelector.cs +++ b/Services/ChangelogSelector.cs @@ -1,31 +1,51 @@ using System; using System.Collections.Generic; +using XrayUI.Helpers; using XrayUI.Models; namespace XrayUI.Services { /// - /// Resolves the feed's first (latest) release to one language. + /// Resolves one release's feed entry to one language. /// Pure — no I/O, no dispatcher — so it is unit-tested directly. /// internal static class ChangelogSelector { + /// + /// The version actually being offered. Matching on it is not optional: the feed + /// carries a release's notes *before* its tag is pushed (release.yml's + /// changelog-gate requires that order), so the newest entry is regularly not the + /// one being installed — a client still on an older build would otherwise be shown + /// the upcoming version's notes under the current version's name. No match returns + /// empty, and the dialog then drops the notes section entirely. + /// /// /// UI language code from the resources ("zh" / "en"). When the /// preferred language has no lines, the other one is used. /// - public static List SelectLatest(ChangelogFeed? feed, string? language) + public static List SelectForVersion( + ChangelogFeed? feed, Version target, string? language) { - if (feed?.Versions is not { Count: > 0 }) return []; + if (feed?.Versions is null) return []; var preferZh = language is not null && language.StartsWith("zh", StringComparison.OrdinalIgnoreCase); - var latest = feed.Versions[0]; - var preferred = Clean(preferZh ? latest.Zh : latest.En); - return preferred.Count > 0 - ? preferred - : Clean(preferZh ? latest.En : latest.Zh); + foreach (var entry in feed.Versions) + { + if (entry is null) continue; + if (!Version.TryParse(entry.Version, out var parsed)) continue; + // Normalized: the feed writes "1.19", which parses with Build/Revision -1, + // while a target built from "1.19.0" carries a zero Build. + if (AppVersion.CompareNormalized(parsed, target) != 0) continue; + + var preferred = Clean(preferZh ? entry.Zh : entry.En); + return preferred.Count > 0 + ? preferred + : Clean(preferZh ? entry.En : entry.Zh); + } + + return []; } private static List Clean(List? lines) diff --git a/Services/UpdateService.cs b/Services/UpdateService.cs index 7617753..a01909f 100644 --- a/Services/UpdateService.cs +++ b/Services/UpdateService.cs @@ -112,7 +112,7 @@ public async Task> FetchChangelogAsync( var feed = await client.GetFromJsonAsync( url, AppJsonSerializerContext.Default.ChangelogFeed, ct); - return ChangelogSelector.SelectLatest(feed, language); + return ChangelogSelector.SelectForVersion(feed, info.NewVersion, language); } // Only a real caller cancellation propagates. HttpClient.Timeout also raises // OperationCanceledException (as TaskCanceledException) with ct untouched, and diff --git a/XrayUI.Tests/ChangelogSelectorTests.cs b/XrayUI.Tests/ChangelogSelectorTests.cs index 5b1dfb0..de95d9a 100644 --- a/XrayUI.Tests/ChangelogSelectorTests.cs +++ b/XrayUI.Tests/ChangelogSelectorTests.cs @@ -16,12 +16,15 @@ private static ChangelogVersion V(string version, string[]? zh = null, string[]? En = en is null ? null : [.. en], }; + private static List Select(ChangelogFeed? feed, string target, string? language) => + ChangelogSelector.SelectForVersion(feed, Version.Parse(target), language); + [Fact] public void PicksChineseWhenLanguageIsChinese() { - var result = ChangelogSelector.SelectLatest( + var result = Select( Feed(V("1.18", zh: ["中文条目"], en: ["English line"])), - "zh"); + "1.18", "zh"); Assert.Equal(["中文条目"], result); } @@ -29,9 +32,9 @@ public void PicksChineseWhenLanguageIsChinese() [Fact] public void PicksEnglishForNonChineseLanguage() { - var result = ChangelogSelector.SelectLatest( + var result = Select( Feed(V("1.18", zh: ["中文条目"], en: ["English line"])), - "en"); + "1.18", "en"); Assert.Equal(["English line"], result); } @@ -39,33 +42,52 @@ public void PicksEnglishForNonChineseLanguage() [Fact] public void FallsBackToOtherLanguageWhenPreferredMissing() { - var zhOnly = ChangelogSelector.SelectLatest( - Feed(V("1.18", zh: ["只有中文"])), - "en"); + var zhOnly = Select(Feed(V("1.18", zh: ["只有中文"])), "1.18", "en"); Assert.Equal(["只有中文"], zhOnly); - var enOnly = ChangelogSelector.SelectLatest( - Feed(V("1.18", en: ["English only"])), - "zh"); + var enOnly = Select(Feed(V("1.18", en: ["English only"])), "1.18", "zh"); Assert.Equal(["English only"], enOnly); } [Fact] - public void ReadsOnlyTheFirstVersion() + public void PicksTheTargetEntryRegardlessOfPosition() { - var result = ChangelogSelector.SelectLatest( - Feed(V("1.18", en: ["latest"]), V("1.17", en: ["older"])), - "en"); + var result = Select( + Feed(V("1.19", en: ["not out yet"]), V("1.18", en: ["being installed"])), + "1.18", "en"); - Assert.Equal(["latest"], result); + Assert.Equal(["being installed"], result); } + /// + /// The release gate puts a version's notes on the site before its tag is pushed, so + /// between those two moments the newest entry is not the one clients are offered. + /// Showing it anyway would label the upcoming release's notes as the current one. + /// [Fact] - public void DoesNotFallBackToAnOlderVersionWhenLatestHasNoNotes() + public void ReturnsEmptyWhenTheTargetVersionIsAbsent() { - var result = ChangelogSelector.SelectLatest( + var result = Select( + Feed(V("1.19", en: ["not out yet"]), V("1.17", en: ["already installed"])), + "1.18", "en"); + + Assert.Empty(result); + } + + [Fact] + public void MatchesRegardlessOfVersionPrecision() + { + var result = Select(Feed(V("1.19", en: ["notes"])), "1.19.0", "en"); + + Assert.Equal(["notes"], result); + } + + [Fact] + public void DoesNotFallBackToAnotherVersionWhenTheTargetHasNoNotes() + { + var result = Select( Feed(V("1.18", en: [""]), V("1.17", en: ["older"])), - "en"); + "1.18", "en"); Assert.Empty(result); } @@ -73,9 +95,9 @@ public void DoesNotFallBackToAnOlderVersionWhenLatestHasNoNotes() [Fact] public void DropsBlankLinesAndTrims() { - var result = ChangelogSelector.SelectLatest( + var result = Select( Feed(V("1.18", en: [" padded ", "", " "])), - "en"); + "1.18", "en"); Assert.Equal(["padded"], result); } @@ -83,17 +105,27 @@ public void DropsBlankLinesAndTrims() [Fact] public void ReturnsEmptyForMissingOrEmptyFeed() { - Assert.Empty(ChangelogSelector.SelectLatest(null, "en")); - Assert.Empty(ChangelogSelector.SelectLatest(new ChangelogFeed(), "en")); - Assert.Empty(ChangelogSelector.SelectLatest(Feed(), "en")); + Assert.Empty(Select(null, "1.18", "en")); + Assert.Empty(Select(new ChangelogFeed(), "1.18", "en")); + Assert.Empty(Select(Feed(), "1.18", "en")); + } + + [Fact] + public void IgnoresEntriesWithAnUnparseableVersion() + { + var result = Select( + Feed(V("nightly", en: ["junk"]), V("1.18", en: ["real"])), + "1.18", "en"); + + Assert.Equal(["real"], result); } [Fact] public void TreatsNullLanguageAsEnglish() { - var result = ChangelogSelector.SelectLatest( + var result = Select( Feed(V("1.18", zh: ["中文"], en: ["English"])), - null); + "1.18", null); Assert.Equal(["English"], result); }