From 1e55d56214d7ed33c8562fbbd106955d9237326b Mon Sep 17 00:00:00 2001 From: Errer Date: Fri, 28 Aug 2026 04:17:00 +0800 Subject: [PATCH 01/14] =?UTF-8?q?=E7=9B=9B=E8=B6=A3=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E7=BD=91=E7=BB=9C=E4=BB=A3=E7=90=86=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../XLHttpClientFactory.cs | 4 +- .../XLProxyProvider.cs | 81 +++++++++++ src/XIVLauncher.Common/Constant/Paths.cs | 3 + src/XIVLauncher.Common/Game/Launcher.cs | 3 +- src/XIVLauncher.DCTravel/DCTravelClient.cs | 4 +- .../GameFileDownloader.cs | 7 +- .../GamePatchMetadataClient.cs | 11 +- .../Update/GamePatchInstaller.cs | 10 +- .../XIVLauncher.GamePatchV3.csproj | 1 + .../Channels/LoginChannelContext.cs | 4 +- .../Http/XLProxyProviderTests.cs | 84 ++++++++++++ .../Settings/ProxySettingsStoreTests.cs | 129 ++++++++++++++++++ src/XIVLauncher/Settings/ProxySettings.cs | 94 +++++++++++++ .../Settings/ProxySettingsStore.cs | 102 ++++++++++++++ src/XIVLauncher/Settings/ProxyType.cs | 12 ++ .../Startup/StartupOrchestrator.cs | 3 + .../Windows/ProxySettingsWindow.xaml | 125 +++++++++++++++++ .../Windows/ProxySettingsWindow.xaml.cs | 43 ++++++ src/XIVLauncher/Windows/SettingsWindow.xaml | 15 ++ .../Windows/SettingsWindow.xaml.cs | 11 ++ .../ViewModel/ProxySettingsWindowViewModel.cs | 79 +++++++++++ 21 files changed, 818 insertions(+), 7 deletions(-) create mode 100644 src/XIVLauncher.Common.Http/XLProxyProvider.cs create mode 100644 src/XIVLauncher.Test/Http/XLProxyProviderTests.cs create mode 100644 src/XIVLauncher.Test/Settings/ProxySettingsStoreTests.cs create mode 100644 src/XIVLauncher/Settings/ProxySettings.cs create mode 100644 src/XIVLauncher/Settings/ProxySettingsStore.cs create mode 100644 src/XIVLauncher/Settings/ProxyType.cs create mode 100644 src/XIVLauncher/Windows/ProxySettingsWindow.xaml create mode 100644 src/XIVLauncher/Windows/ProxySettingsWindow.xaml.cs create mode 100644 src/XIVLauncher/Windows/ViewModel/ProxySettingsWindowViewModel.cs diff --git a/src/XIVLauncher.Common.Http/XLHttpClientFactory.cs b/src/XIVLauncher.Common.Http/XLHttpClientFactory.cs index d8f420bc6..f2fc53ef0 100644 --- a/src/XIVLauncher.Common.Http/XLHttpClientFactory.cs +++ b/src/XIVLauncher.Common.Http/XLHttpClientFactory.cs @@ -8,12 +8,14 @@ public static HttpClient Create ( TimeSpan connectTimeout, int maxConnectionsPerServer, - DecompressionMethods automaticDecompression + DecompressionMethods automaticDecompression, + IWebProxy? proxy = null ) { var handler = new SocketsHttpHandler { UseProxy = true, + Proxy = proxy, ConnectTimeout = connectTimeout, MaxConnectionsPerServer = maxConnectionsPerServer, EnableMultipleHttp2Connections = true, diff --git a/src/XIVLauncher.Common.Http/XLProxyProvider.cs b/src/XIVLauncher.Common.Http/XLProxyProvider.cs new file mode 100644 index 000000000..46641ce5b --- /dev/null +++ b/src/XIVLauncher.Common.Http/XLProxyProvider.cs @@ -0,0 +1,81 @@ +using System.Net; +using Serilog; + +namespace XIVLauncher.Common.Http; + +/// +/// 代理配置快照, 由应用设置层提供, 避免公共库依赖 UI 层类型 +/// +public sealed record ProxyConfigSnapshot +( + string Type, + string Host, + int Port, + string? Username = null, + string? Password = null +) +{ + public bool IsDisabled => + string.IsNullOrWhiteSpace(Host) + || Port is < 1 or > 65535 + || string.IsNullOrWhiteSpace(Type) + || string.Equals(Type, "None", StringComparison.OrdinalIgnoreCase); +} + +/// +/// 启动器全局代理提供者。 +/// HTTP/HTTPS 与 SOCKS4/4a/5 均通过 SocketsHttpHandler 原生支持, 无需额外依赖。 +/// 注意: .NET 的 SOCKS5 实现为本地 DNS 解析 (非 socks5h 远程解析)。 +/// +public static class XLProxyProvider +{ + public static IWebProxy? Current { get; private set; } + + public static void Apply(ProxyConfigSnapshot? config) + { + Current = BuildWebProxy(config); + + Log.Information + ( + "[XLProxyProvider] 代理配置已应用: {Proxy}", + Current is WebProxy webProxy && webProxy.Address != null ? + webProxy.Address.ToString() : + "无" + ); + } + + public static IWebProxy? BuildWebProxy(ProxyConfigSnapshot? config) + { + if (config is null || config.IsDisabled) + return null; + + try + { + var scheme = config.Type.Trim().ToLowerInvariant() switch + { + "http" => "http", + "https" => "https", + "socks5" => "socks5", + _ => null + }; + + if (scheme == null) + { + Log.Warning("[XLProxyProvider] 不支持的代理类型: {ProxyType}", config.Type); + return null; + } + + var proxy = new WebProxy($"{scheme}://{config.Host.Trim()}:{config.Port}"); + + if (!string.IsNullOrWhiteSpace(config.Username)) + proxy.Credentials = new NetworkCredential(config.Username, config.Password); + + return proxy; + } + catch (Exception ex) + { + Log.Warning(ex, "[XLProxyProvider] 构建代理配置失败: {ProxyHost}:{ProxyPort} ({ProxyType})", config.Host, config.Port, config.Type); + return null; + } + } +} diff --git a/src/XIVLauncher.Common/Constant/Paths.cs b/src/XIVLauncher.Common/Constant/Paths.cs index cac1282a1..0b6ab340b 100644 --- a/src/XIVLauncher.Common/Constant/Paths.cs +++ b/src/XIVLauncher.Common/Constant/Paths.cs @@ -43,6 +43,9 @@ public static DirectoryInfo ResolvePatchPath(DirectoryInfo? configuredPatchPath, public static string GetConfigPath(string prefix = "launcher") => Path.Join(RoamingPath, $"{prefix}ConfigV3.json"); + public static string GetProxyConfigPath() => + Path.Join(AppContext.BaseDirectory, "proxyConfigV3.json"); + public static string GetGamePath() { const string REGISTRY_PATH = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\最终幻想14"; diff --git a/src/XIVLauncher.Common/Game/Launcher.cs b/src/XIVLauncher.Common/Game/Launcher.cs index 03bfdfd3c..13ddff2b9 100644 --- a/src/XIVLauncher.Common/Game/Launcher.cs +++ b/src/XIVLauncher.Common/Game/Launcher.cs @@ -2,6 +2,7 @@ using Serilog; using XIVLauncher.Common.Constant; using XIVLauncher.Common.Game.Exceptions; +using XIVLauncher.Common.Http; using XIVLauncher.Common.Util; namespace XIVLauncher.Common.Game; @@ -9,7 +10,7 @@ namespace XIVLauncher.Common.Game; public partial class Launcher { public RestartMonitor RestartMonitor { get; } = new(); - public HttpClient MockHttpClient { get; } = new(new HttpClientHandler { UseCookies = true }); + public HttpClient MockHttpClient { get; } = new(new HttpClientHandler { UseCookies = true, Proxy = XLProxyProvider.Current }); public FFXIVProcess? LaunchGame ( diff --git a/src/XIVLauncher.DCTravel/DCTravelClient.cs b/src/XIVLauncher.DCTravel/DCTravelClient.cs index f7669113b..c22125e10 100644 --- a/src/XIVLauncher.DCTravel/DCTravelClient.cs +++ b/src/XIVLauncher.DCTravel/DCTravelClient.cs @@ -3,6 +3,7 @@ using System.Text.Json.Nodes; using Serilog; using XIVLauncher.Common.Constant; +using XIVLauncher.Common.Http; using XIVLauncher.Login; using XIVLauncher.Login.Workflow; @@ -78,7 +79,8 @@ public DCTravelClient(string nSessionID) CookieContainer = cookieContainer, UseCookies = true, AllowAutoRedirect = true, - AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli, + Proxy = XLProxyProvider.Current }; httpClient = new HttpClient(handler) { diff --git a/src/XIVLauncher.GamePatchV3/GameFileDownloader.cs b/src/XIVLauncher.GamePatchV3/GameFileDownloader.cs index 2dc5fd310..ae249d744 100644 --- a/src/XIVLauncher.GamePatchV3/GameFileDownloader.cs +++ b/src/XIVLauncher.GamePatchV3/GameFileDownloader.cs @@ -6,6 +6,7 @@ using System.Text; using Serilog; using XIVLauncher.Common.Constant; +using XIVLauncher.Common.Http; using XIVLauncher.GamePatchV3.Integrity; using XIVLauncher.GamePatchV3.Integrity.Models; @@ -27,7 +28,11 @@ public class GameFileDownloader : IDisposable private string dataVersion = null!; public GameFileDownloader() - : this(new SocketsHttpHandler()) + : this(new SocketsHttpHandler + { + UseProxy = true, + Proxy = XLProxyProvider.Current + }) { } diff --git a/src/XIVLauncher.GamePatchV3/GamePatchMetadataClient.cs b/src/XIVLauncher.GamePatchV3/GamePatchMetadataClient.cs index 88e097e39..d9b84230c 100644 --- a/src/XIVLauncher.GamePatchV3/GamePatchMetadataClient.cs +++ b/src/XIVLauncher.GamePatchV3/GamePatchMetadataClient.cs @@ -1,7 +1,9 @@ using System.Diagnostics; +using System.Net; using System.Text.Json; using Serilog; using XIVLauncher.Common.Constant; +using XIVLauncher.Common.Http; using XIVLauncher.GamePatchV3.Integrity.Models; using XIVLauncher.GamePatchV3.Models; using XIVLauncher.GamePatchV3.Update.Models; @@ -15,7 +17,14 @@ public sealed class GamePatchMetadataClient : IDisposable PropertyNameCaseInsensitive = true }; - private readonly HttpClient client = new(); + private readonly HttpClient client = new + ( + new SocketsHttpHandler + { + UseProxy = true, + Proxy = XLProxyProvider.Current + } + ); public void Dispose() => client.Dispose(); diff --git a/src/XIVLauncher.GamePatchV3/Update/GamePatchInstaller.cs b/src/XIVLauncher.GamePatchV3/Update/GamePatchInstaller.cs index c79695def..c352ff490 100644 --- a/src/XIVLauncher.GamePatchV3/Update/GamePatchInstaller.cs +++ b/src/XIVLauncher.GamePatchV3/Update/GamePatchInstaller.cs @@ -10,6 +10,7 @@ using System.Xml.Linq; using Serilog; using XIVLauncher.Common.Constant; +using XIVLauncher.Common.Http; using XIVLauncher.GamePatchV3.Integrity.Models; using XIVLauncher.GamePatchV3.Models; using XIVLauncher.GamePatchV3.Update.Models; @@ -24,7 +25,14 @@ public sealed class GamePatchInstaller : IDisposable PropertyNameCaseInsensitive = true }; - private readonly HttpClient client = new() + private readonly HttpClient client = new + ( + new SocketsHttpHandler + { + UseProxy = true, + Proxy = XLProxyProvider.Current + } + ) { DefaultRequestVersion = HttpVersion.Version20, DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower diff --git a/src/XIVLauncher.GamePatchV3/XIVLauncher.GamePatchV3.csproj b/src/XIVLauncher.GamePatchV3/XIVLauncher.GamePatchV3.csproj index df0b5efd8..cda2a4b66 100644 --- a/src/XIVLauncher.GamePatchV3/XIVLauncher.GamePatchV3.csproj +++ b/src/XIVLauncher.GamePatchV3/XIVLauncher.GamePatchV3.csproj @@ -25,6 +25,7 @@ + diff --git a/src/XIVLauncher.Login/Channels/LoginChannelContext.cs b/src/XIVLauncher.Login/Channels/LoginChannelContext.cs index b39866ac0..98e2ec601 100644 --- a/src/XIVLauncher.Login/Channels/LoginChannelContext.cs +++ b/src/XIVLauncher.Login/Channels/LoginChannelContext.cs @@ -3,6 +3,7 @@ using Serilog; using XIVLauncher.Account.DeviceProfiles; using XIVLauncher.Common.Constant; +using XIVLauncher.Common.Http; using XIVLauncher.Common.Util; using XIVLauncher.Login.Client; using XIVLauncher.Login.Exceptions; @@ -29,7 +30,8 @@ public LoginChannelContext(DeviceProfileSnapshot deviceProfile) var loginHandler = new HttpClientHandler { UseCookies = true, - CookieContainer = loginCookies + CookieContainer = loginCookies, + Proxy = XLProxyProvider.Current }; loginHttpClient = new HttpClient(loginHandler); diff --git a/src/XIVLauncher.Test/Http/XLProxyProviderTests.cs b/src/XIVLauncher.Test/Http/XLProxyProviderTests.cs new file mode 100644 index 000000000..5cc0e875d --- /dev/null +++ b/src/XIVLauncher.Test/Http/XLProxyProviderTests.cs @@ -0,0 +1,84 @@ +using System.Net; +using XIVLauncher.Common.Http; +using Xunit; + +namespace XIVLauncher.Test.Http; + +public sealed class XLProxyProviderTests +{ + [Fact] + public void BuildWebProxy_NullOrNone_ReturnsNull() + { + Assert.Null(XLProxyProvider.BuildWebProxy(null)); + Assert.Null(XLProxyProvider.BuildWebProxy(new ProxyConfigSnapshot("None", "127.0.0.1", 1080))); + } + + [Fact] + public void BuildWebProxy_Http_ReturnsProxyWithoutCredentials() + { + var proxy = XLProxyProvider.BuildWebProxy(new ProxyConfigSnapshot("Http", "127.0.0.1", 8080)); + + var webProxy = Assert.IsType(proxy); + Assert.Equal("http://127.0.0.1:8080/", webProxy.Address!.ToString()); + Assert.Null(webProxy.Credentials); + } + + [Fact] + public void BuildWebProxy_Https_ReturnsHttpsAddress() + { + var proxy = XLProxyProvider.BuildWebProxy(new ProxyConfigSnapshot("Https", "proxy.example.com", 443)); + + var webProxy = Assert.IsType(proxy); + // Uri 会归一化默认端口 + Assert.Equal("https://proxy.example.com/", webProxy.Address!.ToString()); + } + + [Fact] + public void BuildWebProxy_Socks5_ReturnsSocksAddress() + { + var proxy = XLProxyProvider.BuildWebProxy(new ProxyConfigSnapshot("Socks5", "127.0.0.1", 1080)); + + var webProxy = Assert.IsType(proxy); + Assert.Equal("socks5://127.0.0.1:1080/", webProxy.Address!.ToString()); + } + + [Fact] + public void BuildWebProxy_WithCredentials_SetsNetworkCredential() + { + var proxy = XLProxyProvider.BuildWebProxy + ( + new ProxyConfigSnapshot("Socks5", "127.0.0.1", 1080, "user", "pass") + ); + + var webProxy = Assert.IsType(proxy); + var credentials = Assert.IsType(webProxy.Credentials); + Assert.Equal("user", credentials.UserName); + Assert.Equal("pass", credentials.Password); + } + + [Fact] + public void BuildWebProxy_WithoutUsername_IgnoresPassword() + { + var proxy = XLProxyProvider.BuildWebProxy + ( + new ProxyConfigSnapshot("Http", "127.0.0.1", 8080, null, "pass") + ); + + var webProxy = Assert.IsType(proxy); + Assert.Null(webProxy.Credentials); + } + + [Theory] + [InlineData("Socks5", "", 1080)] + [InlineData("Socks5", "127.0.0.1", 0)] + [InlineData("Socks5", "127.0.0.1", 65536)] + [InlineData("Socks5", "127.0.0.1", -1)] + [InlineData("Ftp", "127.0.0.1", 1080)] + [InlineData("", "127.0.0.1", 1080)] + public void BuildWebProxy_InvalidInput_ReturnsNull(string type, string host, int port) + { + var proxy = XLProxyProvider.BuildWebProxy(new ProxyConfigSnapshot(type, host, port)); + + Assert.Null(proxy); + } +} diff --git a/src/XIVLauncher.Test/Settings/ProxySettingsStoreTests.cs b/src/XIVLauncher.Test/Settings/ProxySettingsStoreTests.cs new file mode 100644 index 000000000..29a43a048 --- /dev/null +++ b/src/XIVLauncher.Test/Settings/ProxySettingsStoreTests.cs @@ -0,0 +1,129 @@ +using System.IO; +using XIVLauncher.Settings; +using Xunit; + +namespace XIVLauncher.Test.Settings; + +public sealed class ProxySettingsStoreTests +{ + private static string CreateTempPath() => + Path.Combine(Path.GetTempPath(), $"xltest-proxy-{Guid.NewGuid():N}.json"); + + [Fact] + public void Load_MissingFile_ReturnsDefault() + { + var settings = ProxySettingsStore.Load(CreateTempPath()); + + Assert.Equal(ProxyType.None, settings.ProxyType); + Assert.Empty(settings.ProxyHost); + Assert.Equal(0, settings.ProxyPort); + } + + [Fact] + public void SaveLoad_RoundTrip_PreservesFields() + { + var path = CreateTempPath(); + + try + { + var settings = new ProxySettings + { + ProxyType = ProxyType.Socks5, + ProxyHost = "127.0.0.1", + ProxyPort = 1080, + ProxyUsername = "user" + }; + settings.SetPassword("secret"); + + ProxySettingsStore.Save(path, settings); + + var loaded = ProxySettingsStore.Load(path); + + Assert.Equal(ProxyType.Socks5, loaded.ProxyType); + Assert.Equal("127.0.0.1", loaded.ProxyHost); + Assert.Equal(1080, loaded.ProxyPort); + Assert.Equal("user", loaded.ProxyUsername); + Assert.Equal("secret", loaded.GetPassword()); + } + finally + { + File.Delete(path); + File.Delete(path + ".bak"); + } + } + + [Fact] + public void Load_CorruptedFile_IsolatesAndReturnsDefault() + { + var path = CreateTempPath(); + + try + { + File.WriteAllText(path, "{ not valid json !!"); + + var settings = ProxySettingsStore.Load(path); + + Assert.Equal(ProxyType.None, settings.ProxyType); + Assert.False(File.Exists(path)); + Assert.Contains(Directory.GetFiles(Path.GetTempPath(), Path.GetFileName(path) + ".broken-*"), file => true); + } + finally + { + foreach (var leftover in Directory.GetFiles(Path.GetTempPath(), Path.GetFileName(path) + "*")) + File.Delete(leftover); + } + } + + [Fact] + public void SetPassword_Empty_ClearsPassword() + { + var settings = new ProxySettings(); + settings.SetPassword("secret"); + + Assert.Equal("secret", settings.GetPassword()); + Assert.NotEmpty(settings.ProxyPasswordEncrypted); + + settings.SetPassword(null); + + Assert.Null(settings.GetPassword()); + Assert.Empty(settings.ProxyPasswordEncrypted); + } + + [Fact] + public void ToSnapshot_None_ReturnsNull() + { + var settings = new ProxySettings { ProxyType = ProxyType.None, ProxyHost = "127.0.0.1", ProxyPort = 1080 }; + + Assert.Null(settings.ToSnapshot()); + } + + [Fact] + public void ToSnapshot_Socks5WithPassword_MapsFields() + { + var settings = new ProxySettings + { + ProxyType = ProxyType.Socks5, + ProxyHost = "127.0.0.1", + ProxyPort = 1080, + ProxyUsername = "user" + }; + settings.SetPassword("pass"); + + var snapshot = settings.ToSnapshot(); + + Assert.NotNull(snapshot); + Assert.Equal("Socks5", snapshot.Type); + Assert.Equal("127.0.0.1", snapshot.Host); + Assert.Equal(1080, snapshot.Port); + Assert.Equal("user", snapshot.Username); + Assert.Equal("pass", snapshot.Password); + } + + [Fact] + public void ToSnapshot_InvalidPort_ReturnsNull() + { + var settings = new ProxySettings { ProxyType = ProxyType.Http, ProxyHost = "127.0.0.1", ProxyPort = 70000 }; + + Assert.Null(settings.ToSnapshot()); + } +} diff --git a/src/XIVLauncher/Settings/ProxySettings.cs b/src/XIVLauncher/Settings/ProxySettings.cs new file mode 100644 index 000000000..40edc9843 --- /dev/null +++ b/src/XIVLauncher/Settings/ProxySettings.cs @@ -0,0 +1,94 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Serialization; +using XIVLauncher.Common.Http; + +namespace XIVLauncher.Settings; + +/// +/// 启动器网络代理配置 (独立存储于启动器目录下的 proxyConfigV3.json) +/// +public sealed class ProxySettings +{ + private static readonly byte[] PasswordEntropy = "XIVLauncherCN.ProxyPassword"u8.ToArray(); + + public ProxyType ProxyType { get; set; } + + public string ProxyHost { get; set; } = string.Empty; + + public int ProxyPort { get; set; } + + public string ProxyUsername { get; set; } = string.Empty; + + /// + /// DPAPI (CurrentUser) 加密后的密码, Base64;空表示无密码 + /// + public string ProxyPasswordEncrypted { get; set; } = string.Empty; + + /// + /// 明文密码, 仅驻留内存, 不序列化 + /// + [JsonIgnore] + public string? ProxyPasswordPlain { get; set; } + + public void SetPassword(string? plainPassword) + { + if (string.IsNullOrEmpty(plainPassword)) + { + ProxyPasswordEncrypted = string.Empty; + ProxyPasswordPlain = null; + return; + } + + ProxyPasswordPlain = plainPassword; + ProxyPasswordEncrypted = EncryptPassword(plainPassword); + } + + public string? GetPassword() + { + if (ProxyPasswordPlain != null) + return ProxyPasswordPlain; + + if (string.IsNullOrWhiteSpace(ProxyPasswordEncrypted)) + return null; + + try + { + ProxyPasswordPlain = DecryptPassword(ProxyPasswordEncrypted); + return ProxyPasswordPlain; + } + catch + { + ProxyPasswordEncrypted = string.Empty; + return null; + } + } + + public ProxyConfigSnapshot? ToSnapshot() + { + if (ProxyType == ProxyType.None) + return null; + + var host = ProxyHost.Trim(); + if (string.IsNullOrWhiteSpace(host) || ProxyPort is < 1 or > 65535) + return null; + + var username = ProxyUsername.Trim(); + var password = GetPassword(); + + return new ProxyConfigSnapshot + ( + ProxyType.ToString(), + host, + ProxyPort, + string.IsNullOrWhiteSpace(username) ? null : username, + string.IsNullOrEmpty(password) ? null : password + ); + } + + private static string EncryptPassword(string plainPassword) => + Convert.ToBase64String(ProtectedData.Protect(Encoding.UTF8.GetBytes(plainPassword), PasswordEntropy, DataProtectionScope.CurrentUser)); + + private static string DecryptPassword(string encryptedPassword) => + Encoding.UTF8.GetString(ProtectedData.Unprotect(Convert.FromBase64String(encryptedPassword), PasswordEntropy, DataProtectionScope.CurrentUser)); +} diff --git a/src/XIVLauncher/Settings/ProxySettingsStore.cs b/src/XIVLauncher/Settings/ProxySettingsStore.cs new file mode 100644 index 000000000..f6250c53d --- /dev/null +++ b/src/XIVLauncher/Settings/ProxySettingsStore.cs @@ -0,0 +1,102 @@ +using System.IO; +using System.Text; +using System.Text.Json; +using Serilog; + +namespace XIVLauncher.Settings; + +/// +/// 代理配置独立存储: 启动器目录下的 proxyConfigV3.json +/// +public static class ProxySettingsStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true + }; + + public static ProxySettings Load(string path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + return new ProxySettings(); + + try + { + var json = File.ReadAllText(path, Encoding.UTF8); + var settings = JsonSerializer.Deserialize(json, JsonOptions) ?? new ProxySettings(); + + if (!string.IsNullOrWhiteSpace(settings.ProxyPasswordEncrypted) && settings.GetPassword() == null) + { + Log.Warning("[ProxySettingsStore] 代理密码解密失败, 已清空密码字段"); + settings.ProxyPasswordEncrypted = string.Empty; + } + + return settings; + } + catch (Exception ex) + { + Log.Error(ex, "[ProxySettingsStore] 读取代理配置失败: {Path}", path); + IsolateBrokenFile(path); + return new ProxySettings(); + } + } + + public static void Save(string path, ProxySettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("代理配置路径不能为空", nameof(path)); + + var directoryPath = Path.GetDirectoryName(path); + if (!string.IsNullOrWhiteSpace(directoryPath)) + Directory.CreateDirectory(directoryPath); + + var tempPath = path + ".tmp"; + var backupPath = path + ".bak"; + var json = JsonSerializer.Serialize(settings, JsonOptions); + + try + { + File.WriteAllText(tempPath, json, Encoding.UTF8); + + if (File.Exists(path)) + File.Replace(tempPath, path, backupPath, true); + else + File.Move(tempPath, path); + } + catch + { + TryDeleteTempFile(tempPath); + throw; + } + } + + private static void IsolateBrokenFile(string path) + { + try + { + var brokenPath = $"{path}.broken-{DateTime.Now:yyyyMMddHHmmssfff}"; + File.Move(path, brokenPath); + Log.Warning("[ProxySettingsStore] 已隔离损坏的代理配置文件: {BrokenPath}", brokenPath); + } + catch (Exception ex) + { + Log.Error(ex, "[ProxySettingsStore] 隔离损坏的代理配置文件失败: {Path}", path); + } + } + + private static void TryDeleteTempFile(string tempPath) + { + try + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + catch + { + // ignored + } + } +} diff --git a/src/XIVLauncher/Settings/ProxyType.cs b/src/XIVLauncher/Settings/ProxyType.cs new file mode 100644 index 000000000..64735193b --- /dev/null +++ b/src/XIVLauncher/Settings/ProxyType.cs @@ -0,0 +1,12 @@ +namespace XIVLauncher.Settings; + +/// +/// 代理类型 +/// +public enum ProxyType +{ + None = 0, + Http = 1, + Https = 2, + Socks5 = 3 +} diff --git a/src/XIVLauncher/Startup/StartupOrchestrator.cs b/src/XIVLauncher/Startup/StartupOrchestrator.cs index 0db46630a..c28303b79 100644 --- a/src/XIVLauncher/Startup/StartupOrchestrator.cs +++ b/src/XIVLauncher/Startup/StartupOrchestrator.cs @@ -141,6 +141,9 @@ private async Task InitializeSettingsAsync() if (!string.IsNullOrEmpty(commandLineOptions.RoamingPath)) Paths.OverrideRoamingPath(commandLineOptions.RoamingPath); + var proxySettings = ProxySettingsStore.Load(Paths.GetProxyConfigPath()); + XLProxyProvider.Apply(proxySettings.ToSnapshot()); + context.Settings.Update (settings => { diff --git a/src/XIVLauncher/Windows/ProxySettingsWindow.xaml b/src/XIVLauncher/Windows/ProxySettingsWindow.xaml new file mode 100644 index 000000000..6acf304e2 --- /dev/null +++ b/src/XIVLauncher/Windows/ProxySettingsWindow.xaml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/XIVLauncher/Windows/ProxySettingsWindow.xaml.cs b/src/XIVLauncher/Windows/ProxySettingsWindow.xaml.cs new file mode 100644 index 000000000..9e70351db --- /dev/null +++ b/src/XIVLauncher/Windows/ProxySettingsWindow.xaml.cs @@ -0,0 +1,43 @@ +using System.Windows; +using Serilog; +using XIVLauncher.Common.Constant; +using XIVLauncher.Settings; +using XIVLauncher.Windows.ViewModel; + +namespace XIVLauncher.Windows; + +public partial class ProxySettingsWindow +{ + private ProxySettingsWindowViewModel ViewModel => + (ProxySettingsWindowViewModel)DataContext; + + public ProxySettingsWindow() + { + InitializeComponent(); + + var settings = ProxySettingsStore.Load(Paths.GetProxyConfigPath()); + DataContext = new ProxySettingsWindowViewModel(settings); + } + + private void ConfirmButton_OnClick(object sender, RoutedEventArgs e) + { + try + { + ViewModel.Save(ProxyPasswordBox.Password); + DialogResult = true; + Close(); + } + catch (Exception ex) + { + Log.Error(ex, "保存代理配置失败"); + CustomMessageBox.Show + ( + $"保存代理配置失败:{ex.Message}", + "代理配置", + MessageBoxButton.OK, + MessageBoxImage.Warning, + parentWindow: this + ); + } + } +} diff --git a/src/XIVLauncher/Windows/SettingsWindow.xaml b/src/XIVLauncher/Windows/SettingsWindow.xaml index 188e23b6b..ed40bd979 100644 --- a/src/XIVLauncher/Windows/SettingsWindow.xaml +++ b/src/XIVLauncher/Windows/SettingsWindow.xaml @@ -491,6 +491,21 @@ Click="SharedDeviceProfileButton_Click" Style="{StaticResource ThemeOutlinedButtonStyle}" Content="管理" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SelectedValue="{Binding SelectedProfile.ProxyType, UpdateSourceTrigger=PropertyChanged}" + SelectedValuePath="Type" + Style="{StaticResource MaterialDesignOutlinedComboBox}" /> - - diff --git a/src/XIVLauncher/Windows/ProxySettingsWindow.xaml.cs b/src/XIVLauncher/Windows/ProxySettingsWindow.xaml.cs index 9e70351db..8aa3f213a 100644 --- a/src/XIVLauncher/Windows/ProxySettingsWindow.xaml.cs +++ b/src/XIVLauncher/Windows/ProxySettingsWindow.xaml.cs @@ -19,6 +19,28 @@ public ProxySettingsWindow() DataContext = new ProxySettingsWindowViewModel(settings); } + private void CreateProfileButton_OnClick(object sender, RoutedEventArgs e) => + ViewModel.CreateProfile(); + + private void DeleteProfileButton_OnClick(object sender, RoutedEventArgs e) + { + var profile = ViewModel.SelectedProfile; + if (profile == null) + return; + + if (CustomMessageBox.Show + ( + $"确定删除代理配置“{profile.DisplayName}”吗?", + "代理配置", + MessageBoxButton.YesNo, + MessageBoxImage.Question, + parentWindow: this + ) != MessageBoxResult.Yes) + return; + + ViewModel.DeleteSelectedProfile(); + } + private void ConfirmButton_OnClick(object sender, RoutedEventArgs e) { try diff --git a/src/XIVLauncher/Windows/ViewModel/ProxySettingsWindowViewModel.cs b/src/XIVLauncher/Windows/ViewModel/ProxySettingsWindowViewModel.cs index 76066f8a1..ca60ae566 100644 --- a/src/XIVLauncher/Windows/ViewModel/ProxySettingsWindowViewModel.cs +++ b/src/XIVLauncher/Windows/ViewModel/ProxySettingsWindowViewModel.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using XIVLauncher.Common.Constant; using XIVLauncher.Settings; @@ -8,6 +9,12 @@ public sealed partial class ProxySettingsWindowViewModel : ObservableObject { public sealed record ProxyTypeOption(ProxyType Type, string Display); + public sealed record ProxySelectItem(ProxyProfile? Profile) + { + public string DisplayName => + Profile is null ? "不使用代理" : Profile.DisplayName; + } + public IReadOnlyList ProxyTypeOptions { get; } = [ new(ProxyType.None, "不使用代理"), @@ -16,27 +23,26 @@ public sealed record ProxyTypeOption(ProxyType Type, string Display); new(ProxyType.Socks5, "SOCKS5") ]; - [ObservableProperty] - public partial ProxyTypeOption SelectedProxyTypeOption { get; set; } + public ObservableCollection Profiles { get; } = []; + public ObservableCollection SelectionItems { get; } = []; [ObservableProperty] - public partial string ProxyHost { get; set; } = string.Empty; + public partial ProxySelectItem SelectedItem { get; set; } [ObservableProperty] - public partial int ProxyPort { get; set; } = 1080; + public partial string ConfigFilePath { get; set; } = string.Empty; - [ObservableProperty] - public partial string ProxyUsername { get; set; } = string.Empty; + public ProxyProfile? SelectedProfile => + SelectedItem?.Profile; - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(PasswordHint))] - public partial bool HasExistingPassword { get; set; } + public bool HasSelectedProfile => + SelectedProfile != null; public string PasswordHint => - HasExistingPassword ? "留空保持当前密码" : "未设置"; - - [ObservableProperty] - public partial string ConfigFilePath { get; set; } = string.Empty; + HasSelectedProfile + && !string.IsNullOrWhiteSpace(SelectedProfile.ProxyPasswordEncrypted) ? + "留空保持当前密码" : + "未设置"; private readonly ProxySettings settings; @@ -44,36 +50,83 @@ public ProxySettingsWindowViewModel(ProxySettings settings) { ArgumentNullException.ThrowIfNull(settings); - this.settings = settings; - ConfigFilePath = Paths.GetProxyConfigPath(); - SelectedProxyTypeOption = ProxyTypeOptions.First(option => option.Type == settings.ProxyType); - ProxyHost = settings.ProxyHost; - ProxyPort = settings.ProxyPort is >= 1 and <= 65535 ? settings.ProxyPort : 1080; - ProxyUsername = settings.ProxyUsername; - HasExistingPassword = !string.IsNullOrWhiteSpace(settings.ProxyPasswordEncrypted); + this.settings = settings; + ConfigFilePath = Paths.GetProxyConfigPath(); + + foreach (var profile in settings.Profiles) + Profiles.Add(profile); + + RebuildSelection(); + + var activeProfile = settings.GetActiveProfile(); + SelectedItem = activeProfile is null ? + SelectionItems[0] : + SelectionItems.First(item => ReferenceEquals(item.Profile, activeProfile)); + } + + partial void OnSelectedItemChanged(ProxySelectItem? value) + { + OnPropertyChanged(nameof(SelectedProfile)); + OnPropertyChanged(nameof(HasSelectedProfile)); + OnPropertyChanged(nameof(PasswordHint)); + } + + public void CreateProfile() + { + var profile = new ProxyProfile { Name = "新代理配置", ProxyPort = 1080 }; + Profiles.Add(profile); + RebuildSelection(); + SelectedItem = SelectionItems.First(item => ReferenceEquals(item.Profile, profile)); + } + + public void DeleteSelectedProfile() + { + var profile = SelectedProfile; + if (profile == null) + return; + + Profiles.Remove(profile); + RebuildSelection(); + SelectedItem = SelectionItems[0]; } public void Save(string? passwordInput) { - var type = SelectedProxyTypeOption?.Type ?? ProxyType.None; + var profile = SelectedProfile; - if (type != ProxyType.None) + if (profile != null) { - if (string.IsNullOrWhiteSpace(ProxyHost)) - throw new InvalidOperationException("代理主机不能为空。"); + if (string.IsNullOrWhiteSpace(profile.Name)) + throw new InvalidOperationException("配置名称不能为空。"); - if (ProxyPort is < 1 or > 65535) - throw new InvalidOperationException("代理端口必须在 1 - 65535 之间。"); - } + if (profile.ProxyType != ProxyType.None) + { + if (string.IsNullOrWhiteSpace(profile.ProxyHost)) + throw new InvalidOperationException("代理主机不能为空。"); - settings.ProxyType = type; - settings.ProxyHost = type == ProxyType.None ? string.Empty : ProxyHost.Trim(); - settings.ProxyPort = type == ProxyType.None ? 0 : ProxyPort; - settings.ProxyUsername = ProxyUsername?.Trim() ?? string.Empty; + if (profile.ProxyPort is < 1 or > 65535) + throw new InvalidOperationException("代理端口必须在 1 - 65535 之间。"); + } - if (!string.IsNullOrEmpty(passwordInput)) - settings.SetPassword(passwordInput); + profile.Name = profile.Name.Trim(); + profile.ProxyHost = profile.ProxyHost.Trim(); + profile.ProxyUsername = profile.ProxyUsername.Trim(); + + if (!string.IsNullOrEmpty(passwordInput)) + profile.SetPassword(passwordInput); + } + + settings.ActiveProfileId = profile?.Id; ProxySettingsStore.Save(Paths.GetProxyConfigPath(), settings); } + + private void RebuildSelection() + { + SelectionItems.Clear(); + SelectionItems.Add(new ProxySelectItem(null)); + + foreach (var profile in Profiles) + SelectionItems.Add(new ProxySelectItem(profile)); + } } From 3fbc334364009dddf2122de4679570617e02a390 Mon Sep 17 00:00:00 2001 From: Errer Date: Sat, 29 Aug 2026 13:26:26 +0800 Subject: [PATCH 05/14] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=E8=AF=BB=E5=8F=96=E6=8F=90=E6=9D=83=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E8=BF=9B=E7=A8=8B=E4=B8=BB=E6=A8=A1=E5=9D=97=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E7=9A=84=E6=8B=92=E7=BB=9D=E8=AE=BF=E9=97=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/Main/Flows/GameInjectionFlow.cs | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/src/XIVLauncher/Windows/ViewModel/Main/Flows/GameInjectionFlow.cs b/src/XIVLauncher/Windows/ViewModel/Main/Flows/GameInjectionFlow.cs index ee51f77f4..8640489de 100644 --- a/src/XIVLauncher/Windows/ViewModel/Main/Flows/GameInjectionFlow.cs +++ b/src/XIVLauncher/Windows/ViewModel/Main/Flows/GameInjectionFlow.cs @@ -1,7 +1,11 @@ +using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Windows; +using Serilog; +using XIVLauncher.Common.Game; using XIVLauncher.Dalamud; +using XIVLauncher.Login.Models; using XIVLauncher.Support; using XIVLauncher.Windows.ViewModel.Main.Models; using XIVLauncher.Windows.ViewModel.Main.Services; @@ -36,14 +40,17 @@ public bool InjectGame return false; } - var gameExePath = gameProcess.MainModule?.FileName; - var gameExeDir = gameExePath == null ? - null : - Path.GetDirectoryName(gameExePath); + var gameExePath = TryGetGameExePath(gameProcess); + var gameExeDir = gameExePath == null ? + null : + Path.GetDirectoryName(gameExePath); var gamePath = gameExeDir == null ? null : new DirectoryInfo(gameExeDir).Parent; + if (gamePath?.Exists != true) + gamePath = GetConfiguredGamePath(); + if (gamePath?.Exists != true) { CustomMessageBox.Show("无法解析游戏目录, 注入失败", "XIVLauncherCN (Soil)", MessageBoxButton.OK, MessageBoxImage.Error, parentWindow: window); @@ -91,4 +98,27 @@ public bool InjectGame dalamudSession.InjectGame(gamePid, noPlugins); return true; } + + private static string? TryGetGameExePath + ( + Process gameProcess + ) + { + try + { + return gameProcess.MainModule?.FileName; + } + catch (Win32Exception ex) + { + Log.Error(ex, "无法读取游戏进程主模块, 回退到设置中的游戏目录"); + return null; + } + } + + private static DirectoryInfo? GetConfiguredGamePath() + { + var accountType = App.AccountManager.CurrentAccount?.AccountType + ?? App.Settings.SelectedLoginType.ToAccountType(XIVAccountType.Sdo); + return App.Settings.GetGamePath(accountType); + } } From 5b3dfca221235fa798bb4ca4ac41f26c21b97897 Mon Sep 17 00:00:00 2001 From: Errer Date: Sat, 12 Sep 2026 21:36:44 +0800 Subject: [PATCH 06/14] =?UTF-8?q?=E4=BB=A3=E7=90=86=E4=BB=85=E5=AF=B9?= =?UTF-8?q?=E7=9B=9B=E8=B6=A3=E5=9F=9F=E5=90=8D=E7=94=9F=E6=95=88=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=85=A8=E5=A4=A7=E5=8C=BA=E5=88=97=E8=A1=A8=E4=BB=A3?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../XLProxyProvider.cs | 34 +++++++++++++--- src/XIVLauncher.Login/Models/LoginArea.cs | 15 ++++--- .../Http/XLProxyProviderTests.cs | 40 +++++++++++++++++++ 3 files changed, 79 insertions(+), 10 deletions(-) diff --git a/src/XIVLauncher.Common.Http/XLProxyProvider.cs b/src/XIVLauncher.Common.Http/XLProxyProvider.cs index 46641ce5b..4bcb41418 100644 --- a/src/XIVLauncher.Common.Http/XLProxyProvider.cs +++ b/src/XIVLauncher.Common.Http/XLProxyProvider.cs @@ -33,14 +33,13 @@ public static class XLProxyProvider public static void Apply(ProxyConfigSnapshot? config) { - Current = BuildWebProxy(config); + var webProxy = BuildWebProxy(config); + Current = webProxy is null ? null : new SdoScopedProxy(webProxy); Log.Information ( - "[XLProxyProvider] 代理配置已应用: {Proxy}", - Current is WebProxy webProxy && webProxy.Address != null ? - webProxy.Address.ToString() : - "无" + "[XLProxyProvider] 代理配置已应用 (仅对盛趣域名 *.sdo.com / *.jijiagames.com 生效): {Proxy}", + webProxy is WebProxy { Address: not null } proxy ? proxy.Address.ToString() : "无" ); } @@ -78,4 +77,29 @@ public static void Apply(ProxyConfigSnapshot? config) return null; } } + + /// + /// 仅对盛趣域名 (*.sdo.com / *.jijiagames.com) 生效的代理包装: + /// 其余目标一律直连, 避免 GitHub / Dalamud / NuGet 等流量被错误导入代理。 + /// + private sealed class SdoScopedProxy(IWebProxy inner) : IWebProxy + { + public ICredentials? Credentials + { + get => inner.Credentials; + set => inner.Credentials = value; + } + + public Uri? GetProxy(Uri destination) => + IsSdoHost(destination.Host) ? inner.GetProxy(destination) : destination; + + public bool IsBypassed(Uri host) => + !IsSdoHost(host.Host); + + private static bool IsSdoHost(string host) => + string.Equals(host, "sdo.com", StringComparison.OrdinalIgnoreCase) + || host.EndsWith(".sdo.com", StringComparison.OrdinalIgnoreCase) + || string.Equals(host, "jijiagames.com", StringComparison.OrdinalIgnoreCase) + || host.EndsWith(".jijiagames.com", StringComparison.OrdinalIgnoreCase); + } } diff --git a/src/XIVLauncher.Login/Models/LoginArea.cs b/src/XIVLauncher.Login/Models/LoginArea.cs index 702777750..52284e579 100644 --- a/src/XIVLauncher.Login/Models/LoginArea.cs +++ b/src/XIVLauncher.Login/Models/LoginArea.cs @@ -7,10 +7,15 @@ namespace XIVLauncher.Login.Models; public class LoginArea { - private static readonly HttpClient Client = XLHttpClientFactory.Create(TimeSpan.FromSeconds(30), int.MaxValue, DecompressionMethods.None); - - static LoginArea() => - Client.Timeout = TimeSpan.FromSeconds(30); + private static readonly Lazy Client = new + ( + () => + { + var client = XLHttpClientFactory.Create(TimeSpan.FromSeconds(30), int.MaxValue, DecompressionMethods.None, XLProxyProvider.Current); + client.Timeout = TimeSpan.FromSeconds(30); + return client; + } + ); [JsonProperty("Areaid")] public string AreaID { get; set; } = null!; @@ -45,7 +50,7 @@ public static async Task Get() request.Headers.Add("Accept", "*/*"); request.Headers.Add("Host", "ff.dorado.sdo.com"); - using var response = await Client.SendAsync(request); + using var response = await Client.Value.SendAsync(request); response.EnsureSuccessStatusCode(); var text = await response.Content.ReadAsStringAsync(); diff --git a/src/XIVLauncher.Test/Http/XLProxyProviderTests.cs b/src/XIVLauncher.Test/Http/XLProxyProviderTests.cs index 5cc0e875d..c098ad77b 100644 --- a/src/XIVLauncher.Test/Http/XLProxyProviderTests.cs +++ b/src/XIVLauncher.Test/Http/XLProxyProviderTests.cs @@ -81,4 +81,44 @@ public void BuildWebProxy_InvalidInput_ReturnsNull(string type, string host, int Assert.Null(proxy); } + + [Fact] + public void Apply_Current_OnlyProxiesSdoDomains() + { + XLProxyProvider.Apply(new ProxyConfigSnapshot("Http", "127.0.0.1", 8080)); + + try + { + var proxy = XLProxyProvider.Current; + Assert.NotNull(proxy); + + var sdoUri = new Uri("https://ff.dorado.sdo.com/ff/area/serverlist_new.js"); + Assert.False(proxy.IsBypassed(sdoUri)); + Assert.Equal("http://127.0.0.1:8080/", proxy.GetProxy(sdoUri)!.ToString()); + + Assert.False(proxy.IsBypassed(new Uri("https://cas.sdo.com/authen/getGuid.json"))); + Assert.False(proxy.IsBypassed(new Uri("https://n1.cas.sdo.com/authen/ssoLogin.json"))); + Assert.False(proxy.IsBypassed(new Uri("https://ff14bjz.sdo.com/api/orderserivce/pageInit"))); + Assert.False(proxy.IsBypassed(new Uri("https://v3launcher.jijiagames.com/v3launcher/build/ver2data/x"))); + + var githubUri = new Uri("https://github.com/AtmoOmen/FFXIVQuickLauncher"); + Assert.True(proxy.IsBypassed(githubUri)); + Assert.Equal(githubUri, proxy.GetProxy(githubUri)); + + Assert.True(proxy.IsBypassed(new Uri("https://dalamud-dis.atmoomen.top/RELEASE"))); + Assert.True(proxy.IsBypassed(new Uri("https://api.nuget.org/v3-flatcontainer/x"))); + } + finally + { + XLProxyProvider.Apply(null); + } + } + + [Fact] + public void Apply_Null_ClearsCurrent() + { + XLProxyProvider.Apply(null); + + Assert.Null(XLProxyProvider.Current); + } } From 2a682e7bbd095c3b0e8dafbedc8def1f17c80b4d Mon Sep 17 00:00:00 2001 From: Errer Date: Sun, 13 Sep 2026 03:16:05 +0800 Subject: [PATCH 07/14] =?UTF-8?q?=E4=BB=A3=E7=90=86=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=88=B0=20Roaming=20=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81=E6=97=A7=E6=96=87=E4=BB=B6=E8=BF=81?= =?UTF-8?q?=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/XIVLauncher.Common/Constant/Paths.cs | 6 +++ src/XIVLauncher/Settings/ProxySettings.cs | 2 +- .../Settings/ProxySettingsStore.cs | 42 ++++++++++++++++++- .../Windows/ProxySettingsWindow.xaml | 2 +- 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/XIVLauncher.Common/Constant/Paths.cs b/src/XIVLauncher.Common/Constant/Paths.cs index 0b6ab340b..136d4a90f 100644 --- a/src/XIVLauncher.Common/Constant/Paths.cs +++ b/src/XIVLauncher.Common/Constant/Paths.cs @@ -44,6 +44,12 @@ public static string GetConfigPath(string prefix = "launcher") => Path.Join(RoamingPath, $"{prefix}ConfigV3.json"); public static string GetProxyConfigPath() => + Path.Join(RoamingPath, "proxyConfigV3.json"); + + /// + /// 旧版代理配置路径 (启动器安装目录), 用于首次迁移到 Roaming + /// + public static string GetLegacyProxyConfigPath() => Path.Join(AppContext.BaseDirectory, "proxyConfigV3.json"); public static string GetGamePath() diff --git a/src/XIVLauncher/Settings/ProxySettings.cs b/src/XIVLauncher/Settings/ProxySettings.cs index 1e89a31b2..1f5452006 100644 --- a/src/XIVLauncher/Settings/ProxySettings.cs +++ b/src/XIVLauncher/Settings/ProxySettings.cs @@ -3,7 +3,7 @@ namespace XIVLauncher.Settings; /// -/// 启动器网络代理配置 (独立存储于启动器目录下的 proxyConfigV3.json) +/// 启动器网络代理配置 (独立存储于 Roaming 目录下的 proxyConfigV3.json) /// public sealed class ProxySettings { diff --git a/src/XIVLauncher/Settings/ProxySettingsStore.cs b/src/XIVLauncher/Settings/ProxySettingsStore.cs index 51a3752a4..23a73a462 100644 --- a/src/XIVLauncher/Settings/ProxySettingsStore.cs +++ b/src/XIVLauncher/Settings/ProxySettingsStore.cs @@ -2,11 +2,12 @@ using System.Text; using System.Text.Json; using Serilog; +using XIVLauncher.Common.Constant; namespace XIVLauncher.Settings; /// -/// 代理配置独立存储: 启动器目录下的 proxyConfigV3.json +/// 代理配置独立存储: Roaming 目录下的 proxyConfigV3.json /// public static class ProxySettingsStore { @@ -18,6 +19,8 @@ public static class ProxySettingsStore public static ProxySettings Load(string path) { + MigrateLegacyLocation(path); + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return new ProxySettings(); @@ -76,6 +79,43 @@ public static void Save(string path, ProxySettings settings) } } + /// + /// 旧版本把代理配置放在启动器安装目录, 现在统一迁移到 Roaming: + /// 复制成功后尽力删除旧文件 (安装目录可能只读, 删除失败不阻塞) + /// + private static void MigrateLegacyLocation(string currentPath) + { + var legacyPath = Paths.GetLegacyProxyConfigPath(); + + try + { + if (File.Exists(currentPath) + || !File.Exists(legacyPath) + || string.Equals(currentPath, legacyPath, StringComparison.OrdinalIgnoreCase)) + return; + + var directoryPath = Path.GetDirectoryName(currentPath); + if (!string.IsNullOrWhiteSpace(directoryPath)) + Directory.CreateDirectory(directoryPath); + + File.Copy(legacyPath, currentPath, overwrite: false); + Log.Information("[ProxySettingsStore] 已将旧版代理配置从安装目录迁移到: {Path}", currentPath); + + try + { + File.Delete(legacyPath); + } + catch (Exception ex) + { + Log.Warning(ex, "[ProxySettingsStore] 删除安装目录下的旧版代理配置失败 (可忽略): {LegacyPath}", legacyPath); + } + } + catch (Exception ex) + { + Log.Warning(ex, "[ProxySettingsStore] 迁移旧版代理配置失败: {LegacyPath} -> {CurrentPath}", legacyPath, currentPath); + } + } + /// /// 兼容早期单条扁平格式: 根节点直接存放代理字段时转换为单个条目 /// diff --git a/src/XIVLauncher/Windows/ProxySettingsWindow.xaml b/src/XIVLauncher/Windows/ProxySettingsWindow.xaml index bb7a8d0d9..b0219ee02 100644 --- a/src/XIVLauncher/Windows/ProxySettingsWindow.xaml +++ b/src/XIVLauncher/Windows/ProxySettingsWindow.xaml @@ -151,7 +151,7 @@ Foreground="{DynamicResource MaterialDesign.Brush.ForegroundLight}" /> + Text="修改将在重启启动器后生效。配置保存在 Roaming 目录,更新重装不会丢失。" /> From 378b867e19eeea1436762a865e2935d1c10c0f31 Mon Sep 17 00:00:00 2001 From: Errer Date: Sun, 13 Sep 2026 14:29:33 +0800 Subject: [PATCH 08/14] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E9=A2=84=E8=AE=BE=E5=A2=9E=E5=88=A0=E4=B8=8D=E8=90=BD=E7=9B=98?= =?UTF-8?q?=E5=B9=B6=E8=A1=A5=E5=85=85=20VM=20=E5=9B=9E=E5=BD=92=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ProxySettingsWindowViewModelTests.cs | 91 +++++++++++++++++++ .../ViewModel/ProxySettingsWindowViewModel.cs | 1 + 2 files changed, 92 insertions(+) create mode 100644 src/XIVLauncher.Test/Settings/ProxySettingsWindowViewModelTests.cs diff --git a/src/XIVLauncher.Test/Settings/ProxySettingsWindowViewModelTests.cs b/src/XIVLauncher.Test/Settings/ProxySettingsWindowViewModelTests.cs new file mode 100644 index 000000000..508fba2e8 --- /dev/null +++ b/src/XIVLauncher.Test/Settings/ProxySettingsWindowViewModelTests.cs @@ -0,0 +1,91 @@ +using System.IO; +using System.Linq; +using XIVLauncher.Common.Constant; +using XIVLauncher.Settings; +using XIVLauncher.Windows.ViewModel; +using Xunit; + +namespace XIVLauncher.Test.Settings; + +public sealed class ProxySettingsWindowViewModelTests +{ + private static string CreateTempRoamingPath() => + Path.Combine(Path.GetTempPath(), $"xltest-roaming-{Guid.NewGuid():N}"); + + [Fact] + public void Save_AfterCreateProfile_PersistsNewProfile() + { + var originalRoamingPath = Paths.RoamingPath; + var tempRoamingPath = CreateTempRoamingPath(); + + try + { + Paths.OverrideRoamingPath(tempRoamingPath); + + var viewModel = new ProxySettingsWindowViewModel(new ProxySettings()); + viewModel.CreateProfile(); + + var profile = viewModel.SelectedProfile; + Assert.NotNull(profile); + profile.Name = "测试预设"; + profile.ProxyType = ProxyType.Http; + profile.ProxyHost = "127.0.0.1"; + profile.ProxyPort = 8080; + + viewModel.Save(null); + + var loaded = ProxySettingsStore.Load(Paths.GetProxyConfigPath()); + + var savedProfile = Assert.Single(loaded.Profiles); + Assert.Equal(profile.Id, loaded.ActiveProfileId); + Assert.Equal("测试预设", savedProfile.Name); + Assert.Equal(ProxyType.Http, savedProfile.ProxyType); + Assert.Equal("127.0.0.1", savedProfile.ProxyHost); + Assert.Equal(8080, savedProfile.ProxyPort); + } + finally + { + Paths.OverrideRoamingPath(originalRoamingPath); + if (Directory.Exists(tempRoamingPath)) + Directory.Delete(tempRoamingPath, true); + } + } + + [Fact] + public void Save_AfterDeleteProfile_RemovesProfile() + { + var originalRoamingPath = Paths.RoamingPath; + var tempRoamingPath = CreateTempRoamingPath(); + + try + { + Paths.OverrideRoamingPath(tempRoamingPath); + + var profile = new ProxyProfile + { + Name = "待删除", + ProxyType = ProxyType.Socks5, + ProxyHost = "127.0.0.1", + ProxyPort = 1080 + }; + + var settings = new ProxySettings { Profiles = [profile], ActiveProfileId = profile.Id }; + var viewModel = new ProxySettingsWindowViewModel(settings); + + Assert.NotNull(viewModel.SelectedProfile); + viewModel.DeleteSelectedProfile(); + viewModel.Save(null); + + var loaded = ProxySettingsStore.Load(Paths.GetProxyConfigPath()); + + Assert.Empty(loaded.Profiles); + Assert.Null(loaded.ActiveProfileId); + } + finally + { + Paths.OverrideRoamingPath(originalRoamingPath); + if (Directory.Exists(tempRoamingPath)) + Directory.Delete(tempRoamingPath, true); + } + } +} diff --git a/src/XIVLauncher/Windows/ViewModel/ProxySettingsWindowViewModel.cs b/src/XIVLauncher/Windows/ViewModel/ProxySettingsWindowViewModel.cs index ca60ae566..2e41b0fe6 100644 --- a/src/XIVLauncher/Windows/ViewModel/ProxySettingsWindowViewModel.cs +++ b/src/XIVLauncher/Windows/ViewModel/ProxySettingsWindowViewModel.cs @@ -116,6 +116,7 @@ public void Save(string? passwordInput) profile.SetPassword(passwordInput); } + settings.Profiles = Profiles.ToList(); settings.ActiveProfileId = profile?.Id; ProxySettingsStore.Save(Paths.GetProxyConfigPath(), settings); From 7821f01e630235eebdd86cdaae8b3fbdc207a7e3 Mon Sep 17 00:00:00 2001 From: Errer Date: Mon, 14 Sep 2026 22:02:22 +0800 Subject: [PATCH 09/14] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E5=88=87=E6=8D=A2=E9=A2=84=E8=AE=BE=E6=97=B6?= =?UTF-8?q?=E5=AF=86=E7=A0=81=E6=A1=86=E4=B8=B2=E5=86=99=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/XIVLauncher/Windows/ProxySettingsWindow.xaml.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/XIVLauncher/Windows/ProxySettingsWindow.xaml.cs b/src/XIVLauncher/Windows/ProxySettingsWindow.xaml.cs index 8aa3f213a..4b7ea148b 100644 --- a/src/XIVLauncher/Windows/ProxySettingsWindow.xaml.cs +++ b/src/XIVLauncher/Windows/ProxySettingsWindow.xaml.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Windows; using Serilog; using XIVLauncher.Common.Constant; @@ -16,7 +17,15 @@ public ProxySettingsWindow() InitializeComponent(); var settings = ProxySettingsStore.Load(Paths.GetProxyConfigPath()); - DataContext = new ProxySettingsWindowViewModel(settings); + var viewModel = new ProxySettingsWindowViewModel(settings); + DataContext = viewModel; + + // 切换预设时清空密码框, 防止给上一个预设输入的密码被写入新选中的预设 + viewModel.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(ProxySettingsWindowViewModel.SelectedItem)) + ProxyPasswordBox.Password = string.Empty; + }; } private void CreateProfileButton_OnClick(object sender, RoutedEventArgs e) => From 6ef109cc13c2edb182dd9cdd08f2879326e8f28d Mon Sep 17 00:00:00 2001 From: Errer Date: Mon, 14 Sep 2026 22:08:49 +0800 Subject: [PATCH 10/14] =?UTF-8?q?=E5=AF=B9=E9=BD=90=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E9=A3=8E=E6=A0=BC:=20=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E5=89=8D=E7=BD=AE=E3=80=81=E5=88=9D=E5=A7=8B=E5=8C=96=E5=99=A8?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E4=B8=8E=E5=8F=AF=E8=AF=BB=E6=80=A7=E6=8B=86?= =?UTF-8?q?=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/XIVLauncher/Settings/ProxySettingsStore.cs | 16 +++++++++++----- .../ViewModel/ProxySettingsWindowViewModel.cs | 4 ++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/XIVLauncher/Settings/ProxySettingsStore.cs b/src/XIVLauncher/Settings/ProxySettingsStore.cs index 23a73a462..48c9a8f53 100644 --- a/src/XIVLauncher/Settings/ProxySettingsStore.cs +++ b/src/XIVLauncher/Settings/ProxySettingsStore.cs @@ -133,11 +133,11 @@ private static void MigrateLegacyFlatFormat(string json, ProxySettings settings) var profile = new ProxyProfile { - Name = "旧代理配置", - ProxyType = root.GetProperty("ProxyType").GetInt32() is var type && Enum.IsDefined(typeof(ProxyType), type) ? (ProxyType)type : ProxyType.None, - ProxyHost = root.TryGetProperty("ProxyHost", out var host) ? host.GetString() ?? string.Empty : string.Empty, - ProxyPort = root.TryGetProperty("ProxyPort", out var port) ? port.GetInt32() : 0, - ProxyUsername = root.TryGetProperty("ProxyUsername", out var username) ? username.GetString() ?? string.Empty : string.Empty, + Name = "旧代理配置", + ProxyType = ParseLegacyProxyType(root), + ProxyHost = root.TryGetProperty("ProxyHost", out var host) ? host.GetString() ?? string.Empty : string.Empty, + ProxyPort = root.TryGetProperty("ProxyPort", out var port) ? port.GetInt32() : 0, + ProxyUsername = root.TryGetProperty("ProxyUsername", out var username) ? username.GetString() ?? string.Empty : string.Empty, ProxyPasswordEncrypted = root.TryGetProperty("ProxyPasswordEncrypted", out var password) ? password.GetString() ?? string.Empty : string.Empty }; @@ -151,6 +151,12 @@ private static void MigrateLegacyFlatFormat(string json, ProxySettings settings) } } + private static ProxyType ParseLegacyProxyType(JsonElement root) + { + var rawType = root.GetProperty("ProxyType").GetInt32(); + return Enum.IsDefined(typeof(ProxyType), rawType) ? (ProxyType)rawType : ProxyType.None; + } + private static void IsolateBrokenFile(string path) { try diff --git a/src/XIVLauncher/Windows/ViewModel/ProxySettingsWindowViewModel.cs b/src/XIVLauncher/Windows/ViewModel/ProxySettingsWindowViewModel.cs index 2e41b0fe6..6e4e7db26 100644 --- a/src/XIVLauncher/Windows/ViewModel/ProxySettingsWindowViewModel.cs +++ b/src/XIVLauncher/Windows/ViewModel/ProxySettingsWindowViewModel.cs @@ -7,6 +7,8 @@ namespace XIVLauncher.Windows.ViewModel; public sealed partial class ProxySettingsWindowViewModel : ObservableObject { + private readonly ProxySettings settings; + public sealed record ProxyTypeOption(ProxyType Type, string Display); public sealed record ProxySelectItem(ProxyProfile? Profile) @@ -44,8 +46,6 @@ public sealed record ProxySelectItem(ProxyProfile? Profile) "留空保持当前密码" : "未设置"; - private readonly ProxySettings settings; - public ProxySettingsWindowViewModel(ProxySettings settings) { ArgumentNullException.ThrowIfNull(settings); From 28550d6d07c39d5c446a37f22328bf45c190f9ec Mon Sep 17 00:00:00 2001 From: Errer Date: Tue, 15 Sep 2026 22:44:17 +0800 Subject: [PATCH 11/14] =?UTF-8?q?=E4=BB=A3=E7=90=86=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=B9=B6=E5=85=A5=E5=90=AF=E5=8A=A8=E5=99=A8=E4=B8=BB=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=BD=93=E7=B3=BB,=20=E6=97=A7=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=90=AF=E5=8A=A8=E6=97=B6=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Settings/ProxySettingsMigrationTests.cs | 182 +++++++++++++++ .../Settings/ProxySettingsStoreTests.cs | 207 ------------------ .../Settings/ProxySettingsTests.cs | 142 ++++++++++++ .../ProxySettingsWindowViewModelTests.cs | 121 +++++----- .../Settings/LauncherSettingsV3.cs | 31 +++ src/XIVLauncher/Settings/ProxySettings.cs | 13 +- ...ingsStore.cs => ProxySettingsMigration.cs} | 153 ++++++------- .../Startup/StartupOrchestrator.cs | 4 +- .../Windows/ProxySettingsWindow.xaml | 2 +- .../Windows/ProxySettingsWindow.xaml.cs | 10 +- .../ViewModel/ProxySettingsWindowViewModel.cs | 15 +- 11 files changed, 513 insertions(+), 367 deletions(-) create mode 100644 src/XIVLauncher.Test/Settings/ProxySettingsMigrationTests.cs delete mode 100644 src/XIVLauncher.Test/Settings/ProxySettingsStoreTests.cs create mode 100644 src/XIVLauncher.Test/Settings/ProxySettingsTests.cs rename src/XIVLauncher/Settings/{ProxySettingsStore.cs => ProxySettingsMigration.cs} (51%) diff --git a/src/XIVLauncher.Test/Settings/ProxySettingsMigrationTests.cs b/src/XIVLauncher.Test/Settings/ProxySettingsMigrationTests.cs new file mode 100644 index 000000000..daeebcbcf --- /dev/null +++ b/src/XIVLauncher.Test/Settings/ProxySettingsMigrationTests.cs @@ -0,0 +1,182 @@ +using System.IO; +using System.Linq; +using System.Text.Json; +using XIVLauncher.Common.Constant; +using XIVLauncher.Settings; +using Xunit; + +namespace XIVLauncher.Test.Settings; + +public sealed class ProxySettingsMigrationTests +{ + private static string CreateTempRoamingPath() => + Path.Combine(Path.GetTempPath(), $"xltest-roaming-{Guid.NewGuid():N}"); + + [Fact] + public void ImportLegacy_RoamingFile_ImportsAndDeletesFile() + { + var originalRoamingPath = Paths.RoamingPath; + var tempRoamingPath = CreateTempRoamingPath(); + + try + { + Paths.OverrideRoamingPath(tempRoamingPath); + Directory.CreateDirectory(tempRoamingPath); + + var profile = new ProxyProfile { Name = "旧配置", ProxyType = ProxyType.Http, ProxyHost = "127.0.0.1", ProxyPort = 8080 }; + var legacy = new ProxySettings { Profiles = [profile], ActiveProfileId = profile.Id }; + File.WriteAllText(Paths.GetProxyConfigPath(), JsonSerializer.Serialize(legacy)); + + var settings = LauncherSettingsV3.Load(Paths.GetConfigPath()); + + ProxySettingsMigration.ImportLegacyInto(settings); + + var imported = Assert.Single(settings.ProxySettings.Profiles); + Assert.Equal(profile.Id, settings.ProxySettings.ActiveProfileId); + Assert.Equal("旧配置", imported.Name); + Assert.Equal(ProxyType.Http, imported.ProxyType); + Assert.Equal("127.0.0.1", imported.ProxyHost); + Assert.Equal(8080, imported.ProxyPort); + Assert.False(File.Exists(Paths.GetProxyConfigPath())); + } + finally + { + Paths.OverrideRoamingPath(originalRoamingPath); + if (Directory.Exists(tempRoamingPath)) + Directory.Delete(tempRoamingPath, true); + } + } + + [Fact] + public void ImportLegacy_FlatFormat_MigratesToSingleProfile() + { + var originalRoamingPath = Paths.RoamingPath; + var tempRoamingPath = CreateTempRoamingPath(); + + try + { + Paths.OverrideRoamingPath(tempRoamingPath); + Directory.CreateDirectory(tempRoamingPath); + + File.WriteAllText + ( + Paths.GetProxyConfigPath(), + """ + { + "ProxyType": 3, + "ProxyHost": "127.0.0.1", + "ProxyPort": 1080, + "ProxyUsername": "user", + "ProxyPasswordEncrypted": "" + } + """ + ); + + var settings = LauncherSettingsV3.Load(Paths.GetConfigPath()); + + ProxySettingsMigration.ImportLegacyInto(settings); + + var profile = Assert.Single(settings.ProxySettings.Profiles); + Assert.Equal(ProxyType.Socks5, profile.ProxyType); + Assert.Equal("127.0.0.1", profile.ProxyHost); + Assert.Equal(1080, profile.ProxyPort); + Assert.Equal(profile.Id, settings.ProxySettings.ActiveProfileId); + Assert.False(File.Exists(Paths.GetProxyConfigPath())); + } + finally + { + Paths.OverrideRoamingPath(originalRoamingPath); + if (Directory.Exists(tempRoamingPath)) + Directory.Delete(tempRoamingPath, true); + } + } + + [Fact] + public void ImportLegacy_NoLegacyFile_DoesNothing() + { + var originalRoamingPath = Paths.RoamingPath; + var tempRoamingPath = CreateTempRoamingPath(); + + try + { + Paths.OverrideRoamingPath(tempRoamingPath); + Directory.CreateDirectory(tempRoamingPath); + + var settings = LauncherSettingsV3.Load(Paths.GetConfigPath()); + + ProxySettingsMigration.ImportLegacyInto(settings); + + Assert.Empty(settings.ProxySettings.Profiles); + Assert.Null(settings.ProxySettings.ActiveProfileId); + } + finally + { + Paths.OverrideRoamingPath(originalRoamingPath); + if (Directory.Exists(tempRoamingPath)) + Directory.Delete(tempRoamingPath, true); + } + } + + [Fact] + public void ImportLegacy_ExistingProxySettings_KeepsCurrentAndLegacyFile() + { + var originalRoamingPath = Paths.RoamingPath; + var tempRoamingPath = CreateTempRoamingPath(); + + try + { + Paths.OverrideRoamingPath(tempRoamingPath); + Directory.CreateDirectory(tempRoamingPath); + + var legacyProfile = new ProxyProfile { Name = "旧配置", ProxyType = ProxyType.Http, ProxyHost = "127.0.0.1", ProxyPort = 8080 }; + File.WriteAllText(Paths.GetProxyConfigPath(), JsonSerializer.Serialize(new ProxySettings { Profiles = [legacyProfile], ActiveProfileId = legacyProfile.Id })); + + var currentProfile = new ProxyProfile { Name = "当前配置", ProxyType = ProxyType.Socks5, ProxyHost = "127.0.0.1", ProxyPort = 1080 }; + var settings = LauncherSettingsV3.Load(Paths.GetConfigPath()); + settings.ProxySettings = new ProxySettings { Profiles = [currentProfile], ActiveProfileId = currentProfile.Id }; + + ProxySettingsMigration.ImportLegacyInto(settings); + + var keptProfile = Assert.Single(settings.ProxySettings.Profiles); + Assert.Equal(currentProfile.Id, settings.ProxySettings.ActiveProfileId); + Assert.Equal("当前配置", keptProfile.Name); + Assert.True(File.Exists(Paths.GetProxyConfigPath())); + } + finally + { + Paths.OverrideRoamingPath(originalRoamingPath); + if (Directory.Exists(tempRoamingPath)) + Directory.Delete(tempRoamingPath, true); + } + } + + [Fact] + public void ImportLegacy_CorruptFile_Isolates() + { + var originalRoamingPath = Paths.RoamingPath; + var tempRoamingPath = CreateTempRoamingPath(); + + try + { + Paths.OverrideRoamingPath(tempRoamingPath); + Directory.CreateDirectory(tempRoamingPath); + + var legacyPath = Paths.GetProxyConfigPath(); + File.WriteAllText(legacyPath, "{ not valid json !!"); + + var settings = LauncherSettingsV3.Load(Paths.GetConfigPath()); + + ProxySettingsMigration.ImportLegacyInto(settings); + + Assert.Empty(settings.ProxySettings.Profiles); + Assert.False(File.Exists(legacyPath)); + Assert.Contains(Directory.GetFiles(tempRoamingPath, Path.GetFileName(legacyPath) + ".broken-*"), file => true); + } + finally + { + Paths.OverrideRoamingPath(originalRoamingPath); + if (Directory.Exists(tempRoamingPath)) + Directory.Delete(tempRoamingPath, true); + } + } +} diff --git a/src/XIVLauncher.Test/Settings/ProxySettingsStoreTests.cs b/src/XIVLauncher.Test/Settings/ProxySettingsStoreTests.cs deleted file mode 100644 index ba47d07ea..000000000 --- a/src/XIVLauncher.Test/Settings/ProxySettingsStoreTests.cs +++ /dev/null @@ -1,207 +0,0 @@ -using System.IO; -using System.Linq; -using XIVLauncher.Settings; -using Xunit; - -namespace XIVLauncher.Test.Settings; - -public sealed class ProxySettingsStoreTests -{ - private static string CreateTempPath() => - Path.Combine(Path.GetTempPath(), $"xltest-proxy-{Guid.NewGuid():N}.json"); - - [Fact] - public void Load_MissingFile_ReturnsDefault() - { - var settings = ProxySettingsStore.Load(CreateTempPath()); - - Assert.Empty(settings.Profiles); - Assert.Null(settings.ActiveProfileId); - Assert.Null(settings.ToSnapshot()); - } - - [Fact] - public void SaveLoad_RoundTrip_PreservesProfilesAndActive() - { - var path = CreateTempPath(); - - try - { - var profileA = new ProxyProfile - { - Name = "A", - ProxyType = ProxyType.Socks5, - ProxyHost = "127.0.0.1", - ProxyPort = 1080, - ProxyUsername = "user" - }; - profileA.SetPassword("secret"); - - var profileB = new ProxyProfile - { - Name = "B", - ProxyType = ProxyType.Http, - ProxyHost = "proxy.example.com", - ProxyPort = 8080 - }; - - var settings = new ProxySettings - { - Profiles = [profileA, profileB], - ActiveProfileId = profileA.Id - }; - - ProxySettingsStore.Save(path, settings); - - var loaded = ProxySettingsStore.Load(path); - - Assert.Equal(2, loaded.Profiles.Count); - Assert.Equal(profileA.Id, loaded.ActiveProfileId); - - var loadedA = loaded.GetActiveProfile(); - Assert.NotNull(loadedA); - Assert.Equal(ProxyType.Socks5, loadedA.ProxyType); - Assert.Equal("127.0.0.1", loadedA.ProxyHost); - Assert.Equal(1080, loadedA.ProxyPort); - Assert.Equal("user", loadedA.ProxyUsername); - Assert.Equal("secret", loadedA.GetPassword()); - - Assert.NotNull(loaded.ToSnapshot()); - } - finally - { - File.Delete(path); - File.Delete(path + ".bak"); - } - } - - [Fact] - public void ToSnapshot_NoActiveProfile_ReturnsNull() - { - var settings = new ProxySettings - { - Profiles = - [ - new ProxyProfile - { - Name = "未启用", - ProxyType = ProxyType.Socks5, - ProxyHost = "127.0.0.1", - ProxyPort = 1080 - } - ], - ActiveProfileId = null - }; - - Assert.Null(settings.ToSnapshot()); - } - - [Fact] - public void ToSnapshot_ActiveProfile_MapsFields() - { - var profile = new ProxyProfile - { - Name = "A", - ProxyType = ProxyType.Socks5, - ProxyHost = "127.0.0.1", - ProxyPort = 1080, - ProxyUsername = "user" - }; - profile.SetPassword("pass"); - - var settings = new ProxySettings - { - Profiles = [profile], - ActiveProfileId = profile.Id - }; - - var snapshot = settings.ToSnapshot(); - - Assert.NotNull(snapshot); - Assert.Equal("Socks5", snapshot.Type); - Assert.Equal("127.0.0.1", snapshot.Host); - Assert.Equal(1080, snapshot.Port); - Assert.Equal("user", snapshot.Username); - Assert.Equal("pass", snapshot.Password); - } - - [Fact] - public void Load_CorruptedFile_IsolatesAndReturnsDefault() - { - var path = CreateTempPath(); - - try - { - File.WriteAllText(path, "{ not valid json !!"); - - var settings = ProxySettingsStore.Load(path); - - Assert.Empty(settings.Profiles); - Assert.False(File.Exists(path)); - Assert.Contains(Directory.GetFiles(Path.GetTempPath(), Path.GetFileName(path) + ".broken-*"), file => true); - } - finally - { - foreach (var leftover in Directory.GetFiles(Path.GetTempPath(), Path.GetFileName(path) + "*")) - File.Delete(leftover); - } - } - - [Fact] - public void Load_LegacyFlatFormat_MigratesToSingleProfile() - { - var path = CreateTempPath(); - - try - { - File.WriteAllText - ( - path, - """ - { - "ProxyType": 3, - "ProxyHost": "127.0.0.1", - "ProxyPort": 1080, - "ProxyUsername": "user", - "ProxyPasswordEncrypted": "" - } - """ - ); - - var settings = ProxySettingsStore.Load(path); - - var profile = Assert.Single(settings.Profiles); - Assert.Equal(ProxyType.Socks5, profile.ProxyType); - Assert.Equal("127.0.0.1", profile.ProxyHost); - Assert.Equal(1080, profile.ProxyPort); - Assert.Equal(profile.Id, settings.ActiveProfileId); - } - finally - { - File.Delete(path); - } - } - - [Fact] - public void SetPassword_Empty_ClearsPassword() - { - var profile = new ProxyProfile(); - profile.SetPassword("secret"); - - Assert.Equal("secret", profile.GetPassword()); - Assert.NotEmpty(profile.ProxyPasswordEncrypted); - - profile.SetPassword(null); - - Assert.Null(profile.GetPassword()); - Assert.Empty(profile.ProxyPasswordEncrypted); - } - - [Fact] - public void ToSnapshot_InvalidPort_ReturnsNull() - { - var profile = new ProxyProfile { ProxyType = ProxyType.Http, ProxyHost = "127.0.0.1", ProxyPort = 70000 }; - - Assert.Null(profile.ToSnapshot()); - } -} diff --git a/src/XIVLauncher.Test/Settings/ProxySettingsTests.cs b/src/XIVLauncher.Test/Settings/ProxySettingsTests.cs new file mode 100644 index 000000000..c7e6ed822 --- /dev/null +++ b/src/XIVLauncher.Test/Settings/ProxySettingsTests.cs @@ -0,0 +1,142 @@ +using System.IO; +using System.Linq; +using XIVLauncher.Settings; +using Xunit; + +namespace XIVLauncher.Test.Settings; + +public sealed class ProxySettingsTests +{ + private static string CreateTempPath() => + Path.Combine(Path.GetTempPath(), $"xltest-settings-{Guid.NewGuid():N}.json"); + + [Fact] + public void ToSnapshot_NoActiveProfile_ReturnsNull() + { + var settings = new ProxySettings + { + Profiles = + [ + new ProxyProfile + { + Name = "未启用", + ProxyType = ProxyType.Socks5, + ProxyHost = "127.0.0.1", + ProxyPort = 1080 + } + ], + ActiveProfileId = null + }; + + Assert.Null(settings.ToSnapshot()); + } + + [Fact] + public void ToSnapshot_ActiveProfile_MapsFields() + { + var profile = new ProxyProfile + { + Name = "A", + ProxyType = ProxyType.Socks5, + ProxyHost = "127.0.0.1", + ProxyPort = 1080, + ProxyUsername = "user" + }; + profile.SetPassword("pass"); + + var settings = new ProxySettings + { + Profiles = [profile], + ActiveProfileId = profile.Id + }; + + var snapshot = settings.ToSnapshot(); + + Assert.NotNull(snapshot); + Assert.Equal("Socks5", snapshot.Type); + Assert.Equal("127.0.0.1", snapshot.Host); + Assert.Equal(1080, snapshot.Port); + Assert.Equal("user", snapshot.Username); + Assert.Equal("pass", snapshot.Password); + } + + [Fact] + public void SetPassword_Empty_ClearsPassword() + { + var profile = new ProxyProfile(); + profile.SetPassword("secret"); + + Assert.Equal("secret", profile.GetPassword()); + Assert.NotEmpty(profile.ProxyPasswordEncrypted); + + profile.SetPassword(null); + + Assert.Null(profile.GetPassword()); + Assert.Empty(profile.ProxyPasswordEncrypted); + } + + [Fact] + public void ToSnapshot_InvalidPort_ReturnsNull() + { + var profile = new ProxyProfile { ProxyType = ProxyType.Http, ProxyHost = "127.0.0.1", ProxyPort = 70000 }; + + Assert.Null(profile.ToSnapshot()); + } + + [Fact] + public void DeepClone_ReturnsIndependentCopy() + { + var profile = new ProxyProfile { Name = "A", ProxyType = ProxyType.Http, ProxyHost = "127.0.0.1", ProxyPort = 8080 }; + var settings = new ProxySettings { Profiles = [profile], ActiveProfileId = profile.Id }; + + var clone = settings.DeepClone(); + + Assert.NotSame(settings, clone); + Assert.Equal(profile.Id, clone.ActiveProfileId); + Assert.Single(clone.Profiles); + + clone.Profiles[0].ProxyHost = "10.0.0.1"; + clone.Profiles.RemoveAt(0); + + Assert.Equal("127.0.0.1", settings.Profiles[0].ProxyHost); + Assert.Single(settings.Profiles); + } + + [Fact] + public void LauncherSettingsV3_SaveLoad_RoundTrip_PreservesProxySettings() + { + var path = CreateTempPath(); + + try + { + var profile = new ProxyProfile + { + Name = "A", + ProxyType = ProxyType.Socks5, + ProxyHost = "127.0.0.1", + ProxyPort = 1080, + ProxyUsername = "user" + }; + profile.SetPassword("secret"); + + var settings = LauncherSettingsV3.Load(path); + settings.ProxySettings = new ProxySettings { Profiles = [profile], ActiveProfileId = profile.Id }; + settings.Save(); + + var reloaded = LauncherSettingsV3.Load(path); + + var loadedProfile = Assert.Single(reloaded.ProxySettings.Profiles); + Assert.Equal(profile.Id, reloaded.ProxySettings.ActiveProfileId); + Assert.Equal(ProxyType.Socks5, loadedProfile.ProxyType); + Assert.Equal("127.0.0.1", loadedProfile.ProxyHost); + Assert.Equal(1080, loadedProfile.ProxyPort); + Assert.Equal("user", loadedProfile.ProxyUsername); + Assert.Equal("secret", loadedProfile.GetPassword()); + } + finally + { + File.Delete(path); + File.Delete(path + ".bak"); + } + } +} diff --git a/src/XIVLauncher.Test/Settings/ProxySettingsWindowViewModelTests.cs b/src/XIVLauncher.Test/Settings/ProxySettingsWindowViewModelTests.cs index 508fba2e8..e9e0ef5ff 100644 --- a/src/XIVLauncher.Test/Settings/ProxySettingsWindowViewModelTests.cs +++ b/src/XIVLauncher.Test/Settings/ProxySettingsWindowViewModelTests.cs @@ -1,6 +1,3 @@ -using System.IO; -using System.Linq; -using XIVLauncher.Common.Constant; using XIVLauncher.Settings; using XIVLauncher.Windows.ViewModel; using Xunit; @@ -9,83 +6,79 @@ namespace XIVLauncher.Test.Settings; public sealed class ProxySettingsWindowViewModelTests { - private static string CreateTempRoamingPath() => - Path.Combine(Path.GetTempPath(), $"xltest-roaming-{Guid.NewGuid():N}"); - [Fact] - public void Save_AfterCreateProfile_PersistsNewProfile() + public void Save_AfterCreateProfile_UpdatesSettings() { - var originalRoamingPath = Paths.RoamingPath; - var tempRoamingPath = CreateTempRoamingPath(); + var viewModel = new ProxySettingsWindowViewModel(new ProxySettings()); + viewModel.CreateProfile(); + + var profile = viewModel.SelectedProfile; + Assert.NotNull(profile); + profile.Name = "测试预设"; + profile.ProxyType = ProxyType.Http; + profile.ProxyHost = "127.0.0.1"; + profile.ProxyPort = 8080; + + viewModel.Save(null); + + var savedProfile = Assert.Single(viewModel.Settings.Profiles); + Assert.Equal(profile.Id, viewModel.Settings.ActiveProfileId); + Assert.Equal("测试预设", savedProfile.Name); + Assert.Equal(ProxyType.Http, savedProfile.ProxyType); + Assert.Equal("127.0.0.1", savedProfile.ProxyHost); + Assert.Equal(8080, savedProfile.ProxyPort); + } - try + [Fact] + public void Save_AfterDeleteProfile_ClearsSettings() + { + var profile = new ProxyProfile { - Paths.OverrideRoamingPath(tempRoamingPath); + Name = "待删除", + ProxyType = ProxyType.Socks5, + ProxyHost = "127.0.0.1", + ProxyPort = 1080 + }; - var viewModel = new ProxySettingsWindowViewModel(new ProxySettings()); - viewModel.CreateProfile(); + var viewModel = new ProxySettingsWindowViewModel(new ProxySettings { Profiles = [profile], ActiveProfileId = profile.Id }); - var profile = viewModel.SelectedProfile; - Assert.NotNull(profile); - profile.Name = "测试预设"; - profile.ProxyType = ProxyType.Http; - profile.ProxyHost = "127.0.0.1"; - profile.ProxyPort = 8080; + Assert.NotNull(viewModel.SelectedProfile); + viewModel.DeleteSelectedProfile(); + viewModel.Save(null); - viewModel.Save(null); - - var loaded = ProxySettingsStore.Load(Paths.GetProxyConfigPath()); - - var savedProfile = Assert.Single(loaded.Profiles); - Assert.Equal(profile.Id, loaded.ActiveProfileId); - Assert.Equal("测试预设", savedProfile.Name); - Assert.Equal(ProxyType.Http, savedProfile.ProxyType); - Assert.Equal("127.0.0.1", savedProfile.ProxyHost); - Assert.Equal(8080, savedProfile.ProxyPort); - } - finally - { - Paths.OverrideRoamingPath(originalRoamingPath); - if (Directory.Exists(tempRoamingPath)) - Directory.Delete(tempRoamingPath, true); - } + Assert.Empty(viewModel.Settings.Profiles); + Assert.Null(viewModel.Settings.ActiveProfileId); } [Fact] - public void Save_AfterDeleteProfile_RemovesProfile() + public void Save_EmptyName_Throws() { - var originalRoamingPath = Paths.RoamingPath; - var tempRoamingPath = CreateTempRoamingPath(); - - try + var profile = new ProxyProfile { - Paths.OverrideRoamingPath(tempRoamingPath); + Name = " ", + ProxyType = ProxyType.Http, + ProxyHost = "127.0.0.1", + ProxyPort = 8080 + }; - var profile = new ProxyProfile - { - Name = "待删除", - ProxyType = ProxyType.Socks5, - ProxyHost = "127.0.0.1", - ProxyPort = 1080 - }; + var viewModel = new ProxySettingsWindowViewModel(new ProxySettings { Profiles = [profile], ActiveProfileId = profile.Id }); - var settings = new ProxySettings { Profiles = [profile], ActiveProfileId = profile.Id }; - var viewModel = new ProxySettingsWindowViewModel(settings); + Assert.Throws(() => viewModel.Save(null)); + } - Assert.NotNull(viewModel.SelectedProfile); - viewModel.DeleteSelectedProfile(); - viewModel.Save(null); + [Fact] + public void Save_InvalidPort_Throws() + { + var profile = new ProxyProfile + { + Name = "A", + ProxyType = ProxyType.Http, + ProxyHost = "127.0.0.1", + ProxyPort = 70000 + }; - var loaded = ProxySettingsStore.Load(Paths.GetProxyConfigPath()); + var viewModel = new ProxySettingsWindowViewModel(new ProxySettings { Profiles = [profile], ActiveProfileId = profile.Id }); - Assert.Empty(loaded.Profiles); - Assert.Null(loaded.ActiveProfileId); - } - finally - { - Paths.OverrideRoamingPath(originalRoamingPath); - if (Directory.Exists(tempRoamingPath)) - Directory.Delete(tempRoamingPath, true); - } + Assert.Throws(() => viewModel.Save(null)); } } diff --git a/src/XIVLauncher/Settings/LauncherSettingsV3.cs b/src/XIVLauncher/Settings/LauncherSettingsV3.cs index 9305a6722..271d14961 100644 --- a/src/XIVLauncher/Settings/LauncherSettingsV3.cs +++ b/src/XIVLauncher/Settings/LauncherSettingsV3.cs @@ -274,6 +274,19 @@ public bool KeepPatches #endregion + #region 网络代理配置 + + /// + /// 启动器网络代理配置 (与主配置一并存储) + /// + public ProxySettings ProxySettings + { + get; + set => Set(ref field, value); + } = new(); + + #endregion + #region 账户与安全配置 /// @@ -480,6 +493,7 @@ out Exception? exception { var json = File.ReadAllText(sourcePath, Encoding.UTF8); settings = JsonSerializer.Deserialize(json, JsonOptions) ?? new LauncherSettingsV3(); + settings.SanitizeProxySettings(); var migrated = settings.MigrateWeGamePath(); settings.Attach(attachPath); if (migrated) @@ -504,6 +518,23 @@ private bool MigrateWeGamePath() return true; } + /// + /// 加载后清理代理配置: 补齐缺失的配置对象并校验密码字段可解密 + /// + private void SanitizeProxySettings() + { + ProxySettings ??= new ProxySettings(); + + foreach (var profile in ProxySettings.Profiles) + { + if (!string.IsNullOrWhiteSpace(profile.ProxyPasswordEncrypted) && profile.GetPassword() == null) + { + Log.Warning("[LauncherSettingsV3] 代理密码解密失败, 已清空密码字段: {ProfileName}", profile.DisplayName); + profile.ProxyPasswordEncrypted = string.Empty; + } + } + } + private static string? MoveBrokenConfig(string configPath) { if (!File.Exists(configPath)) diff --git a/src/XIVLauncher/Settings/ProxySettings.cs b/src/XIVLauncher/Settings/ProxySettings.cs index 1f5452006..db935fd32 100644 --- a/src/XIVLauncher/Settings/ProxySettings.cs +++ b/src/XIVLauncher/Settings/ProxySettings.cs @@ -1,9 +1,10 @@ +using System.Text.Json; using XIVLauncher.Common.Http; namespace XIVLauncher.Settings; /// -/// 启动器网络代理配置 (独立存储于 Roaming 目录下的 proxyConfigV3.json) +/// 启动器网络代理配置 (随启动器主配置 LauncherSettingsV3 一并存储) /// public sealed class ProxySettings { @@ -22,4 +23,14 @@ public sealed class ProxySettings public ProxyConfigSnapshot? ToSnapshot() => GetActiveProfile()?.ToSnapshot(); + + /// + /// 深拷贝一份配置副本, 供代理设置窗口的编辑会话使用 + /// (取消编辑时不影响已生效的配置) + /// + public ProxySettings DeepClone() + { + var json = JsonSerializer.Serialize(this); + return JsonSerializer.Deserialize(json) ?? new ProxySettings(); + } } diff --git a/src/XIVLauncher/Settings/ProxySettingsStore.cs b/src/XIVLauncher/Settings/ProxySettingsMigration.cs similarity index 51% rename from src/XIVLauncher/Settings/ProxySettingsStore.cs rename to src/XIVLauncher/Settings/ProxySettingsMigration.cs index 48c9a8f53..498d9afd1 100644 --- a/src/XIVLauncher/Settings/ProxySettingsStore.cs +++ b/src/XIVLauncher/Settings/ProxySettingsMigration.cs @@ -7,112 +7,93 @@ namespace XIVLauncher.Settings; /// -/// 代理配置独立存储: Roaming 目录下的 proxyConfigV3.json +/// 旧版独立代理配置 (proxyConfigV3.json) 迁移: +/// 代理配置现已并入启动器主配置 (LauncherSettingsV3) 的统一存储体系, +/// 启动时自动导入旧文件, 导入成功后删除旧文件。 /// -public static class ProxySettingsStore +public static class ProxySettingsMigration { private static readonly JsonSerializerOptions JsonOptions = new() { - WriteIndented = true, PropertyNameCaseInsensitive = true }; - public static ProxySettings Load(string path) + /// + /// 若旧版代理配置文件存在且主配置中尚无代理配置, 则导入主配置并删除旧文件 + /// + public static void ImportLegacyInto(LauncherSettingsV3 settings) { - MigrateLegacyLocation(path); - - if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) - return new ProxySettings(); + ArgumentNullException.ThrowIfNull(settings); try { - var json = File.ReadAllText(path, Encoding.UTF8); - var settings = JsonSerializer.Deserialize(json, JsonOptions) ?? new ProxySettings(); - - MigrateLegacyFlatFormat(json, settings); - - foreach (var profile in settings.Profiles) - if (!string.IsNullOrWhiteSpace(profile.ProxyPasswordEncrypted) && profile.GetPassword() == null) - { - Log.Warning("[ProxySettingsStore] 代理密码解密失败, 已清空密码字段: {ProfileName}", profile.DisplayName); - profile.ProxyPasswordEncrypted = string.Empty; - } - - return settings; - } - catch (Exception ex) - { - Log.Error(ex, "[ProxySettingsStore] 读取代理配置失败: {Path}", path); - IsolateBrokenFile(path); - return new ProxySettings(); - } - } + var sourcePath = ResolveLegacySourcePath(); + if (sourcePath == null) + return; - public static void Save(string path, ProxySettings settings) - { - ArgumentNullException.ThrowIfNull(settings); + var legacy = TryLoadLegacyFile(sourcePath); + if (legacy == null) + { + IsolateBrokenFile(sourcePath); + return; + } - if (string.IsNullOrWhiteSpace(path)) - throw new ArgumentException("代理配置路径不能为空", nameof(path)); + if (legacy.Profiles.Count == 0) + return; - var directoryPath = Path.GetDirectoryName(path); - if (!string.IsNullOrWhiteSpace(directoryPath)) - Directory.CreateDirectory(directoryPath); + if (settings.ProxySettings.Profiles.Count > 0 || !string.IsNullOrWhiteSpace(settings.ProxySettings.ActiveProfileId)) + { + Log.Information("[ProxySettingsMigration] 主配置中已存在代理配置, 跳过旧文件导入: {Path}", sourcePath); + return; + } - var tempPath = path + ".tmp"; - var backupPath = path + ".bak"; - var json = JsonSerializer.Serialize(settings, JsonOptions); + settings.ProxySettings = legacy; + settings.Save(); - try - { - File.WriteAllText(tempPath, json, Encoding.UTF8); - - if (File.Exists(path)) - File.Replace(tempPath, path, backupPath, true); - else - File.Move(tempPath, path); + Log.Information("[ProxySettingsMigration] 旧版代理配置已并入主配置: {Path}", sourcePath); + DeleteLegacyFiles(); } - catch + catch (Exception ex) { - TryDeleteTempFile(tempPath); - throw; + Log.Warning(ex, "[ProxySettingsMigration] 迁移旧版代理配置失败"); } } /// - /// 旧版本把代理配置放在启动器安装目录, 现在统一迁移到 Roaming: - /// 复制成功后尽力删除旧文件 (安装目录可能只读, 删除失败不阻塞) + /// 优先使用 Roaming 目录下的旧配置, 不存在时回退到启动器安装目录 /// - private static void MigrateLegacyLocation(string currentPath) + private static string? ResolveLegacySourcePath() { + var roamingPath = Paths.GetProxyConfigPath(); + if (File.Exists(roamingPath)) + return roamingPath; + var legacyPath = Paths.GetLegacyProxyConfigPath(); + return File.Exists(legacyPath) ? legacyPath : null; + } + private static ProxySettings? TryLoadLegacyFile(string path) + { try { - if (File.Exists(currentPath) - || !File.Exists(legacyPath) - || string.Equals(currentPath, legacyPath, StringComparison.OrdinalIgnoreCase)) - return; + var json = File.ReadAllText(path, Encoding.UTF8); + var settings = JsonSerializer.Deserialize(json, JsonOptions) ?? new ProxySettings(); - var directoryPath = Path.GetDirectoryName(currentPath); - if (!string.IsNullOrWhiteSpace(directoryPath)) - Directory.CreateDirectory(directoryPath); + MigrateLegacyFlatFormat(json, settings); - File.Copy(legacyPath, currentPath, overwrite: false); - Log.Information("[ProxySettingsStore] 已将旧版代理配置从安装目录迁移到: {Path}", currentPath); + foreach (var profile in settings.Profiles) + if (!string.IsNullOrWhiteSpace(profile.ProxyPasswordEncrypted) && profile.GetPassword() == null) + { + Log.Warning("[ProxySettingsMigration] 代理密码解密失败, 已清空密码字段: {ProfileName}", profile.DisplayName); + profile.ProxyPasswordEncrypted = string.Empty; + } - try - { - File.Delete(legacyPath); - } - catch (Exception ex) - { - Log.Warning(ex, "[ProxySettingsStore] 删除安装目录下的旧版代理配置失败 (可忽略): {LegacyPath}", legacyPath); - } + return settings; } catch (Exception ex) { - Log.Warning(ex, "[ProxySettingsStore] 迁移旧版代理配置失败: {LegacyPath} -> {CurrentPath}", legacyPath, currentPath); + Log.Error(ex, "[ProxySettingsMigration] 读取旧版代理配置失败: {Path}", path); + return null; } } @@ -147,7 +128,7 @@ private static void MigrateLegacyFlatFormat(string json, ProxySettings settings) } catch (Exception ex) { - Log.Warning(ex, "[ProxySettingsStore] 旧版扁平代理配置迁移失败"); + Log.Warning(ex, "[ProxySettingsMigration] 旧版扁平代理配置迁移失败"); } } @@ -163,24 +144,30 @@ private static void IsolateBrokenFile(string path) { var brokenPath = $"{path}.broken-{DateTime.Now:yyyyMMddHHmmssfff}"; File.Move(path, brokenPath); - Log.Warning("[ProxySettingsStore] 已隔离损坏的代理配置文件: {BrokenPath}", brokenPath); + Log.Warning("[ProxySettingsMigration] 已隔离损坏的旧版代理配置文件: {BrokenPath}", brokenPath); } catch (Exception ex) { - Log.Error(ex, "[ProxySettingsStore] 隔离损坏的代理配置文件失败: {Path}", path); + Log.Error(ex, "[ProxySettingsMigration] 隔离损坏的旧版代理配置文件失败: {Path}", path); } } - private static void TryDeleteTempFile(string tempPath) + /// + /// 导入成功后尽力删除两处旧文件 (删除失败不阻塞, 保留文件也仅作历史遗留) + /// + private static void DeleteLegacyFiles() { - try + foreach (var legacyPath in new[] { Paths.GetProxyConfigPath(), Paths.GetLegacyProxyConfigPath() }) { - if (File.Exists(tempPath)) - File.Delete(tempPath); - } - catch - { - // ignored + try + { + if (File.Exists(legacyPath)) + File.Delete(legacyPath); + } + catch (Exception ex) + { + Log.Warning(ex, "[ProxySettingsMigration] 删除旧版代理配置文件失败 (可忽略): {LegacyPath}", legacyPath); + } } } } diff --git a/src/XIVLauncher/Startup/StartupOrchestrator.cs b/src/XIVLauncher/Startup/StartupOrchestrator.cs index c28303b79..473a9f17c 100644 --- a/src/XIVLauncher/Startup/StartupOrchestrator.cs +++ b/src/XIVLauncher/Startup/StartupOrchestrator.cs @@ -141,8 +141,8 @@ private async Task InitializeSettingsAsync() if (!string.IsNullOrEmpty(commandLineOptions.RoamingPath)) Paths.OverrideRoamingPath(commandLineOptions.RoamingPath); - var proxySettings = ProxySettingsStore.Load(Paths.GetProxyConfigPath()); - XLProxyProvider.Apply(proxySettings.ToSnapshot()); + ProxySettingsMigration.ImportLegacyInto(context.Settings); + XLProxyProvider.Apply(context.Settings.ProxySettings.ToSnapshot()); context.Settings.Update (settings => diff --git a/src/XIVLauncher/Windows/ProxySettingsWindow.xaml b/src/XIVLauncher/Windows/ProxySettingsWindow.xaml index b0219ee02..75214a6e9 100644 --- a/src/XIVLauncher/Windows/ProxySettingsWindow.xaml +++ b/src/XIVLauncher/Windows/ProxySettingsWindow.xaml @@ -151,7 +151,7 @@ Foreground="{DynamicResource MaterialDesign.Brush.ForegroundLight}" /> + Text="保存后立即生效。配置随启动器配置保存在 Roaming 目录,更新重装不会丢失。" /> diff --git a/src/XIVLauncher/Windows/ProxySettingsWindow.xaml.cs b/src/XIVLauncher/Windows/ProxySettingsWindow.xaml.cs index 4b7ea148b..cc35f0e42 100644 --- a/src/XIVLauncher/Windows/ProxySettingsWindow.xaml.cs +++ b/src/XIVLauncher/Windows/ProxySettingsWindow.xaml.cs @@ -1,7 +1,7 @@ using System.ComponentModel; using System.Windows; using Serilog; -using XIVLauncher.Common.Constant; +using XIVLauncher.Common.Http; using XIVLauncher.Settings; using XIVLauncher.Windows.ViewModel; @@ -16,7 +16,8 @@ public ProxySettingsWindow() { InitializeComponent(); - var settings = ProxySettingsStore.Load(Paths.GetProxyConfigPath()); + // 基于主配置的深拷贝进行编辑, 取消窗口时不影响已生效的代理配置 + var settings = App.Settings.ProxySettings.DeepClone(); var viewModel = new ProxySettingsWindowViewModel(settings); DataContext = viewModel; @@ -55,6 +56,11 @@ private void ConfirmButton_OnClick(object sender, RoutedEventArgs e) try { ViewModel.Save(ProxyPasswordBox.Password); + + // 提交到启动器主配置并立即应用 + App.Settings.Update(settings => settings.ProxySettings = ViewModel.Settings); + XLProxyProvider.Apply(App.Settings.ProxySettings.ToSnapshot()); + DialogResult = true; Close(); } diff --git a/src/XIVLauncher/Windows/ViewModel/ProxySettingsWindowViewModel.cs b/src/XIVLauncher/Windows/ViewModel/ProxySettingsWindowViewModel.cs index 6e4e7db26..470f0110c 100644 --- a/src/XIVLauncher/Windows/ViewModel/ProxySettingsWindowViewModel.cs +++ b/src/XIVLauncher/Windows/ViewModel/ProxySettingsWindowViewModel.cs @@ -7,7 +7,10 @@ namespace XIVLauncher.Windows.ViewModel; public sealed partial class ProxySettingsWindowViewModel : ObservableObject { - private readonly ProxySettings settings; + /// + /// 编辑会话中使用的配置副本, 确认后由窗口层提交到启动器主配置 + /// + public ProxySettings Settings { get; } public sealed record ProxyTypeOption(ProxyType Type, string Display); @@ -50,8 +53,8 @@ public ProxySettingsWindowViewModel(ProxySettings settings) { ArgumentNullException.ThrowIfNull(settings); - this.settings = settings; - ConfigFilePath = Paths.GetProxyConfigPath(); + Settings = settings; + ConfigFilePath = Paths.GetConfigPath(); foreach (var profile in settings.Profiles) Profiles.Add(profile); @@ -116,10 +119,8 @@ public void Save(string? passwordInput) profile.SetPassword(passwordInput); } - settings.Profiles = Profiles.ToList(); - settings.ActiveProfileId = profile?.Id; - - ProxySettingsStore.Save(Paths.GetProxyConfigPath(), settings); + Settings.Profiles = Profiles.ToList(); + Settings.ActiveProfileId = profile?.Id; } private void RebuildSelection() From a786f3c967132a9493089a42c6ddca3dbc70b7dd Mon Sep 17 00:00:00 2001 From: Errer Date: Tue, 15 Sep 2026 22:44:22 +0800 Subject: [PATCH 12/14] =?UTF-8?q?=E4=BB=A3=E7=90=86=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E5=85=A5=E5=8F=A3=E7=A7=BB=E5=85=A5=20Settin?= =?UTF-8?q?gsWindowViewModel=20=E5=B9=B6=E7=BB=9F=E4=B8=80=E7=BB=8F?= =?UTF-8?q?=E7=94=B1=20DialogService=20=E6=89=93=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/XIVLauncher/Windows/Services/DialogService.cs | 7 +++++++ src/XIVLauncher/Windows/Services/IDialogService.cs | 2 ++ src/XIVLauncher/Windows/SettingsWindow.xaml | 4 ++-- src/XIVLauncher/Windows/SettingsWindow.xaml.cs | 11 ----------- .../Windows/ViewModel/SettingsWindowViewModel.cs | 4 ++++ 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/XIVLauncher/Windows/Services/DialogService.cs b/src/XIVLauncher/Windows/Services/DialogService.cs index bcef2d7d0..12a437aea 100644 --- a/src/XIVLauncher/Windows/Services/DialogService.cs +++ b/src/XIVLauncher/Windows/Services/DialogService.cs @@ -64,6 +64,13 @@ public void ShowAdvancedSettings() window.ShowDialog(); } + public void ShowProxySettings() + { + var window = new ProxySettingsWindow(); + PrepareOwner(window); + window.ShowDialog(); + } + public CompanionAppConfiguration? ShowCompanionAppSetup(CompanionAppConfiguration? companionApp = null) { var window = new CompanionAppSetupWindow(companionApp); diff --git a/src/XIVLauncher/Windows/Services/IDialogService.cs b/src/XIVLauncher/Windows/Services/IDialogService.cs index d8b9c6a17..838034da5 100644 --- a/src/XIVLauncher/Windows/Services/IDialogService.cs +++ b/src/XIVLauncher/Windows/Services/IDialogService.cs @@ -27,6 +27,8 @@ MessageBoxResult ShowMessage void ShowAdvancedSettings(); + void ShowProxySettings(); + CompanionAppConfiguration? ShowCompanionAppSetup(CompanionAppConfiguration? companionApp = null); bool ShowProfilePictureInput(XIVAccount account, out string? profileImagePath); diff --git a/src/XIVLauncher/Windows/SettingsWindow.xaml b/src/XIVLauncher/Windows/SettingsWindow.xaml index 4b9af5b03..118d82b9f 100644 --- a/src/XIVLauncher/Windows/SettingsWindow.xaml +++ b/src/XIVLauncher/Windows/SettingsWindow.xaml @@ -603,10 +603,10 @@ + Text="启动器网络请求(登录、补丁、超域旅行等)使用的代理,保存后立即生效" /> public ProxySettings Settings { get; } @@ -49,6 +49,10 @@ public sealed record ProxySelectItem(ProxyProfile? Profile) "留空保持当前密码" : "未设置"; + public bool CanClearPassword => + HasSelectedProfile + && !string.IsNullOrWhiteSpace(SelectedProfile.ProxyPasswordEncrypted); + public ProxySettingsWindowViewModel(ProxySettings settings) { ArgumentNullException.ThrowIfNull(settings); @@ -72,6 +76,17 @@ partial void OnSelectedItemChanged(ProxySelectItem? value) OnPropertyChanged(nameof(SelectedProfile)); OnPropertyChanged(nameof(HasSelectedProfile)); OnPropertyChanged(nameof(PasswordHint)); + OnPropertyChanged(nameof(CanClearPassword)); + } + + public void ClearPassword() + { + if (SelectedProfile == null) + return; + + SelectedProfile.SetPassword(null); + OnPropertyChanged(nameof(PasswordHint)); + OnPropertyChanged(nameof(CanClearPassword)); } public void CreateProfile()