Skip to content

卫月启动器添加代理设置功能 - #45

Open
Errerer wants to merge 21 commits into
AtmoOmen:CNfrom
Errerer:CN
Open

Errerer wants to merge 21 commits into
AtmoOmen:CNfrom
Errerer:CN

Conversation

@Errerer

@Errerer Errerer commented Sep 14, 2026

Copy link
Copy Markdown

No description provided.

@AtmoOmen AtmoOmen left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

请勿让代理配置绕开原有的配置体系,目前整个代理系统都游离于其他系统之外。

@Errerer

Errerer commented Sep 15, 2026

Copy link
Copy Markdown
Author

配置已并入主配置、入口已移入 VM、保存即生效

@AtmoOmen AtmoOmen left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. 不要包含“西红柿炒鸡蛋(没有红烧肉版)”式注释。
  2. 测试内容没有什么意义,请删除。

@AtmoOmen AtmoOmen left a comment •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

逻辑错误

  1. 下拉框条目名不会随"显示名称"修改刷新。 ProxySettingsWindowViewModel.cs 中 ProxySelectItem 是 record,DisplayName 是只读计算属性:
public sealed record ProxySelectItem(ProxyProfile? Profile)
{
    public string DisplayName =>
        Profile is null ? "不使用代理" : Profile.DisplayName;
}

record 不实现 INotifyPropertyChanged,而 RebuildSelection() 只在构造函数、CreateProfile()、DeleteSelectedProfile() 里被调用。XAML 用 DisplayMemberPath="DisplayName" 绑定 SelectionItems,因此用户改完"显示名称"以后,条目下拉框仍显示旧名字,只有再新增或删除条目才会刷新。

  1. ProxyConfigSnapshot.IsDisabled 里与 ToSnapshot() 重复的一半判断不可达。 ProxyProfile.ToSnapshot() 已经做了同等校验:
if (ProxyType == ProxyType.None)
    return null;

var host = ProxyHost.Trim();
if (string.IsNullOrWhiteSpace(host) || ProxyPort is < 1 or > 65535)
    return null;

而 ProxyConfigSnapshot 又写了一遍:

public bool IsDisabled =>
    string.IsNullOrWhiteSpace(Host)
    || Port is < 1 or > 65535
    || string.IsNullOrWhiteSpace(Type)
    || string.Equals(Type, "None", StringComparison.OrdinalIgnoreCase);

在这次改动的全部调用路径中,ProxyConfigSnapshot 只由 ProxyProfile.ToSnapshot() 构造,所以 Host 为空、端口越界、Type 为空或为 "None" 四种情况都已经被上一层拦掉,这几个分支不会成立。

  1. 密码解密失败的清理写了两遍,其中一遍必然不生效。 ProxyProfile.GetPassword() 在 catch 中直接清空字段:
catch
{
    ProxyPasswordEncrypted = string.Empty;
    return null;
}

LauncherSettingsV3.SanitizeProxySettings() 再判一次并再清一次:

if (!string.IsNullOrWhiteSpace(profile.ProxyPasswordEncrypted) && profile.GetPassword() == null)
{
    Log.Warning("[LauncherSettingsV3] 代理密码解密失败, 已清空密码字段: {ProfileName}", profile.DisplayName);
    profile.ProxyPasswordEncrypted = string.Empty;
}

条件里的 profile.GetPassword() 求值时已经把 ProxyPasswordEncrypted 清空,紧接着的赋值不会产生任何变化,这段实际只起到记日志的作用。

  1. GamePatchMetadataClient 与 GamePatchInstaller 从 new HttpClient() 换成显式 SocketsHttpHandler 后,Cookie 处理语义变了。 原先 private readonly HttpClient client = new(); 内部用的是 HttpClientHandler,其 UseCookies 默认为 true;换成 new SocketsHttpHandler { UseProxy = true, Proxy = ... } 后,SocketsHttpHandler.CookieContainer 默认为 null,不再处理 Set-Cookie。这两个类只 GET SdoInfos.CLIENT_ALL_FILES_LIST_URL 与 SdoInfos.REMOTE_VERSION_URL,不使用 Cookie,所以这次改动没有实际后果;同一次改动中的另外两处则显式保留了该语义——Launcher.MockHttpClient 用 new HttpClientHandler { UseCookies = true, Proxy = ... },LoginChannelContext 保留了 CookieContainer。

  2. ConfirmButton_OnClick 里 Close() 是多余的。

DialogResult = true;
Close();

给 DialogResult 赋值本身就会关闭窗口。

  1. 代理对象与 HttpClient 之间存在时序耦合,但没有约束保证。 XLProxyProvider.Current 是可变的静态属性,只在 StartupOrchestrator.InitializeSettingsAsync 中被写一次:
XLProxyProvider.Apply(context.Settings.ProxySettings.ToSnapshot());

所有 HttpClient 都必须在这次调用之后构造才能拿到代理。但 GameFileDownloader、GamePatchMetadataClient、GamePatchInstaller 仍是字段初始化器 Proxy = XLProxyProvider.Current。目前这几处对象都在登录、打补丁阶段才创建,MainWindowViewModel.cs 第 70 行的 Launcher = new(); 也在启动流程之后,所以当前没有失效路径;一旦有 HttpClient 在 InitializeSettingsAsync 之前构造,就会静默退回直连。

  1. XLProxyProvider.BuildWebProxy 声明为 public,但改动中没有本类以外的调用点,只有 Apply 内部调用它。

  2. "不使用代理"存在两条独立表达路径。 SelectionItems[0] 是 ProxySelectItem(null)(显示"不使用代理"),ProxyTypeOptions 里也有一项 new(ProxyType.None, "不使用代理")。前者走 Save 里 Settings.ActiveProfileId = profile?.Id 置 null,后者走 ProxyProfile.ToSnapshot() 返回 null。两条路径最终结果相同,但界面上同时存在两个名为"不使用代理"的选项。

界面绘制与项目风格的一致性

  1. 窗口基类与同类设置窗口不同。 ProxySettingsWindow.xaml 是普通 <Window>,只有系统标题栏;项目里的设置类窗口都是 components:ChromeWindow 加 components:TitleBar:SettingsWindow.xaml、AdvancedSettingsWindow.xaml(第 1、33 行)、ChangelogWindow.xaml、CompanionAppSetupWindow.xaml、AccountDeviceProfileWindow.xaml。ChromeWindow.cs 的注释写明了它的作用:
///     Base window that removes the visible OS frame while keeping the native
///     caption style. Windows therefore still plays its own show/hide/minimize/
///     maximize animation (no custom, flash-prone opacity animation is needed),
///     and the window keeps Aero Snap, native resizing, the DWM drop shadow and
///     Windows 11 rounded corners.

新窗口反而采用了注释里被点名的旧做法:<Window.Triggers><EventTrigger RoutedEvent="Window.Loaded"><BeginStoryboard Storyboard="{StaticResource WindowLoadAnimation}" />,配合 Grid x:Name="WindowAnimationLayer" Opacity="0"。项目里保留这套写法的是 CaptchaInputWindow、GameClientFileTaskWindow、LoadingDialog 这类任务对话框,而不是从设置窗口进入的子窗口。

  1. 卡片参数与相邻窗口不一致。 新窗口用 materialDesign:Card Margin="0,0,0,16" UniformCornerRadius="8" 加 <StackPanel Margin="20">;AccountDeviceProfileWindow.xaml 用 UniformCornerRadius="12" ClipContent="True" Background="{DynamicResource MaterialDesign.Brush.Surface}" materialDesign:ElevationAssist.Elevation="Dp2" 加 <StackPanel Margin="24,20">;SettingsWindow.xaml 的 SettingCardStyle 是 UniformCornerRadius=8、Background=Surface、Elevation=Dp0、Padding=18。

  2. 标题区比例失衡。 新窗口的头部是:

<materialDesign:PackIcon Kind="Network" Width="36" Height="36" VerticalAlignment="Center"
                         Foreground="{DynamicResource MaterialDesign.Brush.Primary}"
                         Margin="0,0,16,0" />
<TextBlock Foreground="{DynamicResource MaterialDesign.Brush.Foreground}"
           Text="网络代理" VerticalAlignment="Center" FontWeight="SemiBold" />

TextBlock 没有绑定文本样式,字号继承窗口上的 TextElement.FontSize="14"。AdvancedSettingsWindow.xaml 的同类头部是 32×32 图标加 Style="{StaticResource MaterialDesignHeadline5TextBlock}"。

  1. 按钮与底部操作栏没有复用邻近窗口的参数。 新窗口的"新增/删除"直接用 Style="{StaticResource MaterialDesignOutlinedButton}",AccountDeviceProfileWindow.xaml 对应位置用 CardButtonStyle(materialDesign:ButtonAssist.CornerRadius=6、Height=36、Padding=14,0)。底部操作栏 Padding="24,16",AccountDeviceProfileWindow.xaml 是 Padding="28,16"。

  2. 详情表单同时用了外置标签和框内 Hint。 例如:

<TextBlock Text="代理主机" Foreground="{DynamicResource MaterialDesign.Brush.ForegroundLight}" />
<TextBox Margin="0,4,0,16"
         Text="{Binding SelectedProfile.ProxyHost, UpdateSourceTrigger=PropertyChanged}"
         materialDesign:HintAssist.Hint="例如 127.0.0.1"
         Style="{StaticResource MaterialDesignOutlinedTextBox}" />

AccountDeviceProfileWindow.xaml 的同类字段(设备 ID、MAC 地址、主机名等)只有 HintAssist.Hint,没有外置标签。

  1. DarkIntegerUpDownStyle 是逐条复制过来的。 新窗口内的样式与 AccountDeviceProfileWindow.xaml 第 88-105 行的同名样式在 8 个 Setter(Background、Foreground、BorderBrush、BorderThickness、Height、FontSize、Padding)和 IsEnabled 触发器上完全一致。同时端口控件又在元素上写了 Padding="4",覆盖掉样式里的 Padding="10,0":
<xctk:IntegerUpDown Margin="0,4,0,16" Width="140" Minimum="1" Maximum="65535"
                    Value="{Binding SelectedProfile.ProxyPort, UpdateSourceTrigger=PropertyChanged}"
                    Style="{StaticResource DarkIntegerUpDownStyle}"
                    Padding="4" />

交互与用户心智

  1. 设置入口落在"账号安全"分组里。 新增项插在 SettingsWindow.xaml 第 594-608 行,位置上属于第 527 行的 Text="账号安全" 卡片(下一张卡片标题"伴随程序"在第 600 行)。该卡片原有内容是 RequireDeviceProfileSetupForNewAccountLogin、RequireDeviceProfileSetupForQRCodeLogin 与"共享设备信息",都是设备信息相关项。

  2. 密码要重新输入才能保住,切条目就丢。 窗口 code-behind 订阅 PropertyChanged,切换条目时清空密码框:

viewModel.PropertyChanged += (_, e) =>
{
    if (e.PropertyName == nameof(ProxySettingsWindowViewModel.SelectedItem))
        ProxyPasswordBox.Password = string.Empty;
};

密码只在 ConfirmButton_OnClick 里通过 ViewModel.Save(ProxyPasswordBox.Password) 落进条目,且 Save 只在输入非空时调用 SetPassword。因此用户输入密码后切到另一条看一眼再切回来,输入内容已经没了,此时 hint 会显示 PasswordHint 的"先设置"分支,即"未设置"。

  1. "清除密码"与"未设置"提示词。 PasswordHint 在没有已存密码时返回"未设置",这个字符串被当作 materialDesign:HintAssist.Hint 使用,表达的是状态而不是输入提示;有密码时返回"留空保持当前密码",才是输入提示。两种语义共用一个位置。

  2. 窗口里直接展示配置文件完整路径。 ProxySettingsWindowViewModel 把 ConfigFilePath = Paths.GetConfigPath() 传给界面,ProxySettingsWindow.xaml 用 <TextBlock Text="{Binding ConfigFilePath}" Opacity="0.58" TextWrapping="Wrap" /> 显示,紧邻的下一行又用文字说明"配置随启动器配置保存在 Roaming 目录"。用户在同一屏里同时看到绝对路径和"Roaming 目录"这类表述。

  3. 改名后下拉框不更新(同逻辑问题第 1 条的使用侧表现)。 用户在"显示名称"里改成新名字,条目下拉框仍显示旧名字,只有点"新增"或"删除"才会刷新。

  4. 提交方式只有"确定"。 修改不落盘的唯一路径是关闭窗口,依赖窗口构造函数里的深拷贝:

var settings  = App.Settings.ProxySettings.DeepClone();
var viewModel = new ProxySettingsWindowViewModel(settings);
DataContext   = viewModel;

AdvancedSettingsWindow 同样只有"确认"按钮,这一点一致;差别在于删除条目有 CustomMessageBox 二次确认,清除密码没有。

重复造轮子

  1. XLHttpClientFactory 新增的代理参数只有一个调用点,另有三处手写了同类 handler。 这次改动给工厂加了参数:
public static HttpClient Create
(
    TimeSpan             connectTimeout,
    int                  maxConnectionsPerServer,
    DecompressionMethods automaticDecompression,
    IWebProxy?           proxy = null
)

全项目 7 处 XLHttpClientFactory.Create 调用(XLHttpClientFileDownloader、NetworkEnvironmentService、DotNetRuntimeManager、HttpClientWithProgress、DalamudAssetManager、DalamudUpdater、LoginArea)中只有 LoginArea 传了 XLProxyProvider.Current。而 GameFileDownloader、GamePatchMetadataClient、GamePatchInstaller 各自展开了一份 new SocketsHttpHandler { UseProxy = true, Proxy = XLProxyProvider.Current }。工厂里那份 handler 还额外配置了 ConnectTimeout、MaxConnectionsPerServer、EnableMultipleHttp2Connections、KeepAlivePingDelay/Timeout/Policy、PooledConnectionLifetime、PooledConnectionIdleTimeout、Expect100ContinueTimeout、ResponseDrainTimeout,并套了一层 Http11FallbackHandler,手写的三处都没有这些。DCTravelClient、LoginChannelContext、Launcher 用的是 HttpClientHandler(需要 CookieContainer),工厂不覆盖这种情形。

  1. 条目管理界面整块照搬了 AccountDeviceProfileWindow 的"设备信息预设"卡片。 相同的 materialDesign:PackIcon Kind="FormatListBulleted" 标题行、相同的右上"新增/删除"按钮组(Kind="Plus"、Kind="DeleteOutline")、相同的 ComboBox(HintAssist.Hint="选择…") + 16 宽间隔 + TextBox(Hint="显示名称") 双列布局:
<ColumnDefinition Width="*" />
<ColumnDefinition Width="16" />
<ColumnDefinition Width="*" />

AccountDeviceProfileWindow.xaml 第 225-246 行是同一段结构,只是 Hint 文案是"选择预设"。详情卡片的做法也一样:<StackPanel Margin="20" IsEnabled="{Binding HasSelectedProfile}"> 对应 AccountDeviceProfileWindow.xaml 的 <StackPanel Margin="24,20" IsEnabled="{Binding CanEditDeviceDetails}">。

  1. DarkIntegerUpDownStyle 重复定义第二份(详见风格一节第 6 条),位置在 ProxySettingsWindow.xaml 的窗口资源里,而 Xaml/Components/ 下已有共享资源字典可放这类样式。

  2. 密码加解密另起了一套。 ProxyProfile 用 DPAPI 自建:

private static readonly byte[] PasswordEntropy = "XIVLauncherCN.ProxyPassword"u8.ToArray();

private static string EncryptPassword(string plainPassword) =>
    Convert.ToBase64String(ProtectedData.Protect(Encoding.UTF8.GetBytes(plainPassword), PasswordEntropy, DataProtectionScope.CurrentUser));

项目里已有两套加解密实现:XIVLauncher.Account/Cred/EncryptionHelper.cs(Aegis256 + Argon2id,EncryptString/DecryptString)与 ICredProvider 体系(CredentialManager 基于 Windows 凭据管理器、WindowsHello)。代理密码不能直接套用后者的一个客观原因是加载顺序:StartupOrchestrator.cs 第 129 行 LauncherSettingsV3.Load(...) 触发 SanitizeProxySettings() 解密,第 164 行才执行 context.AccountManager.InitializeCredProviderAsync(...),凭据提供者在代理密码需要解密时尚未初始化。

  1. 代理类型在两处各定义一次,中间靠字符串往返。 ProxyType 枚举在 XIVLauncher/Settings/ProxyType.cs,ProxyConfigSnapshot.Type 是 string,由 ProxyType.ToString() 得到,再在 XLProxyProvider.BuildWebProxy 里映射回来:
var scheme = config.Type.Trim().ToLowerInvariant() switch
{
    "http"   => "http",
    "https"  => "https",
    "socks5" => "socks5",
    _        => null
};

ProxyConfigSnapshot.IsDisabled 里判断 string.Equals(Type, "None", ...),ProxySettingsWindowViewModel 里又有一份 ProxyTypeOptions("不使用代理"、"HTTP"、"HTTPS"、"SOCKS5")。

ProxySettings.DeepClone() 用 JsonSerializer.Serialize/Deserialize 默认选项做深拷贝,而 LauncherSettingsV3 有自己的 JsonOptions(IncludeFields = true、PropertyNameCaseInsensitive = true、WriteIndented = true),两处序列化配置并存。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants