重做设置界面并修复登录自启:页签单选、滚轮归属、任务缺失判定、发布去掉自带运行时

设置窗口
- 改为独立非模态窗口(进任务栏、可最小化),不再被托盘浮窗挡住导致点不到
- 位置改为按屏幕可用区域居中裁剪,不再默认落在右下角
- 三个页签重构为卡片式布局:统一卡片内边距、页签行独立成行、正文左右留白、
  帮助文字按实际换行测量高度、标题栏与底栏加分隔线
- 直连 DNS 复选框、下拉框、滚动条改为自绘控件,配色与整体统一

页签
- PanelChip 的选中/强调改为值变化即重绘,修掉“切换页签后旧页签仍显示为激活、
  鼠标移上去才更新”的陈旧绘制

滚轮
- 输入框滚不动自己时把滚轮交给外层页面,单行输入框不再吞掉滚轮事件

登录自启
- 改用任务 XML + schtasks 写入,COM 失败时用 schtasks 复核;写入后读回校验
- 任务不存在按 HRESULT 判定(0x80070002 / 0x80070003 / 0x8004130F),修掉
  “关闭登录自启必定报错并回滚”(晚绑定 COM 抛的是 FileNotFoundException)
- 状态读取 COM 失败时回退到 schtasks,失败按异常类型 + HResult 去重记录

诊断
- 界面报错统一写日志;控制器记录自启读写结果;启动自检记录管理员/自启/进程/参数

发布
- 去掉 -SelfContained 分支,只保留单文件运行时依赖包,脚本与文档同步
- 新增 tools/unblock-publish.ps1,清除发布产物的网络来源标记

测试
- 新增登录自启任务 XML 用例与“任务缺失按 HRESULT 判定”回归测试(46 → 47),探测 16/16
This commit is contained in:
chuzhongzai
2026-10-24 01:32:03 +08:00
parent 2d85527656
commit 7be117057a
23 changed files with 1549 additions and 217 deletions
@@ -0,0 +1,67 @@
using System.Xml.Linq;
using GatewayKeepalive.Windows;
namespace GatewayKeepalive.Tests;
/// <summary>
/// 登录自启任务的定义完全靠 XML 表达,错了只会在“下次登录”时才暴露。
/// 这里把生成出来的 XML 当场验一遍:启动命令、隐藏启动、最高权限、电源与超时设置。
/// </summary>
internal static class LoginStartupTests
{
private const string SchedulerNamespace = "http://schemas.microsoft.com/windows/2004/02/mit/task";
public static Task LogonTaskXmlAsync()
{
const string executable = @"D:\Program Files\网关 保活\GatewayKeepalive.exe";
var root = XDocument.Parse(LoginStartup.BuildTaskXml(executable)).Root!;
XNamespace ns = SchedulerNamespace;
Check.Equal("Task", root.Name.LocalName, "任务定义的根元素应当是 Task");
Check.Equal(SchedulerNamespace, root.Name.NamespaceName, "任务定义必须使用计划任务的命名空间");
var exec = root.Element(ns + "Actions")?.Element(ns + "Exec");
Check.True(exec is not null, "任务必须有一个启动程序的 Exec 动作");
Check.Equal(executable, exec!.Element(ns + "Command")?.Value, "动作命令必须是当前可执行文件");
Check.Contains("--minimized", exec.Element(ns + "Arguments")?.Value ?? string.Empty, "登录启动必须带 --minimized,不能弹出设置窗口");
Check.Equal(@"D:\Program Files\网关 保活", exec.Element(ns + "WorkingDirectory")?.Value, "工作目录应当跟随可执行文件");
var principal = root.Element(ns + "Principals")?.Element(ns + "Principal");
Check.True(principal is not null, "任务必须声明主体");
Check.Equal("InteractiveToken", principal!.Element(ns + "LogonType")?.Value, "必须以交互令牌运行,托盘才能出现在桌面上");
Check.Equal("HighestAvailable", principal.Element(ns + "RunLevel")?.Value, "必须是最高权限,否则每次登录都会弹 UAC");
Check.False(string.IsNullOrWhiteSpace(principal.Element(ns + "UserId")?.Value), "主体必须绑定当前用户");
var trigger = root.Element(ns + "Triggers")?.Element(ns + "LogonTrigger");
Check.True(trigger is not null, "必须由登录事件触发");
Check.Equal("true", trigger!.Element(ns + "Enabled")?.Value, "登录触发器必须启用");
Check.False(string.IsNullOrWhiteSpace(trigger.Element(ns + "UserId")?.Value), "登录触发器必须绑定当前用户");
var settings = root.Element(ns + "Settings");
Check.True(settings is not null, "任务必须带设置节");
Check.Equal("false", settings!.Element(ns + "DisallowStartIfOnBatteries")?.Value, "电池供电时也要启动");
Check.Equal("false", settings.Element(ns + "StopIfGoingOnBatteries")?.Value, "切换到电池供电时不能结束进程");
Check.Equal("true", settings.Element(ns + "StartWhenAvailable")?.Value, "错过触发时间后应当补跑");
Check.Equal("PT0S", settings.Element(ns + "ExecutionTimeLimit")?.Value, "常驻托盘程序不能设置执行时长上限");
Check.Equal("IgnoreNew", settings.Element(ns + "MultipleInstancesPolicy")?.Value, "不允许重复实例,避免两个托盘互相改写网络");
return Task.CompletedTask;
}
/// <summary>
/// 晚绑定 COM 抛出的异常类型由 CLR 按 HRESULT 翻译:任务不存在时拿到的是 <see cref="FileNotFoundException"/>,
/// 不是 <see cref="System.Runtime.InteropServices.COMException"/>。早先只按类型抓 0x80070002,
/// 于是一次都没抓到——“关闭登录自启”永远在读回时失败,界面报「找不到文件或目录」。
/// </summary>
public static Task TaskMissingDetectionAsync()
{
Check.True(LoginStartup.IsTaskMissing(new FileNotFoundException()), "文件不存在(0x80070002)应当判定为任务不存在");
Check.True(LoginStartup.IsTaskMissing(new DirectoryNotFoundException()), "目录不存在(0x80070003)应当判定为任务不存在");
Check.True(LoginStartup.IsTaskMissing(new System.Runtime.InteropServices.COMException("任务不存在", unchecked((int)0x8004130F))),
"SCHED_E 的任务不存在代码应当被识别");
Check.False(LoginStartup.IsTaskMissing(new System.Runtime.InteropServices.COMException("拒绝访问", unchecked((int)0x80070005))),
"0x80070005 是权限问题,不能静默当成任务不存在");
Check.False(LoginStartup.IsTaskMissing(new InvalidOperationException("别的错误")), "其他异常不能被当成任务不存在");
return Task.CompletedTask;
}
}