Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Helpers/AppVersion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
/// 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.
/// </summary>
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));
}
}
17 changes: 7 additions & 10 deletions Helpers/L.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
/// <summary>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.</summary>
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 ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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");
Expand All @@ -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");
Expand Down
28 changes: 28 additions & 0 deletions Models/ChangelogFeed.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;

namespace XrayUI.Models
{
/// <summary>
/// Shape of <c>https://www.xrayui.site/changelog.json</c> — 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).
/// </summary>
internal sealed class ChangelogFeed
{
[JsonPropertyName("versions")] public List<ChangelogVersion>? Versions { get; set; }
}

internal sealed class ChangelogVersion
{
[JsonPropertyName("version")] public string? Version { get; set; }
[JsonPropertyName("zh")] public List<string>? Zh { get; set; }
[JsonPropertyName("en")] public List<string>? En { get; set; }
}

/// <summary>One version's notes, already resolved to a single language.</summary>
public sealed record ChangelogEntry(Version Version, IReadOnlyList<string> Lines);
}
2 changes: 2 additions & 0 deletions Services/AppJsonSerializerContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
82 changes: 82 additions & 0 deletions Services/ChangelogSelector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using XrayUI.Helpers;
using XrayUI.Models;

namespace XrayUI.Services
{
/// <summary>
/// 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.
/// </summary>
internal static class ChangelogSelector
{
/// <summary>
/// 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.
/// </summary>
internal const int MaxVersions = 4;

/// <param name="language">
/// UI language code from the resources (<c>"zh"</c> / <c>"en"</c>). 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.
/// </param>
public static List<ChangelogEntry> Select(
ChangelogFeed? feed, Version currentVersion, Version targetVersion, string? language)
{
var result = new List<ChangelogEntry>();
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<string> 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<string> Clean(List<string>? lines)
{
var result = new List<string>();
if (lines is null) return result;

foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line)) continue;
result.Add(line.Trim());
}
return result;
}
}
}
199 changes: 103 additions & 96 deletions Services/DialogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -734,102 +735,6 @@ public async Task ShowErrorAsync(string title, string message, XamlRoot? xamlRoo

// ── Progress ──────────────────────────────────────────────────────────

public async Task ShowProgressDialogAsync(string title, Func<IProgress<string>, 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<T> 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<string>(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<IProgress<ProgressDialogUpdate>, CancellationToken, Task> work, XamlRoot? xamlRoot = null)
{
Expand Down Expand Up @@ -1098,6 +1003,108 @@ public async Task ShowShareLinkDialogAsync(string serverName, string link)
return (toggle.IsOn, checkBox.IsChecked == true);
}

// ── App update confirm ────────────────────────────────────────────────

public async Task<bool> ShowUpdateConfirmDialogAsync(
Version newVersion, IReadOnlyList<ChangelogEntry> 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;
}

/// <summary>
/// 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.
/// </summary>
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<bool> ShowDnsSettingsDialogAsync(AppSettings settings, bool isTunMode)
Expand Down
Loading
Loading