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="管理" /> + + + + + + + + + + +