diff --git a/src/XIVLauncher.Common.Http/ProxyConfigSnapshot.cs b/src/XIVLauncher.Common.Http/ProxyConfigSnapshot.cs
new file mode 100644
index 000000000..51199bac4
--- /dev/null
+++ b/src/XIVLauncher.Common.Http/ProxyConfigSnapshot.cs
@@ -0,0 +1,20 @@
+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);
+}
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..4d0d415ef
--- /dev/null
+++ b/src/XIVLauncher.Common.Http/XLProxyProvider.cs
@@ -0,0 +1,85 @@
+using System.Net;
+using Serilog;
+
+namespace XIVLauncher.Common.Http;
+
+///
+/// 启动器全局代理提供者, 由启动流程根据代理配置构建一次。
+/// HTTP/HTTPS/SOCKS5 由 SocketsHttpHandler 原生支持。
+///
+public static class XLProxyProvider
+{
+ public static IWebProxy? Current { get; private set; }
+
+ public static void Apply(ProxyConfigSnapshot? config)
+ {
+ var webProxy = BuildWebProxy(config);
+ Current = webProxy is null ? null : new SdoScopedProxy(webProxy);
+
+ Log.Information
+ (
+ "[XLProxyProvider] 代理配置已应用 (仅对盛趣域名 *.sdo.com / *.jijiagames.com 生效): {Proxy}",
+ webProxy is WebProxy { Address: not null } proxy ? proxy.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;
+ }
+ }
+
+ ///
+ /// 仅对盛趣域名 (*.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.Common/Game/Launcher.cs b/src/XIVLauncher.Common/Game/Launcher.cs
index ce9cfb2bb..4913ee695 100644
--- a/src/XIVLauncher.Common/Game/Launcher.cs
+++ b/src/XIVLauncher.Common/Game/Launcher.cs
@@ -4,6 +4,7 @@
using Serilog;
using XIVLauncher.Common.Constant;
using XIVLauncher.Common.Game.Exceptions;
+using XIVLauncher.Common.Http;
using XIVLauncher.Common.Util;
namespace XIVLauncher.Common.Game;
@@ -13,7 +14,7 @@ public partial class Launcher
public delegate Process? GameStarter(GameStartRequest request);
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 45d00e5d6..33dc31b08 100644
--- a/src/XIVLauncher.GamePatchV3/Update/GamePatchInstaller.cs
+++ b/src/XIVLauncher.GamePatchV3/Update/GamePatchInstaller.cs
@@ -11,6 +11,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;
@@ -25,7 +26,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.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/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/ProxyProfile.cs b/src/XIVLauncher/Settings/ProxyProfile.cs
new file mode 100644
index 000000000..7da609967
--- /dev/null
+++ b/src/XIVLauncher/Settings/ProxyProfile.cs
@@ -0,0 +1,102 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json.Serialization;
+using XIVLauncher.Common.Http;
+
+namespace XIVLauncher.Settings;
+
+///
+/// 单个代理配置条目 (预设)
+///
+public sealed class ProxyProfile
+{
+ private static readonly byte[] PasswordEntropy = "XIVLauncherCN.ProxyPassword"u8.ToArray();
+
+ public string Id { get; set; } = Guid.NewGuid().ToString("N");
+
+ public string Name { get; set; } = string.Empty;
+
+ 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; }
+
+ [JsonIgnore]
+ public string DisplayName =>
+ string.IsNullOrWhiteSpace(Name) ? "未命名配置" : Name;
+
+ 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/ProxySettings.cs b/src/XIVLauncher/Settings/ProxySettings.cs
new file mode 100644
index 000000000..74f0dd600
--- /dev/null
+++ b/src/XIVLauncher/Settings/ProxySettings.cs
@@ -0,0 +1,35 @@
+using System.Text.Json;
+using XIVLauncher.Common.Http;
+
+namespace XIVLauncher.Settings;
+
+///
+/// 启动器网络代理配置 (随主配置一并存储)
+///
+public sealed class ProxySettings
+{
+ ///
+ /// 代理配置条目 (预设)
+ ///
+ public List Profiles { get; set; } = [];
+
+ ///
+ /// 当前生效的条目 Id;null 表示不使用代理
+ ///
+ public string? ActiveProfileId { get; set; }
+
+ public ProxyProfile? GetActiveProfile() =>
+ Profiles.FirstOrDefault(profile => string.Equals(profile.Id, ActiveProfileId, StringComparison.Ordinal));
+
+ 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/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..ed8ffc72e 100644
--- a/src/XIVLauncher/Startup/StartupOrchestrator.cs
+++ b/src/XIVLauncher/Startup/StartupOrchestrator.cs
@@ -141,6 +141,8 @@ private async Task InitializeSettingsAsync()
if (!string.IsNullOrEmpty(commandLineOptions.RoamingPath))
Paths.OverrideRoamingPath(commandLineOptions.RoamingPath);
+ XLProxyProvider.Apply(context.Settings.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..28a6e42aa
--- /dev/null
+++ b/src/XIVLauncher/Windows/ProxySettingsWindow.xaml
@@ -0,0 +1,208 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/XIVLauncher/Windows/ProxySettingsWindow.xaml.cs b/src/XIVLauncher/Windows/ProxySettingsWindow.xaml.cs
new file mode 100644
index 000000000..e8e7d2811
--- /dev/null
+++ b/src/XIVLauncher/Windows/ProxySettingsWindow.xaml.cs
@@ -0,0 +1,83 @@
+using System.ComponentModel;
+using System.Windows;
+using Serilog;
+using XIVLauncher.Windows.ViewModel;
+
+namespace XIVLauncher.Windows;
+
+public partial class ProxySettingsWindow
+{
+ private ProxySettingsWindowViewModel ViewModel =>
+ (ProxySettingsWindowViewModel)DataContext;
+
+ public ProxySettingsWindow()
+ {
+ InitializeComponent();
+
+ // 基于主配置的深拷贝进行编辑, 取消窗口时不影响已生效的代理配置
+ var settings = App.Settings.ProxySettings.DeepClone();
+ 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) =>
+ 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 ClearPasswordButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ ViewModel.ClearPassword();
+ ProxyPasswordBox.Password = string.Empty;
+ }
+
+ private void ConfirmButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ ViewModel.Save(ProxyPasswordBox.Password);
+
+ // 提交到启动器主配置, 下次启动时生效
+ App.Settings.Update(settings => settings.ProxySettings = ViewModel.Settings);
+
+ 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/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 053ff87df..408b47eab 100644
--- a/src/XIVLauncher/Windows/SettingsWindow.xaml
+++ b/src/XIVLauncher/Windows/SettingsWindow.xaml
@@ -594,6 +594,21 @@
Click="SharedDeviceProfileButton_Click"
Style="{StaticResource ThemeOutlinedButtonStyle}" Content="管理" />
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/XIVLauncher/Windows/ViewModel/ProxySettingsWindowViewModel.cs b/src/XIVLauncher/Windows/ViewModel/ProxySettingsWindowViewModel.cs
new file mode 100644
index 000000000..787a33763
--- /dev/null
+++ b/src/XIVLauncher/Windows/ViewModel/ProxySettingsWindowViewModel.cs
@@ -0,0 +1,149 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using XIVLauncher.Common.Constant;
+using XIVLauncher.Settings;
+
+namespace XIVLauncher.Windows.ViewModel;
+
+public sealed partial class ProxySettingsWindowViewModel : ObservableObject
+{
+ ///
+ /// 编辑会话使用的配置副本
+ ///
+ public ProxySettings Settings { get; }
+
+ 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, "不使用代理"),
+ new(ProxyType.Http, "HTTP"),
+ new(ProxyType.Https, "HTTPS"),
+ new(ProxyType.Socks5, "SOCKS5")
+ ];
+
+ public ObservableCollection Profiles { get; } = [];
+ public ObservableCollection SelectionItems { get; } = [];
+
+ [ObservableProperty]
+ public partial ProxySelectItem SelectedItem { get; set; }
+
+ [ObservableProperty]
+ public partial string ConfigFilePath { get; set; } = string.Empty;
+
+ public ProxyProfile? SelectedProfile =>
+ SelectedItem?.Profile;
+
+ public bool HasSelectedProfile =>
+ SelectedProfile != null;
+
+ public string PasswordHint =>
+ HasSelectedProfile
+ && !string.IsNullOrWhiteSpace(SelectedProfile.ProxyPasswordEncrypted) ?
+ "留空保持当前密码" :
+ "未设置";
+
+ public bool CanClearPassword =>
+ HasSelectedProfile
+ && !string.IsNullOrWhiteSpace(SelectedProfile.ProxyPasswordEncrypted);
+
+ public ProxySettingsWindowViewModel(ProxySettings settings)
+ {
+ ArgumentNullException.ThrowIfNull(settings);
+
+ Settings = settings;
+ ConfigFilePath = Paths.GetConfigPath();
+
+ 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));
+ OnPropertyChanged(nameof(CanClearPassword));
+ }
+
+ public void ClearPassword()
+ {
+ if (SelectedProfile == null)
+ return;
+
+ SelectedProfile.SetPassword(null);
+ OnPropertyChanged(nameof(PasswordHint));
+ OnPropertyChanged(nameof(CanClearPassword));
+ }
+
+ 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 profile = SelectedProfile;
+
+ if (profile != null)
+ {
+ if (string.IsNullOrWhiteSpace(profile.Name))
+ throw new InvalidOperationException("配置名称不能为空。");
+
+ if (profile.ProxyType != ProxyType.None)
+ {
+ if (string.IsNullOrWhiteSpace(profile.ProxyHost))
+ throw new InvalidOperationException("代理主机不能为空。");
+
+ if (profile.ProxyPort is < 1 or > 65535)
+ throw new InvalidOperationException("代理端口必须在 1 - 65535 之间。");
+ }
+
+ profile.Name = profile.Name.Trim();
+ profile.ProxyHost = profile.ProxyHost.Trim();
+ profile.ProxyUsername = profile.ProxyUsername.Trim();
+
+ if (!string.IsNullOrEmpty(passwordInput))
+ profile.SetPassword(passwordInput);
+ }
+
+ Settings.Profiles = Profiles.ToList();
+ Settings.ActiveProfileId = profile?.Id;
+ }
+
+ private void RebuildSelection()
+ {
+ SelectionItems.Clear();
+ SelectionItems.Add(new ProxySelectItem(null));
+
+ foreach (var profile in Profiles)
+ SelectionItems.Add(new ProxySelectItem(profile));
+ }
+}
diff --git a/src/XIVLauncher/Windows/ViewModel/SettingsWindowViewModel.cs b/src/XIVLauncher/Windows/ViewModel/SettingsWindowViewModel.cs
index ad1c7af61..e45254e52 100644
--- a/src/XIVLauncher/Windows/ViewModel/SettingsWindowViewModel.cs
+++ b/src/XIVLauncher/Windows/ViewModel/SettingsWindowViewModel.cs
@@ -233,6 +233,10 @@ private bool CanOpenGamePathCommand() =>
private void OpenAdvancedSettings() =>
_dialogService.ShowAdvancedSettings();
+ [RelayCommand]
+ private void OpenProxySettings() =>
+ _dialogService.ShowProxySettings();
+
public void OpenLicense() =>
_externalLaunchService.OpenPath(Path.Combine(Paths.ResourcesPath, "LICENSE.txt"));