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
71 changes: 71 additions & 0 deletions App.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.UI.Xaml;
using Microsoft.Windows.AppLifecycle;
using XrayUI.Helpers;
using XrayUI.Services;

namespace XrayUI
Expand All @@ -23,13 +27,41 @@ public partial class App

public App()
{
// Must run before InitializeComponent — the XAML resource loader caches the
// current locale at first touch, and that happens during component init.
ApplyPersistedLanguageOverride();

this.InitializeComponent();

ConfigureProcessShutdownBehavior();
this.UnhandledException += (_, _) => CleanupOnExit();
AppDomain.CurrentDomain.ProcessExit += (_, _) => CleanupOnExit();
}

private static void ApplyPersistedLanguageOverride()
{
string? language = null;
try
{
if (File.Exists(AppPaths.SettingsJsonPath))
{
using var stream = File.OpenRead(AppPaths.SettingsJsonPath);
using var doc = JsonDocument.Parse(stream);
if (doc.RootElement.TryGetProperty("Language", out var langElem)
&& langElem.ValueKind == JsonValueKind.String)
{
language = langElem.GetString();
}
}
}
catch (Exception ex)
{
Debug.WriteLine($"[Language] Failed to load persisted language: {ex.Message}");
}

LanguageHelper.ApplyOverride(language);
}

protected override async void OnLaunched(LaunchActivatedEventArgs args)
{
var cmdArgs = Environment.GetCommandLineArgs();
Expand Down Expand Up @@ -89,6 +121,45 @@ public void RequestShutdown(bool fastShutdown = false)
Environment.Exit(0);
}

/// <summary>
/// Restart the process. xray.exe is a child process not bound to a job object, so
/// bare termination would leak it (and the system proxy) — and <see cref="AppInstance.Restart"/>
/// itself bypasses Window.Closed / ProcessExit, so cleanup must be triggered explicitly here.
/// </summary>
public static void Restart()
{
(Application.Current as App)?.CleanupOnExit(fastShutdown: true);

// Restart terminates the process on success; any synchronous return (or
// exception) means the platform refused — fall through to a manual relaunch.
try { AppInstance.Restart(string.Empty); } catch { }

var exePath = Process.GetCurrentProcess().MainModule?.FileName;
if (!string.IsNullOrEmpty(exePath))
{
// Let an external process wait for this one to exit before relaunching.
// An in-process delayed task would be killed by Environment.Exit below.
try
{
Process.Start(new ProcessStartInfo
{
FileName = Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe",
Arguments = $"/c ping 127.0.0.1 -n 2 > nul & start \"\" \"{exePath}\"",
WorkingDirectory = AppContext.BaseDirectory,
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
});
}
catch
{
// Worst case the user relaunches manually.
}
}

Environment.Exit(0);
}

public void HandleSessionEnding()
{
CleanupOnExit(fastShutdown: true);
Expand Down
2 changes: 2 additions & 0 deletions Helpers/AppPaths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ public static class AppPaths
"XrayUI");

public static string UpdatesDir { get; } = Path.Combine(LocalAppDataDir, "Updates");

public static string SettingsJsonPath { get; } = Path.Combine(LocalAppDataDir, "settings.json");
}
}
262 changes: 262 additions & 0 deletions Helpers/L.cs

Large diffs are not rendered by default.

117 changes: 117 additions & 0 deletions Helpers/LanguageHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
using System;
using System.Diagnostics;
using Microsoft.Windows.Globalization;

namespace XrayUI.Helpers
{
/// <summary>
/// One row in <see cref="LanguageHelper.SupportedLanguages"/>. <c>Tag = null</c>
/// means "follow system" (no <c>PrimaryLanguageOverride</c> applied).
/// Top-level (not nested) so XAML's <c>x:DataType</c> can reference it directly.
///
/// Real language rows pass a fixed <paramref name="displayName"/> endonym
/// (the language's name in its own script — "简体中文", "English") so that
/// users always see their own language spelled the way they recognize it,
/// regardless of the currently-applied UI locale. The "follow system" row is
/// the exception: it isn't a language name, it's an action description, so it
/// passes a <c>resourceKey</c> and the display string is resolved against the
/// current UI locale instead.
/// </summary>
public sealed partial class LanguageInfo
{
private readonly string _displayName;
private readonly string? _resourceKey;

public string? Tag { get; }

public string DisplayName =>
_resourceKey is null ? _displayName : Loc.GetString(_resourceKey);

public LanguageInfo(string? tag, string displayName, string? resourceKey = null)
{
Tag = tag;
_displayName = displayName;
_resourceKey = resourceKey;
}
}

/// <summary>
/// Drives the WinAppSDK <see cref="ApplicationLanguages.PrimaryLanguageOverride"/>.
/// Adding a new language means adding one row to <see cref="SupportedLanguages"/> —
/// the UI dropdown, persisted-setting normalization and index lookups all read
/// from this single table.
///
/// Index 0 is the "follow system" choice (Tag = null): when applied, no override
/// is set and WinAppSDK falls back to the OS locale.
/// </summary>
public static class LanguageHelper
{
public static readonly LanguageInfo[] SupportedLanguages =
[
// Index 0 — "follow system" is an action label, not a language name, so
// it must follow the current UI locale (not stay in Chinese forever).
new(null, "跟随系统", resourceKey: "Language_FollowSystem"),
new("zh-CN", "简体中文"),
new("en-US", "English"),
];

/// <summary>
/// Returns the canonical tag if supported. A <c>null</c> return is also the
/// signal to skip <see cref="ApplyOverride"/> (i.e. follow system) — that
/// mapping is deliberate, not an error case.
/// </summary>
public static string? Normalize(string? tag)
{
if (string.IsNullOrEmpty(tag)) return null;
foreach (var lang in SupportedLanguages)
{
if (lang.Tag is not null
&& string.Equals(lang.Tag, tag, StringComparison.OrdinalIgnoreCase))
return lang.Tag;
}
return null;
}

/// <summary>
/// 0-based index in <see cref="SupportedLanguages"/>. <c>null</c> and unknown
/// tags both map to index 0 ("follow system"), keeping the UI dropdown
/// truthful when no explicit choice has been persisted.
/// </summary>
public static int IndexOf(string? tag)
{
if (string.IsNullOrEmpty(tag)) return 0;
for (int i = 0; i < SupportedLanguages.Length; i++)
{
if (string.Equals(SupportedLanguages[i].Tag, tag, StringComparison.OrdinalIgnoreCase))
return i;
}
return 0;
}

/// <summary>Tag at the given index. Index 0 ("follow system") returns <c>null</c>.</summary>
public static string? TagAt(int index)
=> (uint)index < (uint)SupportedLanguages.Length
? SupportedLanguages[index].Tag
: null;

/// <summary>
/// Applies the WinAppSDK language override. <c>null</c> / unsupported means
/// "follow system", which must explicitly clear any previously-persisted
/// override. Must be called before any XAML resource resolution.
/// </summary>
public static void ApplyOverride(string? tag)
{
var language = Normalize(tag);

try
{
ApplicationLanguages.PrimaryLanguageOverride = language ?? string.Empty;
}
catch (Exception ex)
{
var label = string.IsNullOrEmpty(language) ? "follow system" : language;
Debug.WriteLine($"[Language] Failed to apply '{label}': {ex.Message}");
}
}
}
}
20 changes: 20 additions & 0 deletions Helpers/Loc.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Microsoft.Windows.ApplicationModel.Resources;

namespace XrayUI.Helpers;

/// <summary>
/// Thin wrapper over WinAppSDK's <see cref="ResourceLoader"/>. The default
/// constructor resolves to the "Resources" resource map (which maps to
/// <c>Strings/{lang}/Resources.resw</c>). Cached once at static init; values
/// are frozen at the locale active when this class is first touched, so any
/// language change requires a process restart.
/// </summary>
public static class Loc
{
private static readonly ResourceLoader _loader = new();

public static string GetString(string key) => _loader.GetString(key);

public static string Format(string key, params object?[] args) =>
string.Format(_loader.GetString(key), args);
}
15 changes: 7 additions & 8 deletions MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,16 @@
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>

<Button Grid.Column="0"
<Button x:Name="DockButton"
Grid.Column="0"
Click="DockButton_Click"
Background="Transparent"
BorderThickness="0"
CornerRadius="4"
Width="30" Height="28"
Padding="0"
Margin="8,0,0,0"
VerticalAlignment="Center"
ToolTipService.ToolTip="切换mini窗口">
VerticalAlignment="Center">
<FontIcon Glyph="&#xE90C;" FontSize="13" />
</Button>

Expand All @@ -51,7 +51,8 @@
<FontIcon Glyph="&#xE72B;" FontSize="12" />
</Button>

<TextBlock Grid.Column="2"
<TextBlock x:Uid="MainWindow_TitleText"
Grid.Column="2"
Text="代理控制台"
FontSize="13"
VerticalAlignment="Center"
Expand Down Expand Up @@ -141,8 +142,7 @@
Width="28" Height="22"
Padding="0"
BorderThickness="0"
Background="Transparent"
ToolTipService.ToolTip="返回完整窗口">
Background="Transparent">
<FontIcon Glyph="&#xE740;" FontSize="12" />
</Button>

Expand All @@ -151,8 +151,7 @@
Width="28" Height="22"
Padding="0"
BorderThickness="0"
Background="Transparent"
ToolTipService.ToolTip="关闭">
Background="Transparent">
<FontIcon Glyph="&#xE711;" FontSize="12" />
</Button>
</Grid>
Expand Down
9 changes: 7 additions & 2 deletions MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ public MainWindow(bool startMinimized = false)

InitializeComponent();

Title = L.MainWindow_Title;
ToolTipService.SetToolTip(DockButton, L.MainWindow_ToggleMini);
ToolTipService.SetToolTip(MiniExpandButton, L.MainWindow_ExpandFull);
ToolTipService.SetToolTip(MinicloseButton, L.MainWindow_Close);

var hWnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
var scale = DpiHelper.GetWindowScale(hWnd);
AppWindow.Resize(new SizeInt32((int)Math.Round(950 * scale), (int)Math.Round(600 * scale)));
Expand Down Expand Up @@ -146,13 +151,13 @@ private void ConfigureTray()
{
var flyout = new MenuFlyout();

var openItem = new MenuFlyoutItem { Text = "\u6253\u5f00\u7a97\u53e3" };
var openItem = new MenuFlyoutItem { Text = L.Tray_Open };
openItem.Click += (_, _) => RestoreFromTray();
flyout.Items.Add(openItem);

flyout.Items.Add(new MenuFlyoutSeparator());

var exitItem = new MenuFlyoutItem { Text = "\u9000\u51fa" };
var exitItem = new MenuFlyoutItem { Text = L.Tray_Exit };
exitItem.Click += (_, _) => ExitApplication();
flyout.Items.Add(exitItem);

Expand Down
4 changes: 4 additions & 0 deletions Models/AppSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ public class AppSettings
/// <summary>"" | "quarter" | "half" | "full"; controls Xray log IP masking.</summary>
public string LogMaskAddress { get; set; } = string.Empty;

// ── Internationalization ──────────────────────────────────────────────
/// <summary>BCP-47 tag from <see cref="XrayUI.Helpers.LanguageHelper.SupportedLanguages"/>, or null to follow system.</summary>
public string? Language { get; set; }

// ── Personalization ───────────────────────────────────────────────────
/// <summary>"Light" | "Dark" | "Default" (follows system)</summary>
public string? ThemeSetting { get; set; }
Expand Down
13 changes: 7 additions & 6 deletions Models/SubscriptionEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Text.Json.Serialization;
using XrayUI.Helpers;

namespace XrayUI.Models
{
Expand Down Expand Up @@ -60,15 +61,15 @@ public string LastUpdatedText
{
get
{
if (!_lastUpdated.HasValue) return "上次更新: 从未更新";
if (!_lastUpdated.HasValue) return L.Subscription_NeverUpdated;
var delta = DateTimeOffset.Now - _lastUpdated.Value;
string rel;
if (delta.TotalSeconds < 60) rel = "刚刚";
else if (delta.TotalMinutes < 60) rel = $"{(int)delta.TotalMinutes} 分钟前";
else if (delta.TotalHours < 24) rel = $"{(int)delta.TotalHours} 小时前";
else if (delta.TotalDays < 30) rel = $"{(int)delta.TotalDays} 天前";
if (delta.TotalSeconds < 60) rel = L.Subscription_JustNow;
else if (delta.TotalMinutes < 60) rel = Loc.Format("Subscription_MinutesAgo", (int)delta.TotalMinutes);
else if (delta.TotalHours < 24) rel = Loc.Format("Subscription_HoursAgo", (int)delta.TotalHours);
else if (delta.TotalDays < 30) rel = Loc.Format("Subscription_DaysAgo", (int)delta.TotalDays);
else rel = _lastUpdated.Value.LocalDateTime.ToString("yyyy-MM-dd");
return $"上次更新: {rel}";
return Loc.Format("Subscription_LastUpdated", rel);
}
}

Expand Down
Loading
Loading