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

设置窗口
- 改为独立非模态窗口(进任务栏、可最小化),不再被托盘浮窗挡住导致点不到
- 位置改为按屏幕可用区域居中裁剪,不再默认落在右下角
- 三个页签重构为卡片式布局:统一卡片内边距、页签行独立成行、正文左右留白、
  帮助文字按实际换行测量高度、标题栏与底栏加分隔线
- 直连 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
+11 -4
View File
@@ -24,14 +24,22 @@ Windows 上的主路由 / 旁路由保活工具。旁路由连续检测失败时
## 运行
使用 Windows x64。依赖运行时版需要 .NET 10 Desktop Runtime,独立版包含运行时。托盘模式会请求管理员权限;只读诊断和预览不提权。
使用 Windows x64 与 .NET 10 Desktop Runtime(需自行安装;发布只提供依赖运行时版)。托盘模式会请求管理员权限;只读诊断和预览不提权。
首次运行会看到两次系统提示,都是 Windows 自己弹的,不是程序重复启动:
1. “未知发布者 / 安全警告”:exe 没有代码签名。点“运行”即可;想少一次提示可以先跑 `.\tools\unblock-publish.ps1` 去掉“来自网络”标记,或者给 exe 做代码签名。
2. 用户账户控制(UAC):改路由和 DNS 需要管理员权限。拒绝提权后就无法切换网络。
手动启动时一定会弹出面板(登录任务用 `--minimized` 启动,只驻留托盘),不会再让人以为程序没启动而反复双击。
- 左键图标:显示/隐藏面板;Esc 或点外部:收起面板。
- 自动:按连续阈值和冷却切换;直连/旁路由:固定模式,失败时提示。
- 暂停:继续监测,停止自动改网络。
- 恢复:恢复接管前快照并暂停。存在未完成切换时,先处理那次切换的恢复。
- 默认退出保留当前网络;“退出时恢复”可在设置中开启。
- 登录后启动:创建当前用户的交互式最高权限计划任务,程序路径移动后重新设置。
- 设置是独立窗口:在任务栏有按钮,居中打开不会被托盘浮窗挡住;设置开着时点“退出程序”,会先关掉设置再继续退出。
- 登录后启动:创建当前用户的交互式最高权限计划任务(`schtasks` 写入任务 XML,失败回退任务计划 COM,两条路径都读回校验)。程序换了位置后,下次以管理员身份启动会自动把任务改写为新路径,并写日志。
首次设置的示例网关为 `192.168.6.1` / `192.168.6.2`,SOCKS5 端口为 `1070`,请按实际网络调整。旁路由探测默认使用 `www.google.com`;直连探测另用 `www.baidu.com`、`www.microsoft.com`,避免直连无法访问代理目标导致错误回滚。默认各要求至少一个目标成功。
@@ -41,8 +49,7 @@ Windows 上的主路由 / 旁路由保活工具。旁路由连续检测失败时
```powershell
.\build.ps1 -Test
.\build.ps1 -Test -Publish # 依赖运行时版
.\build.ps1 -Test -Publish -SelfContained # 包含运行时版
.\build.ps1 -Test -Publish
```
脚本优先使用 `$env:GATEWAY_DOTNET`、`$env:USERPROFILE\.dotnet-sdk\dotnet.exe`,其次使用 PATH 中的 dotnet。产物在 `artifacts/publish/`,编译与测试不会修改系统网络。
+5 -7
View File
@@ -1,7 +1,6 @@
param(
[switch]$Test,
[switch]$Publish,
[switch]$SelfContained
[switch]$Publish
)
$ErrorActionPreference = 'Stop'
$sdkCandidate = Join-Path $env:USERPROFILE '.dotnet-sdk\dotnet.exe'
@@ -20,15 +19,14 @@ try {
}
}
if ($Publish) {
$flavor = if ($SelfContained) { 'standalone' } else { 'runtime-dependent' }
$destination = Join-Path $PSScriptRoot "artifacts\publish\$flavor"
$contained = if ($SelfContained) { 'true' } else { 'false' }
& $dotnet publish (Join-Path $PSScriptRoot 'src\GatewayKeepalive.Tray\GatewayKeepalive.Tray.csproj') -c Release -r win-x64 --self-contained $contained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=embedded -o $destination
$destination = Join-Path $PSScriptRoot 'artifacts\publish'
& $dotnet publish (Join-Path $PSScriptRoot 'src\GatewayKeepalive.Tray\GatewayKeepalive.Tray.csproj') -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true -p:DebugType=embedded -p:NuGetAudit=false -o $destination
if ($LASTEXITCODE -ne 0) { throw '托盘版发布失败。' }
$cli = Join-Path $destination 'diagnostics'
& $dotnet publish (Join-Path $PSScriptRoot 'src\GatewayKeepalive.Cli\GatewayKeepalive.Cli.csproj') -c Release -r win-x64 --self-contained $contained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=embedded -o $cli
& $dotnet publish (Join-Path $PSScriptRoot 'src\GatewayKeepalive.Cli\GatewayKeepalive.Cli.csproj') -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true -p:DebugType=embedded -p:NuGetAudit=false -o $cli
if ($LASTEXITCODE -ne 0) { throw '只读诊断工具发布失败。' }
Write-Host "发布完成:$destination"
Write-Host '目标机器需要安装 .NET 10 桌面运行时(Desktop Runtime x64)。'
}
}
finally {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 75 KiB

+90 -4
View File
@@ -1,8 +1,94 @@
# 验证记录
日期:2026-10-23。环境:Windows 10/11 x64,开发机网卡 `以太网`(索引 16),IPv4 地址 `192.168.6.119/24`,主路由 `192.168.6.1`。
日期:2026-10-23(初版)、2026-10-24(第二轮修复)。环境:Windows 10/11 x64,开发机网卡 `以太网`(索引 16),IPv4 地址 `192.168.6.119/24`,主路由 `192.168.6.1`。
## 已实际验证
## 第七轮修复(2026-10-24,关闭登录自启必报错:按 HRESULT 而不是按异常类型判断)
症状:点「登录后自动启动」必弹「开机自启设置失败:找不到文件或目录:系统找不到指定的文件。(0x80070002) 已回滚到原设置。」,这个开关因此永远关不掉。
日志给出的因果链(01:20:55):`SetEnabled(false)` → `RemoveTask()` 删掉任务 → 读回状态 `QueryEnabled()` → 抛 `System.IO.FileNotFoundException 0x80070002` → 控制器记失败 → 托盘回滚成“已启用” → 界面报错、任务依旧在。
真因:晚绑定 COM(`dynamic`)抛出的异常类型是 CLR 按 HRESULT 翻译过的——任务不存在时拿到的是 `FileNotFoundException`,不是 `COMException`。原代码三处 `catch (COMException) when (HResult == 0x80070002)` 因此一次都没命中,「任务不存在」被当成真错误抛到了界面。本机实测确认:`GetFolder('\').GetTask('不存在的名字')` → `System.IO.FileNotFoundException / 0x80070002`。
修法:新增 `LoginStartup.IsTaskMissing(Exception)`,只认 HResult(0x80070002 / 0x80070003 / 0x8004130F),三处捕获统一改成 `catch (Exception) when (IsTaskMissing(e))`:删任务、读任务动作、读回状态。
本轮验证:
- 新增回归测试「missing-task detection follows HRESULT, not exception type」:0x80070002 / 0x80070003 / 0x8004130F 三种判定为“任务不存在”,0x80070005(拒绝访问)必须继续上报。本地测试总数 46 → 47,全绿;探测离线测试 16/16。
- Release 构建 0 警告 0 错误(`TreatWarningsAsErrors` 开启);重新发布 `artifacts\publish\GatewayKeepalive.exe`(545,472 字节)。
- **未验证**:真正的“关 → 开 → 关”需要提权点击,命令行侧没有管理员权限,只能由用户在托盘里点一次确认。
## 第六轮修复(2026-10-24,页签不重绘的真因、自启报错可诊断)
- **页签“同时激活”的真因**:`PanelChip.Selected` / `Emphasized` 是自动属性,赋值时既不 `Invalidate()` 也不 `UpdateStyles()`。切页只是把布尔值改了,像素还停在上一次:看上去两个页签都亮着,直到鼠标移进旧页签触发悬停重绘才“追上”状态——这正是“鼠标放上去才更新”。第五轮以为是配色太接近,只改了外观,所以问题照旧。现在两个属性改成显式字段 + 值变化时 `Invalidate()`,`SelectTab` 再对全部页签补一次重绘,切页即刻同步。
- **自启报错为什么一直查不到**:`ToggleAutostart` 失败只弹提示、不写日志,日志里没有任何证据。现在四处留痕:控制器 `AutostartEnabled` / `SetAutostart` 记录成功与失败(含异常全文)、`ReportError` 把界面上每条错误写进日志、`SetEnabled` 入口记录「启用方向 + 是否管理员 + 进程路径」、启动时写一行 `启动自检:管理员=… 登录自启=… 进程=… 参数=…`。下次再报错,日志里直接就有原因。
- **`--preview` 输出目录**:目标目录不存在时先创建,不再静默地什么都不生成。
本轮验证:
- Release 构建 0 警告 0 错误(`TreatWarningsAsErrors` 开启);核心测试 46/46、探测离线测试 16/16。
- 重新发布 `artifacts\publish\GatewayKeepalive.exe`(545,472 字节)+ `diagnostics/`;`--preview` 指向一个不存在的目录也能正常产出 10 张图。
- **未验证**:页签切换需要真人在新进程里点一次——离屏渲染每次整窗重绘,天生看不到“该重绘却没重绘”这类问题;自启报错的具体原因要等下一次点击后看日志。
## 第五轮修复(2026-10-24,页签单选语义、滚轮归属、自启状态读回)
- **页签“同时激活”**:选中态原来是半透明强调底 + 彩色描边、深色字没变,和悬停态(只换底色、文字变亮)几乎一样——鼠标一经过,就有两个页签看起来都是“当前页”。现在选中项是实心 Accent 底 + 深色字(和“保存”主按钮同一套),悬停只给一层很浅的中性底、永不用强调色,未选中项既无底色也无描边。单选语义靠形状和颜色一起表达,不靠深浅差别。**注意:配色不是真因,见第六轮。**
- **鼠标停在输入框上页面滚不动**:滚轮让页逻辑原来只挂在多行框上,单行框(网关、DNS、端口、最少成功数)照样把滚轮吃掉。现在任何输入框“自己滚不动”时都把滚轮让给外层页面;页面本来不需要滚动时不吃事件,继续向上冒泡。
- **自启状态读错**:任务计划 COM 偶尔读不到刚注册好的任务(同一台机器上 `schtasks` 却看得见),界面就把“已启用”显示成关闭,用户再保存一次又会重复注册任务。现在 COM 读不到时用 `schtasks /Query /TN … /XML` 复核;两条路都读不到才报错,并且同一个失败签名只往日志里写一次,不再每个检查周期刷屏。
- **启动时的两次提示**:第一次是 exe 带“来自网络”标记时 Windows 的“未知发布者”安全警告——当前发布目录里的 exe 已无该标记(`Get-Item -Stream *` 只有 `:$DATA`),发布脚本产出的是本机新建文件,不会再带;第二次是 UAC,改路由和 DNS 必须提权,系统强制,程序无法绕过。
本轮验证:
- Release 构建 0 警告 0 错误(`TreatWarningsAsErrors` 开启)。
- 核心测试 46/46、探测离线测试 16/16。本次运行环境不再限制文件写入,此前因沙箱拒绝 `File.Replace` 而失败的 24 项全部通过。
- 重新发布 `artifacts\publish\GatewayKeepalive.exe`(544,960 字节)+ `diagnostics/`;`--preview` 出 10 张图,三页设置与托盘面板逐张核对,`docs/screenshots/` 已更新。
- **未验证**:页签悬停态与输入框上的滚轮需要真人用鼠标确认(离屏渲染只能看到未悬停的画面)。
## 第四轮修复(2026-10-24,独立复查后加固)
一次只读代码复查(未看本轮改动过程)在本轮改动里找出了四处真实缺陷,已全部修掉:
- 保存失败会留下一个关不掉的窗口:`SettingsForm.SaveAsync` 出错时把 `_closing` 留在 `true`,之后的关闭按钮和 Esc 都不再响应。改为保存失败时复位标志;窗口/页面已释放时(保存回调可能在 `Dispose` 之后触发)所有 UI 更新都先判 `IsDisposed`。
- 自绘画笔泄漏:`PanelChip` 选中态背景的 `SolidBrush` 没释放,每次重绘漏一个 GDI 句柄(面板常驻,长时间运行会累积)。
- 滚轮“跳一格再拖会弹回”:`DarkScrollBar` 页跳后没有重置拖动基准,拖动被算成从跳转前的位置开始。页跳后同步基准值。
- 多行输入框吞掉滚轮:文本框能滚就归它,滚不动时把滚轮让给外层页面(`FieldBox` → `DarkScrollPanel`,同一档步长),不会再出现“鼠标停在多行框上页面滚不动”。
- 另外一并加固:`schtasks` 改为并发读取 stdout/stderr 后再等退出(顺序 `ReadToEnd` 会被写满的 stderr 缓冲卡住,20 秒超时形同虚设);读取任务动作时释放全部 COM RCW;`RepairIfStale` 除了比对路径也比对 `--minimized` 参数;校正任务改为后台线程执行,不再拖慢托盘点亮;页面栈在布局时清理已释放的项;`WrapTextLabel` 改用 `TextRenderer` 绘制并用 `TextRenderer.MeasureText` 量高(绘制与测量同一条路径)。
本轮验证:
- Release 构建 0 警告、0 错误(`TreatWarningsAsErrors` 开启)。
- 新增回归测试「登录自启任务 XML」:断言动作命令、`--minimized`、工作目录、`InteractiveToken`、`HighestAvailable`、电源与超时设置、单实例策略。本地测试总数 45 → 46。
- 探测离线测试 16/16;核心测试在本机沙箱内 22 通过 / 24 失败,失败项与第二轮完全相同(沙箱拒绝 `File.Replace` 等文件写入),新增用例通过。
- 发布:单文件约 543 KB + `diagnostics/`;已发布 exe 的 `--preview` 输出 10 张界面图,只读诊断 `--diagnose` 返回 `readOnly=true`、网关 `192.168.6.1`、接口索引 16。
- 界面:三页设置与托盘面板重新渲染后逐张核对,截图已更新到 `docs/screenshots/`。
## 第三轮修复(2026-10-24,界面细节与启动体验)
- 页签误判“同时激活”:`PanelChip` 未选中时改成既无底色也无描边(只有悬停/按下才浮现),选中项才有强调底色与强调色文字,单选语义在视觉上一眼可辨。
- 设置窗口进入任务栏:改为非模态独立窗口(`Show()` + `ShowInTaskbar=true`),不再依附托盘浮窗;打开设置也不再让整个程序处于忙碌状态。
- 启动提示与启动反馈:手动启动(非 `--minimized`)一定弹出面板,避免“以为没启动→反复双击→反复弹 UAC”;新增 `tools/unblock-publish.ps1` 清除发布产物的“来自网络”标记,README 说明两次系统提示的来源(未签名安全警告 + UAC 提权)。
- 多行输入框与滚轮:`FieldBox` 只在内容真的放不下时才挂竖条;`DarkScrollPanel` 命中“有竖条的多行框”时把 `WM_MOUSEWHEEL` 转发给它,页面不需要滚动时不再吞掉滚轮事件。
- 复查:Release 构建 0 警告 0 错误;预览重新渲染后逐张核对(三页页签、探测目标多行框不再出现残留方块)。
## 第二轮修复(2026-10-24)
针对反馈的五个问题:
- 设置界面重做:左侧分段标签改成自适应宽度的小页签,卡片圆角/内边距/行距统一,输入框改为自绘圆角容器(去掉系统浅色边框与原生滚动条),复选框改为自绘勾选行,标题区与底栏各有一条分隔线,窗口默认 720×620 并随 DPI 缩放。
- 设置窗口位置:不再 `ShowDialog(owner)` 走 `CenterParent`(原先会被托盘浮窗挤到屏幕右下角并压在浮窗下面),改为按鼠标所在屏幕居中,`OnShown` 再校正一次。
- 设置打开时可以退出:点“退出程序”会先请求设置窗口关闭,`OpenSettingsAsync` 结束后自动继续退出流程;保存进行中则等保存完成后再退出。
- 登录自启重写:任务 XML 交 `schtasks /Create /XML` 创建,失败回退任务计划 COM;两条路径都在创建后读回 `Enabled` 校验,任一失败都会带上系统返回原文报错并写日志。启动时若发现任务指向的程序路径与当前可执行文件不一致,会自动改写(需管理员)。
- 发布只看依赖运行时版:`build.ps1` 去掉 `-SelfContained`,只产出单文件 + `diagnostics/`,发布命令加 `-p:NuGetAudit=false`(本项目零外部包,离线也能发布)。
本轮验证:
- Release 构建 0 警告、0 错误。
- 探测离线测试 16/16 通过。
- 核心测试在本机沙箱内 21 通过 / 24 失败:失败项均为 `UnauthorizedAccessException`/`IOException` 发生在 `File.Replace` 等文件写入路径上,是当前受限运行环境拒绝文件替换导致,与本轮改动无关;在正常桌面会话下需复跑确认。
- 发布:`artifacts/publish/GatewayKeepalive.exe`(约 524 KB 单文件)与 `diagnostics/GatewayKeepalive.Cli.exe` 均正常;托盘 exe `--preview` 输出 10 张界面图,只读诊断 `--help` 正常。
- 界面:三页设置与托盘面板四态均重新渲染并逐张检查,截图已更新到 `docs/screenshots/`。
## 已实际验证(第一轮,2026-10-23)
- 解决方案 Debug/Release 构建:0 警告、0 错误(`TreatWarningsAsErrors` 已开启)。
- 核心离线测试:45/45 通过,覆盖状态机计数与冷却、切换故障补偿、外部修改保护、跨网络恢复拒绝、无变化跳过写入、持久的暂停状态、Stop 恢复基线、写入前现场复核、原子 JSON 持久化与原生结构布局。
@@ -10,7 +96,7 @@
- 只读诊断:成功读取网卡、IPv4 默认路由、DNS 配置和有效选路;`NativeLayout` 为 104/168/64 字节,路由 metric 偏移 84,接口 metric 偏移 148,与 x64 SDK 结构一致。运行前后系统网关保持 `192.168.6.1`。
- 切换计划预演(`--dry-run --proxy`):生成目标 metric 1 的旁路由默认路由,原主路由降为 metric 101,DNS 指向 `192.168.6.2`,未写系统。
- 真实分层探测:主路由 TLS 出口 2/2 通过;`192.168.6.2` 的 TCP/DNS/SOCKS 探测超时,判为旁路由不可用,符合当前设备实际状态。
- 发布包:依赖运行时单文件约 490 KB,独立单文件约 116 MB;两者托盘 `--preview` 均能启动并输出 10 张界面图,无异常输出;独立版只读诊断通过。
- 发布包:单文件约 524 KB(依赖 .NET 10 桌面运行时);托盘 `--preview` 能启动并输出 10 张界面图,无异常输出;只读诊断通过。
- 界面预览渲染:主面板正常/故障回退/未配置、诊断展开,设置三页及滚动后状态,均在 200% 缩放下检查过无裁切、无浅色系统控件。
## 未验证,需真机确认
@@ -20,7 +106,7 @@
- 实际切换到旁路由并读回路由/DNS/出口;旁路由断开后的自动回退;恢复后回切旁路由。
- 旁路由 DNS 正常但默认转发失效、SOCKS5 正常但转发失效等不对称故障。
- VPN 或其他网卡同时持有默认路由、DHCP 续租、拔插网线、休眠唤醒后的行为。
- 登录后最高权限计划任务的创建、注销/重启后启动,以及程序移动路径后的重新设置。
- 登录后最高权限计划任务的创建、注销/重启后启动(本轮重写后仍需要真机管理员账号确认一次)。
- 崩溃后重启识别未完成切换日志的实际恢复流程。
首次在目标机启用前,建议先运行发布的 `diagnostics/GatewayKeepalive.Cli.exe --diagnose`,确认网卡 GUID、主/旁路由和 DNS 与计划一致,再用 `--dry-run` 核对计划,最后通过托盘界面保存并手动启用自动保活。
+30 -2
View File
@@ -26,7 +26,19 @@ public sealed class GatewayController : ITrayController
public AppSettings Settings => _settings;
public AppStatus Status => _status;
public string DataDirectory => _store.DirectoryPath;
public bool AutostartEnabled => _getAutostart();
public bool AutostartEnabled
{
get
{
try { return _getAutostart(); }
catch (Exception e)
{
Log("控制器读取登录自启状态失败:" + e);
throw;
}
}
}
public event Action<AppStatus>? StatusChanged;
public GatewayController(INetworkBackend backend, IHealthProbe probe, JsonStore store, Action<string> log, Func<bool>? getAutostart = null, Action<bool>? setAutostart = null)
@@ -59,7 +71,23 @@ public sealed class GatewayController : ITrayController
}
public Task<IReadOnlyList<AdapterInfo>> ListAdaptersAsync() => Task.Run(() => _backend.ListAdaptersAsync(_stop.Token));
public void SetAutostart(bool enabled) => _setAutostart(enabled);
/// <summary>给界面层一个写日志的入口:界面上报的错也要能在日志里查到。</summary>
public void AppendLog(string text) => Log(text);
public void SetAutostart(bool enabled)
{
try
{
_setAutostart(enabled);
Log("控制器设置登录自启成功:启用=" + (enabled ? "是" : "否"));
}
catch (Exception e)
{
Log("控制器设置登录自启失败:启用=" + (enabled ? "是" : "否") + " " + e);
throw;
}
}
public async Task RefreshAsync()
{
+1
View File
@@ -109,6 +109,7 @@ public interface ITrayController
AppStatus Status { get; }
string DataDirectory { get; }
bool AutostartEnabled { get; }
void AppendLog(string text);
event Action<AppStatus>? StatusChanged;
Task<IReadOnlyList<AdapterInfo>> ListAdaptersAsync();
Task RefreshAsync();
+33 -3
View File
@@ -16,6 +16,7 @@ internal static class Program
{
var index = Array.IndexOf(args, "--preview");
var path = index + 1 < args.Length ? args[index + 1] : Path.Combine(AppContext.BaseDirectory, "preview");
Directory.CreateDirectory(path);
TrayApplicationContext.RenderPreview(path);
return 0;
}
@@ -37,7 +38,19 @@ internal static class Program
Process.Start(start);
return 0;
}
catch (Win32Exception e) when (e.NativeErrorCode == 1223) { return 1; }
catch (Win32Exception e) when (e.NativeErrorCode == 1223)
{
// 用户取消了 UAC:明确说清楚,否则看起来就是“双击了但什么都没发生”。
MessageBox.Show("网关保活需要管理员权限才能修改路由和 DNS。\n本次提权被取消,程序没有启动。", "网关保活",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return 1;
}
catch (Win32Exception e)
{
MessageBox.Show("无法以管理员权限重新启动:" + e.Message, "网关保活",
MessageBoxButtons.OK, MessageBoxIcon.Error);
return 1;
}
}
using var identity = WindowsIdentity.GetCurrent();
@@ -50,10 +63,27 @@ internal static class Program
try
{
var store = new JsonStore(directory);
var startup = new LoginStartup();
var startup = new LoginStartup(logger.Write);
// 每次启动先留一行自检:出问题时光看日志就知道是不是提权/自启任务的问题。
try
{
logger.Write($"启动自检:管理员={backend.IsAdministrator} 登录自启={startup.IsEnabled} 进程={Environment.ProcessPath} 参数={(args.Length == 0 ? "(无)" : string.Join(' ', args))}");
}
catch (Exception exception)
{
logger.Write("启动自检失败:" + exception.Message);
}
// 校正旧任务指向要连 COM、必要时还会拉起 schtasks,放到后台做,别让托盘图标准备好之前先卡住。
_ = Task.Run(() =>
{
try { startup.RepairIfStale(); }
catch (Exception exception) { logger.Write("校正登录自启任务失败:" + exception); }
});
var probe = new NetworkHealthProbe(backend.ValidateRoute);
var controller = new GatewayController(backend, probe, store, logger.Write, () => startup.IsEnabled, startup.SetEnabled);
using var app = new TrayApplicationContext(controller, !controller.Settings.Configured || !args.Contains("--minimized"));
// 手动启动(不是登录任务、也没带 --minimized)就一定要有可见反馈:
// 只留一个托盘图标会让人以为程序没起来,于是反复双击、反复弹 UAC。
using var app = new TrayApplicationContext(controller, !args.Contains("--minimized"));
using var dispatcher = new Control();
_ = dispatcher.Handle;
var wait = ThreadPool.RegisterWaitForSingleObject(show, (_, _) =>
@@ -32,6 +32,7 @@ public sealed class TrayApplicationContext : ApplicationContext
private AppStatus _status = AppStatus.Initial;
private SettingsForm? _settingsForm;
private bool _exitPending;
private bool _busy;
private bool _exitInProgress;
private bool _disposed;
@@ -331,7 +332,6 @@ public sealed class TrayApplicationContext : ApplicationContext
}
_busy = true;
_panel.SetBusy(true);
var previousAutoHide = _panel.AllowAutoHide;
_panel.AllowAutoHide = false;
try
@@ -349,11 +349,18 @@ public sealed class TrayApplicationContext : ApplicationContext
return;
}
using var form = new SettingsForm(_controller, settings, adapters);
var form = new SettingsForm(_controller, settings, adapters);
_settingsForm = form;
var owner = _panel.Visible ? _panel : null;
form.ShowDialog(owner);
ApplyStatus(ReadStatus(_status));
var closed = new TaskCompletionSource();
form.FormClosed += (_, _) => closed.TrySetResult();
// 先收起托盘浮窗:它是最顶层窗口,留在屏幕上会盖住设置窗口。
_panel.HidePanel();
// 非模态独立窗口:会出现在任务栏,可以随时切回来;也不再被托盘浮窗当成 owner 挤到角落。
form.Show();
form.Activate();
await closed.Task;
form.Dispose();
if (!_disposed) ApplyStatus(ReadStatus(_status));
}
catch (Exception exception)
{
@@ -367,7 +374,12 @@ public sealed class TrayApplicationContext : ApplicationContext
{
_busy = false;
_panel.SetBusy(false);
if (_panel.Visible) _panel.Activate();
if (_exitPending)
{
// 用户在设置窗口打开期间点了退出:窗口已经关掉,接着把退出流程走完。
_exitPending = false;
_dispatcher.BeginInvoke((Action)(() => _ = ExitAsync()));
}
}
}
}
@@ -446,7 +458,10 @@ public sealed class TrayApplicationContext : ApplicationContext
if (_disposed || _exitInProgress) return;
if (_settingsForm is not null)
{
ReportError("暂时无法退出", "请先关闭设置窗口,再退出程序。");
// 设置窗口还开着:先把它关掉,OpenSettingsAsync 收尾后会接着把退出流程走完。
_exitPending = true;
try { _settingsForm.Close(); }
catch (Exception exception) when (exception is ObjectDisposedException or InvalidOperationException) { }
return;
}
if (_busy)
@@ -494,6 +509,8 @@ public sealed class TrayApplicationContext : ApplicationContext
private void ReportError(string title, string message)
{
if (_disposed) return;
// 界面上只要报了错,日志里就必须有对应的一条:否则用户只能描述“有报错”,无法定位。
_controller.AppendLog($"界面报错[{title}]:{message}");
_panel.ShowNotice(message, true);
if (_panel.Visible)
{
@@ -772,6 +789,7 @@ public sealed class TrayApplicationContext : ApplicationContext
public Task RestoreAsync() => Task.CompletedTask;
public Task StopAsync() => Task.CompletedTask;
public void SetAutostart(bool enabled) { }
public void AppendLog(string text) { }
}
private sealed class DarkMenuColors : ProfessionalColorTable
+611 -60
View File
@@ -10,6 +10,8 @@ internal sealed class PanelChip : Control
{
private bool _hovered;
private bool _pressed;
private bool _selected;
private bool _emphasized;
private Color _accent = UiTheme.Accent;
public PanelChip()
@@ -25,16 +27,39 @@ internal sealed class PanelChip : Control
[Browsable(false)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public bool Selected { get; set; }
public bool Selected
{
get => _selected;
set
{
if (_selected == value) return;
_selected = value;
Invalidate();
}
}
[Browsable(false)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public bool Emphasized { get; set; }
public bool Emphasized
{
get => _emphasized;
set
{
if (_emphasized == value) return;
_emphasized = value;
Invalidate();
}
}
[Browsable(false)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public bool Ghost { get; set; }
/// <summary>未选中时不铺底色(分段标签用),避免一排实心方块压过页面内容。</summary>
[Browsable(false)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public bool Quiet { get; set; }
[Browsable(false)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public int CornerRadiusDip { get; set; } = 8;
@@ -62,25 +87,57 @@ internal sealed class PanelChip : Control
var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1));
var radius = UiTheme.S(this, CornerRadiusDip);
var fill = Ghost && !_hovered && !_pressed && !Selected && !Emphasized
? UiTheme.Background
: Emphasized
? UiTheme.WithAlpha(AccentColor, 54)
: Selected
? UiTheme.WithAlpha(AccentColor, 38)
: _pressed ? UiTheme.CardPressed : _hovered ? UiTheme.CardHover : UiTheme.Card;
var border = Ghost && !_hovered && !Selected && !Emphasized
? UiTheme.Background
: Selected || Emphasized
? UiTheme.WithAlpha(AccentColor, 165)
: _hovered ? UiTheme.Border : UiTheme.BorderSoft;
UiTheme.FillRounded(graphics, bounds, radius, fill);
UiTheme.StrokeRounded(graphics, bounds, radius, border, UiTheme.SF(this, 1f));
// 页签是单选语义,而且“当前页”必须一眼认出来:选中项用实心 Accent 底 + 深色字(和主按钮同一套),
// 未选中项既无底色也无描边。此前的半透明底 + 彩色描边和“悬停/按下”长得很像,
// 一悬停就出现两个看起来都被激活的页签。
var flat = Quiet || Ghost;
Color fill;
Color border;
var selectedInk = false;
if (Selected)
{
fill = _pressed ? ControlPaint.Dark(AccentColor, 0.10f)
: _hovered ? ControlPaint.Light(AccentColor, 0.14f)
: AccentColor;
border = fill;
selectedInk = true;
}
else if (Emphasized)
{
fill = UiTheme.WithAlpha(AccentColor, 54);
border = UiTheme.WithAlpha(AccentColor, 150);
}
else if (_pressed)
{
fill = UiTheme.CardPressed;
border = flat ? Color.Transparent : UiTheme.Border;
}
else if (_hovered)
{
// 悬停只给一层很浅的中性底,永不用强调色:否则“悬停项”和“选中项”会互相冒充。
fill = UiTheme.CardHover;
border = flat ? Color.Transparent : UiTheme.Border;
}
else if (flat)
{
fill = Color.Transparent;
border = Color.Transparent;
}
else
{
fill = UiTheme.Card;
border = UiTheme.BorderSoft;
}
if (fill.A > 0) UiTheme.FillRounded(graphics, bounds, radius, fill);
if (border.A > 0) UiTheme.StrokeRounded(graphics, bounds, radius, border, UiTheme.SF(this, 1f));
var foreground = !Enabled ? UiTheme.Faint
: Selected || Emphasized ? AccentColor
: Ghost ? UiTheme.Muted : UiTheme.Text;
: selectedInk ? UiTheme.Background
: Emphasized ? AccentColor
: flat ? (_hovered || _pressed ? UiTheme.Text : UiTheme.Muted)
: UiTheme.Text;
UiTheme.DrawText(graphics, Text, Font ?? UiTheme.Body, foreground,
Rectangle.Inflate(bounds, -UiTheme.S(this, 5), 0),
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
@@ -154,33 +211,443 @@ internal sealed class PanelChip : Control
}
}
internal sealed class ThemeButton : Button
/// <summary>
/// 圆角按钮。系统 Button 只会画方角并用系统主题色做悬停,深色界面里会露出浅色边缘,
/// 所以整块自绘;仍然实现 IButtonControl,可以继续当 AcceptButton/CancelButton 用。
/// </summary>
internal sealed class ThemeButton : Control, IButtonControl
{
private readonly bool _primary;
private bool _hovered;
private bool _pressed;
private bool _default;
public ThemeButton(bool primary)
{
_primary = primary;
AutoSize = true;
AutoSizeMode = AutoSizeMode.GrowAndShrink;
MinimumSize = new Size(82, 36);
Padding = new Padding(14, 4, 14, 4);
Margin = new Padding(6, 4, 0, 4);
FlatStyle = FlatStyle.Flat;
FlatAppearance.BorderSize = 1;
FlatAppearance.BorderColor = primary ? UiTheme.Accent : UiTheme.Border;
FlatAppearance.MouseOverBackColor = primary ? Color.FromArgb(0x5D, 0xE2, 0xBD) : UiTheme.CardHover;
FlatAppearance.MouseDownBackColor = primary ? Color.FromArgb(0x3D, 0xBE, 0x9A) : UiTheme.CardPressed;
BackColor = primary ? UiTheme.Accent : UiTheme.Card;
ForeColor = primary ? UiTheme.Background : UiTheme.Text;
Font = UiTheme.BodyBold;
UseVisualStyleBackColor = false;
UseMnemonic = false;
TextAlign = ContentAlignment.MiddleCenter;
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw |
ControlStyles.SupportsTransparentBackColor | ControlStyles.Selectable, true);
TabStop = true;
Cursor = Cursors.Hand;
AccessibleRole = AccessibleRole.PushButton;
BackColor = Color.Transparent;
Font = UiTheme.BodyBold;
Margin = new Padding(8, 0, 0, 0);
ApplyMetrics();
}
public bool Primary => _primary;
[Browsable(false)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public DialogResult DialogResult { get; set; }
public void NotifyDefault(bool value)
{
if (_default == value) return;
_default = value;
Invalidate();
}
public void PerformClick()
{
if (Enabled) OnClick(EventArgs.Empty);
}
private void ApplyMetrics()
{
Height = UiTheme.S(this, 32);
var text = TextRenderer.MeasureText(Text, Font).Width;
MinimumSize = new Size(UiTheme.S(this, 84), Height);
Width = Math.Max(MinimumSize.Width, text + UiTheme.S(this, 28));
}
protected override void OnHandleCreated(EventArgs e)
{
base.OnHandleCreated(e);
ApplyMetrics();
}
protected override void OnTextChanged(EventArgs e)
{
base.OnTextChanged(e);
if (IsHandleCreated) ApplyMetrics();
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
var graphics = e.Graphics;
UiTheme.Configure(graphics);
graphics.Clear(Parent?.BackColor ?? UiTheme.Background);
var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1));
var radius = UiTheme.S(this, 7);
Color fill, border, ink;
if (!Enabled)
{
fill = UiTheme.Card;
border = UiTheme.BorderSoft;
ink = UiTheme.Faint;
}
else if (_primary)
{
fill = _pressed ? ControlPaint.Dark(UiTheme.Accent, 0.10f)
: _hovered ? ControlPaint.Light(UiTheme.Accent, 0.14f)
: UiTheme.Accent;
border = fill;
ink = UiTheme.Background;
}
else
{
fill = _pressed ? UiTheme.CardPressed : _hovered ? UiTheme.CardHover : UiTheme.Card;
border = _hovered ? UiTheme.Border : UiTheme.BorderSoft;
ink = UiTheme.Text;
}
UiTheme.FillRounded(graphics, bounds, radius, fill);
UiTheme.StrokeRounded(graphics, bounds, radius, border, UiTheme.SF(this, 1f));
UiTheme.DrawText(graphics, Text, Font ?? UiTheme.BodyBold, ink,
Rectangle.Inflate(bounds, -UiTheme.S(this, 6), 0),
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
if ((_default || Focused) && ShowFocusCues && Enabled)
{
var focus = Rectangle.Inflate(bounds, -UiTheme.S(this, 3), -UiTheme.S(this, 3));
ControlPaint.DrawFocusRectangle(graphics, focus, _primary ? UiTheme.Background : UiTheme.Text, fill);
}
}
protected override void OnMouseEnter(EventArgs e)
{
_hovered = true;
Invalidate();
base.OnMouseEnter(e);
}
protected override void OnMouseLeave(EventArgs e)
{
_hovered = false;
if (_pressed)
{
_pressed = false;
Capture = false;
}
Invalidate();
base.OnMouseLeave(e);
}
protected override void OnMouseDown(MouseEventArgs e)
{
base.OnMouseDown(e);
if (e.Button != MouseButtons.Left || !Enabled) return;
_pressed = true;
Capture = true;
Focus();
Invalidate();
}
protected override void OnMouseUp(MouseEventArgs e)
{
base.OnMouseUp(e);
if (e.Button != MouseButtons.Left || !_pressed) return;
_pressed = false;
Capture = false;
Invalidate();
if (ClientRectangle.Contains(e.Location) && Enabled) PerformClick();
}
protected override bool IsInputKey(Keys keyData) =>
keyData is Keys.Enter or Keys.Space || base.IsInputKey(keyData);
protected override void OnKeyDown(KeyEventArgs e)
{
if (e.KeyCode is Keys.Enter or Keys.Space)
{
PerformClick();
e.Handled = true;
return;
}
base.OnKeyDown(e);
}
protected override void OnEnabledChanged(EventArgs e)
{
Cursor = Enabled ? Cursors.Hand : Cursors.Default;
Invalidate();
base.OnEnabledChanged(e);
}
}
/// <summary>
/// 圆角输入框外壳:里面放一个无边框 TextBox,自己画圆角、边框和聚焦高亮,
/// 避免系统 TextBox 的方角与固定 1px 边线在深色卡片上显得像“一堆临时控件”。
/// </summary>
internal sealed class FieldBox : Panel
{
private readonly bool _multiline;
private bool _hovered;
public FieldBox(TextBox inner, bool multiline)
{
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw |
ControlStyles.SupportsTransparentBackColor, true);
_multiline = multiline;
Inner = inner;
BackColor = Color.Transparent;
Margin = new Padding(0);
Dock = DockStyle.Top;
inner.BorderStyle = BorderStyle.None;
inner.BackColor = UiTheme.Input;
inner.ForeColor = UiTheme.Text;
inner.Font = UiTheme.Body;
inner.Multiline = multiline;
inner.Margin = new Padding(0);
if (multiline)
{
inner.WordWrap = true;
inner.AcceptsReturn = true;
// 只有内容真装不下时才挂竖条,短内容不该出现滚动条,也不该被滚轮接管。
inner.ScrollBars = ScrollBars.None;
inner.TextChanged += (_, _) => UpdateMultilineScrollBars();
inner.SizeChanged += (_, _) => UpdateMultilineScrollBars();
}
// 文本框会自己吞掉滚轮(单行框永远滚不动,也照样吞):滚不动的时候把事件交给外层页面,
// 否则鼠标一停在任何一个输入框上,整页就滚不动了。
inner.MouseWheel += (_, args) => ForwardWheelToPage(args);
inner.GotFocus += (_, _) => Invalidate();
inner.LostFocus += (_, _) => Invalidate();
inner.EnabledChanged += (_, _) => Invalidate();
inner.MouseEnter += (_, _) => { _hovered = true; Invalidate(); };
inner.MouseLeave += (_, _) => { _hovered = false; Invalidate(); };
Controls.Add(inner);
Height = UiTheme.S(this, multiline ? 92 : 32);
}
public TextBox Inner { get; }
/// <summary>多行框真的出现竖条时,滚轮应当先归它,而不是滚动整页。</summary>
public bool AcceptsWheel => _multiline && Inner.ScrollBars == ScrollBars.Vertical;
/// <summary>多行框自己滚不动时,把滚轮让给外层页面(单行框一律让出)。</summary>
private void ForwardWheelToPage(MouseEventArgs args)
{
if (AcceptsWheel || args.Delta == 0) return;
// 页面没滚(内容本来就不高)就别把事件吃掉,让它继续往上传。
if (!DarkScrollPanel.ForwardWheelToPage(this, -args.Delta / 120)) return;
if (args is HandledMouseEventArgs handled) handled.Handled = true;
}
private void UpdateMultilineScrollBars()
{
if (!_multiline || Inner.IsDisposed) return;
var lineHeight = Math.Max(1, Inner.Font.Height);
var contentWidth = Math.Max(1, Inner.ClientSize.Width - UiTheme.S(this, 2));
var visualLines = 0;
foreach (var line in Inner.Lines)
{
var width = TextRenderer.MeasureText(line.Length == 0 ? " " : line, Inner.Font,
new Size(int.MaxValue, int.MaxValue),
TextFormatFlags.NoPadding | TextFormatFlags.SingleLine).Width;
visualLines += Math.Max(1, (int)Math.Ceiling(width / (double)contentWidth));
}
var needed = visualLines * lineHeight + UiTheme.S(this, 6);
var target = needed > Inner.ClientSize.Height ? ScrollBars.Vertical : ScrollBars.None;
if (Inner.ScrollBars == target) return;
Inner.ScrollBars = target;
Invalidate();
}
protected override void OnLayout(LayoutEventArgs e)
{
base.OnLayout(e);
var padX = UiTheme.S(this, 9);
var width = Math.Max(0, Width - padX - UiTheme.S(this, 8));
if (_multiline)
Inner.SetBounds(padX, UiTheme.S(this, 8), width, Math.Max(0, Height - UiTheme.S(this, 16)));
else
{
var height = Math.Min(Inner.PreferredHeight, Math.Max(0, Height - UiTheme.S(this, 6)));
Inner.SetBounds(padX, Math.Max(0, (Height - height) / 2), width, height);
}
UpdateMultilineScrollBars();
}
protected override void OnPaint(PaintEventArgs e)
{
var graphics = e.Graphics;
UiTheme.Configure(graphics);
graphics.Clear(Parent?.BackColor ?? UiTheme.Card);
var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1));
var radius = UiTheme.S(this, 6);
var focused = Inner.Focused;
var enabled = Inner.Enabled;
UiTheme.FillRounded(graphics, bounds, radius, enabled ? UiTheme.Input : UiTheme.Background);
var border = !enabled ? UiTheme.BorderSoft
: focused ? UiTheme.Accent
: _hovered ? UiTheme.Border
: UiTheme.BorderSoft;
UiTheme.StrokeRounded(graphics, bounds, radius, border, UiTheme.SF(this, 1f));
}
protected override void OnMouseEnter(EventArgs e)
{
_hovered = true;
Invalidate();
base.OnMouseEnter(e);
}
protected override void OnMouseLeave(EventArgs e)
{
_hovered = false;
Invalidate();
base.OnMouseLeave(e);
}
protected override void OnMouseDown(MouseEventArgs e)
{
base.OnMouseDown(e);
if (Inner.Enabled) Inner.Focus();
}
}
/// <summary>自绘勾选项:小圆角方框 + 文字,避免系统浅色复选框破坏深色整体。</summary>
internal sealed class CheckLine : Control
{
private bool _checked;
private bool _hovered;
public CheckLine()
{
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw |
ControlStyles.SupportsTransparentBackColor | ControlStyles.Selectable, true);
TabStop = true;
Cursor = Cursors.Hand;
AccessibleRole = AccessibleRole.CheckButton;
BackColor = Color.Transparent;
Font = UiTheme.Body;
Dock = DockStyle.Top;
Height = UiTheme.S(this, 26);
Margin = new Padding(0, 2, 0, 2);
}
[Browsable(false)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public bool Checked
{
get => _checked;
set
{
if (_checked == value) return;
_checked = value;
Invalidate();
CheckedChanged?.Invoke(this, EventArgs.Empty);
}
}
public event EventHandler? CheckedChanged;
protected override void OnPaint(PaintEventArgs e)
{
var graphics = e.Graphics;
UiTheme.Configure(graphics);
graphics.Clear(Parent?.BackColor ?? UiTheme.Card);
var box = UiTheme.S(this, 16);
var top = (Height - box) / 2;
var rect = new Rectangle(0, Math.Max(0, top), box, box);
var radius = UiTheme.S(this, 4);
if (_checked)
{
UiTheme.FillRounded(graphics, rect, radius, Enabled ? (_hovered ? UiTheme.AccentSoft : UiTheme.Accent) : UiTheme.Paused);
using var pen = new Pen(UiTheme.Background, UiTheme.SF(this, 1.8f))
{
StartCap = LineCap.Round,
EndCap = LineCap.Round,
LineJoin = LineJoin.Round
};
var x = rect.X;
var y = rect.Y;
graphics.DrawLines(pen, new[]
{
new PointF(x + box * 0.26f, y + box * 0.53f),
new PointF(x + box * 0.43f, y + box * 0.70f),
new PointF(x + box * 0.75f, y + box * 0.32f)
});
}
else
{
UiTheme.FillRounded(graphics, rect, radius, Enabled ? UiTheme.Input : UiTheme.Background);
UiTheme.StrokeRounded(graphics, rect, radius,
!Enabled ? UiTheme.BorderSoft : _hovered ? UiTheme.Border : UiTheme.Border, UiTheme.SF(this, 1f));
}
var textBounds = new Rectangle(box + UiTheme.S(this, 9), 0, Math.Max(0, Width - box - UiTheme.S(this, 9)), Height);
UiTheme.DrawText(graphics, Text, Font ?? UiTheme.Body, Enabled ? UiTheme.Text : UiTheme.Faint,
textBounds, TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine |
TextFormatFlags.EndEllipsis);
if (Focused && ShowFocusCues)
ControlPaint.DrawFocusRectangle(graphics, Rectangle.Inflate(textBounds, 0, -UiTheme.S(this, 5)),
UiTheme.Text, Color.Transparent);
}
private void Toggle()
{
if (!Enabled) return;
Checked = !Checked;
}
protected override void OnMouseEnter(EventArgs e)
{
_hovered = true;
Invalidate();
base.OnMouseEnter(e);
}
protected override void OnMouseLeave(EventArgs e)
{
_hovered = false;
Invalidate();
base.OnMouseLeave(e);
}
protected override void OnMouseUp(MouseEventArgs e)
{
base.OnMouseUp(e);
if (e.Button == MouseButtons.Left && ClientRectangle.Contains(e.Location)) Toggle();
}
protected override bool IsInputKey(Keys keyData) =>
keyData is Keys.Space || base.IsInputKey(keyData);
protected override void OnKeyDown(KeyEventArgs e)
{
if (e.KeyCode == Keys.Space)
{
Toggle();
e.Handled = true;
return;
}
base.OnKeyDown(e);
}
protected override void OnGotFocus(EventArgs e)
{
Invalidate();
base.OnGotFocus(e);
}
protected override void OnLostFocus(EventArgs e)
{
Invalidate();
base.OnLostFocus(e);
}
}
internal sealed class WrapTextLabel : Label
@@ -197,23 +664,14 @@ internal sealed class WrapTextLabel : Label
protected override void OnPaint(PaintEventArgs e)
{
e.Graphics.Clear(BackColor);
UiTheme.Configure(e.Graphics);
var bounds = ClientRectangle;
bounds = new Rectangle(bounds.X + Padding.Left, bounds.Y + Padding.Top,
Math.Max(0, bounds.Width - Padding.Horizontal),
Math.Max(0, bounds.Height - Padding.Vertical));
e.Graphics.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit;
using var brush = new SolidBrush(ForeColor);
using var format = new StringFormat(StringFormat.GenericTypographic)
{
FormatFlags = StringFormatFlags.LineLimit,
Trimming = StringTrimming.EllipsisCharacter,
HotkeyPrefix = System.Drawing.Text.HotkeyPrefix.None,
Alignment = StringAlignment.Near,
LineAlignment = StringAlignment.Near
};
e.Graphics.DrawString(Text, Font, brush,
new RectangleF(bounds.X, bounds.Y, bounds.Width, bounds.Height), format);
var bounds = new Rectangle(ClientRectangle.X + Padding.Left, ClientRectangle.Y + Padding.Top,
Math.Max(0, ClientRectangle.Width - Padding.Horizontal),
Math.Max(0, ClientRectangle.Height - Padding.Vertical));
// 与 RelayoutHelpLabels 的 TextRenderer.MeasureText 走同一条 GDI 路径,
// 否则“量出来 2 行、画出来 3 行”会把最后一行裁掉。
TextRenderer.DrawText(e.Graphics, Text, Font, bounds, ForeColor,
TextFormatFlags.WordBreak | TextFormatFlags.TextBoxControl | TextFormatFlags.NoPadding |
TextFormatFlags.Top | TextFormatFlags.Left);
}
protected override void OnTextChanged(EventArgs e)
@@ -254,16 +712,27 @@ internal sealed class ToggleSwitch : Control
public event EventHandler? UserToggled;
protected override void OnHandleCreated(EventArgs e)
{
base.OnHandleCreated(e);
// 句柄建好后 DeviceDpi 才是真值:按它把默认尺寸补成缩放后的值。
var target = new Size(UiTheme.S(this, 42), UiTheme.S(this, 22));
if (Size != target) Size = target;
}
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
UiTheme.Configure(e.Graphics);
var scale = UiTheme.Scale(this);
// 控件尺寸由宿主决定(可能已被 DPI 缩放),轨道永远不超过它,避免高 DPI 下被裁。
var trackWidth = Math.Min(38 * scale, Width);
var trackHeight = Math.Min(20 * scale, Height);
var track = new RectangleF(
(Width - 38 * scale) / 2f,
(Height - 20 * scale) / 2f,
38 * scale,
20 * scale);
(Width - trackWidth) / 2f,
(Height - trackHeight) / 2f,
trackWidth,
trackHeight);
var radius = track.Height / 2f;
var trackColor = !Enabled ? UiTheme.BorderSoft : _checked ? UiTheme.Accent : _hovered ? UiTheme.CardPressed : UiTheme.Border;
using (var path = new GraphicsPath())
@@ -449,7 +918,7 @@ internal sealed class CardPanel : Panel
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = UiTheme.Card;
ForeColor = UiTheme.Text;
Padding = new Padding(14, 12, 14, 14);
Padding = new Padding(20, 16, 20, 18);
Margin = new Padding(0, 0, 0, 12);
}
@@ -458,7 +927,7 @@ internal sealed class CardPanel : Panel
base.OnPaint(e);
UiTheme.Configure(e.Graphics);
var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1));
UiTheme.StrokeRounded(e.Graphics, bounds, UiTheme.S(this, 10), UiTheme.BorderSoft, UiTheme.SF(this, 1f));
UiTheme.StrokeRounded(e.Graphics, bounds, UiTheme.S(this, 12), UiTheme.BorderSoft, UiTheme.SF(this, 1f));
}
}
@@ -660,7 +1129,8 @@ internal sealed class DarkComboBox : ComboBox
{
if (e.Index < 0 || e.Index >= Items.Count)
{
e.Graphics.FillRectangle(new SolidBrush(BackColor), e.Bounds);
using (var emptyBackground = new SolidBrush(BackColor))
e.Graphics.FillRectangle(emptyBackground, e.Bounds);
return;
}
@@ -685,6 +1155,7 @@ internal sealed class DarkScrollPanel : Panel
private readonly DarkScrollBar _bar = new();
private readonly List<Control> _stacks = [];
private int _offset;
private bool _forwardingWheel;
public DarkScrollPanel()
{
@@ -707,6 +1178,27 @@ internal sealed class DarkScrollPanel : Panel
public void ScrollByPixels(int delta) => SetOffset(_offset + delta);
/// <summary>
/// 一格滚轮(120)滚多少像素,文本框把滚轮让出来时按同一个步长走。
/// </summary>
public int StepPixels => UiTheme.S(this, 76);
/// <summary>
/// 把滚轮让给最近的页面容器(输入框自己滚不动时调用)。返回是否真的滚了。
/// </summary>
public static bool ForwardWheelToPage(Control? from, int notches)
{
if (notches == 0) return false;
for (var node = from?.Parent; node is not null; node = node.Parent)
{
if (node is not DarkScrollPanel page) continue;
if (page.ContentHeight <= page.ViewportHeight) return false; // 页面本来就不需要滚,交回上层。
page.ScrollByPixels(notches * page.StepPixels);
return true;
}
return false;
}
/// <summary>Page children are re-hosted into the offset content panel, preserving order.</summary>
protected override void OnControlAdded(ControlEventArgs e)
{
@@ -727,6 +1219,8 @@ internal sealed class DarkScrollPanel : Panel
protected override void OnLayout(LayoutEventArgs levent)
{
base.OnLayout(levent);
// 页面切换/重建会移除控件,留下已经 Dispose 的栈会让下面的 GetPreferredSize 抛异常。
_stacks.RemoveAll(stack => stack.IsDisposed || stack.Parent is null);
var width = ContentWidth;
var y = 0;
foreach (var stack in _stacks)
@@ -762,9 +1256,63 @@ internal sealed class DarkScrollPanel : Panel
private void ClampOffset() => SetOffset(_offset);
/// <summary>
/// 滚轮归属:鼠标停在“真的有竖条的多行输入框”上时先给它,其余情况才滚动整页;
/// 页面本身不需要滚动时不吃这个事件,交给父级处理。
/// </summary>
protected override void OnMouseWheel(MouseEventArgs e)
{
ScrollByPixels(-e.Delta / 120 * UiTheme.S(this, 76));
// 文本框滚到尽头时可能把消息再抛回父级,形成往返:转发期间直接丢弃。
if (_forwardingWheel) return;
if (FindFieldBox(HitTestDeep(this, e.Location)) is { AcceptsWheel: true } field)
{
_forwardingWheel = true;
try { ForwardWheel(field.Inner, e, PointToScreen(e.Location)); }
finally { _forwardingWheel = false; }
return;
}
if (ContentHeight <= ViewportHeight)
{
base.OnMouseWheel(e);
return;
}
ScrollByPixels(-e.Delta / 120 * StepPixels);
}
private static FieldBox? FindFieldBox(Control? control)
{
for (var node = control; node is not null; node = node.Parent)
if (node is FieldBox box) return box;
return null;
}
private static Control? HitTestDeep(Control parent, Point point)
{
for (var i = parent.Controls.Count - 1; i >= 0; i--)
{
var child = parent.Controls[i];
if (child is null || !child.Visible) continue;
var local = new Point(point.X - child.Left, point.Y - child.Top);
if (!child.ClientRectangle.Contains(local)) continue;
return HitTestDeep(child, local) ?? child;
}
return null;
}
private const int WmMouseWheel = 0x020A;
[DllImport("user32.dll", CharSet = CharSet.Auto)]
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
/// <summary>把滚轮消息转给内部 TextBox,让它在自己内部滚动而不是带动整页。</summary>
private static void ForwardWheel(TextBox target, MouseEventArgs e, Point screen)
{
if (!target.IsHandleCreated) return;
var coordinates = ((screen.Y & 0xFFFF) << 16) | (screen.X & 0xFFFF);
SendMessage(target.Handle, WmMouseWheel, (IntPtr)(e.Delta << 16), (IntPtr)coordinates);
}
}
@@ -852,6 +1400,9 @@ internal sealed class DarkScrollBar : Control
{
var jump = e.Y < ThumbBounds.Top ? -Viewport : Viewport;
Scrolled?.Invoke(this, Math.Clamp(Value + jump, 0, Maximum));
// 页跳之后基准要重置,否则接着拖动会从“跳之前”的位置算起,看起来像弹回去。
_dragStartY = e.Y;
_dragStartValue = Value;
}
Invalidate();
}
+154 -84
View File
@@ -16,10 +16,10 @@ internal sealed class SettingsForm : FramelessForm
private readonly TextBox _directGateway = new();
private readonly TextBox _proxyGateway = new();
private readonly TextBox _proxyDns = new();
private readonly CheckBox _preserveOriginalDirectDns = new();
private readonly CheckBox _directDnsAutomatic = new();
private readonly CheckLine _preserveOriginalDirectDns = new();
private readonly CheckLine _directDnsAutomatic = new();
private readonly TextBox _directDnsServers = new();
private readonly CheckBox _useSocks = new();
private readonly CheckLine _useSocks = new();
private readonly TextBox _socksPort = new();
private readonly TextBox _testHosts = new();
private readonly TextBox _requiredTargets = new();
@@ -32,13 +32,13 @@ internal sealed class SettingsForm : FramelessForm
private readonly TextBox _failureThreshold = new();
private readonly TextBox _recoveryCooldown = new();
private readonly TextBox _failureCooldown = new();
private readonly CheckBox _notifications = new();
private readonly CheckBox _restoreOnExit = new();
private readonly CheckLine _notifications = new();
private readonly CheckLine _restoreOnExit = new();
private readonly ThemeButton _saveButton = new(true) { Text = "保存" };
private readonly ThemeButton _cancelButton = new(false) { Text = "取消" };
private readonly ThemeButton _refreshAdaptersButton = new(false) { Text = "刷新网卡" };
private readonly ThemeButton _closeButton = new(false) { Text = "关闭" };
private readonly PanelChip _closeButton = new() { Text = "✕", Ghost = true, CornerRadiusDip = 6 };
private readonly Label _message = new();
private readonly ToolTip _toolTip = new();
private readonly PanelChip[] _tabs = new PanelChip[3];
@@ -47,12 +47,16 @@ internal sealed class SettingsForm : FramelessForm
private readonly List<WrapTextLabel> _helpLabels = [];
private TableLayoutPanel _outer = null!;
private TableLayoutPanel _body = null!;
private TableLayoutPanel _footer = null!;
private Panel _pageHost = null!;
private Control _directDnsPolicy = null!;
private Control _directDnsServersField = null!;
private WrapTextLabel _preserveHint = null!;
private Control _socksPortField = null!;
private FieldBox _testHostsField = null!;
private FieldBox _directTestHostsField = null!;
private bool _saving;
private bool _closing;
private bool _refreshing;
private bool _dragging;
private Point _dragOffset;
@@ -67,8 +71,10 @@ internal sealed class SettingsForm : FramelessForm
Text = "设置 · 网关保活";
AutoScaleDimensions = new SizeF(96f, 96f);
AutoScaleMode = AutoScaleMode.Dpi;
StartPosition = FormStartPosition.CenterParent;
ClientSize = new Size(620, 560);
StartPosition = FormStartPosition.Manual;
// 设置是独立窗口,要在任务栏留一个按钮:托盘浮窗一收起来,用户还能点回来。
ShowInTaskbar = true;
ClientSize = new Size(720, 620);
BuildInterface();
FillValues();
}
@@ -125,7 +131,7 @@ internal sealed class SettingsForm : FramelessForm
BackColor = UiTheme.Background,
ColumnCount = 2,
RowCount = 1,
Padding = new Padding(18, 8, 16, 4)
Padding = new Padding(20, 10, 14, 6)
};
header.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
header.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
@@ -158,8 +164,13 @@ internal sealed class SettingsForm : FramelessForm
titles.Controls.Add(title, 0, 0);
titles.Controls.Add(subtitle, 0, 1);
_closeButton.Anchor = AnchorStyles.Right;
_closeButton.Margin = new Padding(8, 0, 0, 0);
_closeButton.Size = new Size(UiTheme.S(this, 30), UiTheme.S(this, 26));
_closeButton.AccessibleName = "关闭设置";
_toolTip.SetToolTip(_closeButton, "关闭设置(Esc)");
header.Controls.Add(titles, 0, 0);
header.Controls.Add(_closeButton, 1, 0);
header.Paint += (_, e) => DrawHairline(e.Graphics, header, header.ClientSize.Height - 1);
header.MouseDown += BeginDrag;
header.MouseMove += ContinueDrag;
@@ -181,22 +192,25 @@ internal sealed class SettingsForm : FramelessForm
ColumnCount = 1,
RowCount = 2,
BackColor = UiTheme.Background,
Padding = new Padding(14, 2, 14, 0)
Padding = new Padding(18, 6, 18, 0)
};
body.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
body.RowStyles.Add(new RowStyle(SizeType.Absolute, 42));
body.RowStyles.Add(new RowStyle(SizeType.Absolute, 40));
body.RowStyles.Add(new RowStyle(SizeType.Percent, 100f));
_body = body;
var tabRow = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 3,
ColumnCount = 4,
RowCount = 1,
BackColor = UiTheme.Background,
Margin = new Padding(0)
};
for (var i = 0; i < 3; i++) tabRow.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.333f));
tabRow.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
tabRow.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
tabRow.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
tabRow.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
tabRow.RowStyles.Add(new RowStyle(SizeType.Percent, 100f));
_pages[0] = MakePage();
@@ -213,11 +227,15 @@ internal sealed class SettingsForm : FramelessForm
var tab = new PanelChip
{
Text = names[i],
Font = UiTheme.BodyBold,
Dock = DockStyle.Fill,
Margin = new Padding(3, 2, 3, 4),
Font = UiTheme.Body,
Quiet = true,
CornerRadiusDip = 8,
Anchor = AnchorStyles.Left,
Margin = new Padding(0, 0, 8, 0),
AccessibleName = names[i]
};
var textWidth = TextRenderer.MeasureText(names[i], UiTheme.Body).Width;
tab.Size = new Size(Math.Max(UiTheme.S(this, 84), textWidth + UiTheme.S(this, 30)), UiTheme.S(this, 30));
tab.Click += (_, _) => SelectTab(index);
_tabs[i] = tab;
tabRow.Controls.Add(tab, i, 0);
@@ -230,6 +248,7 @@ internal sealed class SettingsForm : FramelessForm
Margin = new Padding(0)
};
foreach (var page in _pages) _pageHost.Controls.Add(page);
_pageHost.SizeChanged += (_, _) => RelayoutHelpLabels();
body.Controls.Add(tabRow, 0, 0);
body.Controls.Add(_pageHost, 0, 1);
return body;
@@ -257,6 +276,7 @@ internal sealed class SettingsForm : FramelessForm
_message.ForeColor = UiTheme.Muted;
_message.BackColor = UiTheme.BackgroundRaised;
_message.AutoEllipsis = true;
_footer = footer;
var actions = new FlowLayoutPanel
{
@@ -271,9 +291,19 @@ internal sealed class SettingsForm : FramelessForm
actions.Controls.Add(_saveButton);
footer.Controls.Add(_message, 0, 0);
footer.Controls.Add(actions, 1, 0);
footer.Paint += (_, e) => DrawHairline(e.Graphics, footer, 0);
return footer;
}
/// <summary>在宿主控件自己的一条边上画细线(底栏上沿、标题区下沿)。</summary>
private static void DrawHairline(Graphics graphics, Control host, int y)
{
if (y < 0) return;
UiTheme.Configure(graphics);
using var pen = new Pen(UiTheme.BorderSoft);
graphics.DrawLine(pen, 0, y, host.ClientSize.Width, y);
}
private DarkScrollPanel MakePage() => new()
{
Dock = DockStyle.Fill,
@@ -306,20 +336,20 @@ internal sealed class SettingsForm : FramelessForm
AddRow(page, MakeCard("网卡", "选择本机要管理的网络接口", Field("网络适配器", adapterRow)));
_toolTip.SetToolTip(_adapterBox, "只管理这里明确选择的网卡");
ConfigureText(_directGateway);
ConfigureText(_proxyGateway);
ConfigureText(_proxyDns);
var directGatewayField = ConfigureText(_directGateway);
var proxyGatewayField = ConfigureText(_proxyGateway);
var proxyDnsField = ConfigureText(_proxyDns);
AddRow(page, MakeCard("网关", "填写主路由和旁路由的 IPv4 地址",
Pair(Field("主路由网关", _directGateway), Field("旁路由网关", _proxyGateway)),
Field("旁路由 DNS", _proxyDns)));
Pair(Field("主路由网关", directGatewayField), Field("旁路由网关", proxyGatewayField)),
Field("旁路由 DNS", proxyDnsField)));
ConfigureCheck(_preserveOriginalDirectDns, "保留接管前的直连 DNS");
ConfigureCheck(_directDnsAutomatic, "直连时自动获取 DNS");
ConfigureText(_directDnsServers);
var directDnsServersField = ConfigureText(_directDnsServers);
_preserveHint = HelpLabel("恢复直连时使用接管前读取到、并在系统里生效的原始 DNS。");
_directDnsPolicy = NewPageStack();
AddRow((TableLayoutPanel)_directDnsPolicy, WrapControl(_directDnsAutomatic));
_directDnsServersField = Field("直连 DNS 服务器(逗号或空格分隔)", _directDnsServers);
_directDnsServersField = Field("直连 DNS 服务器(逗号或空格分隔)", directDnsServersField);
AddRow((TableLayoutPanel)_directDnsPolicy, _directDnsServersField);
var dnsCard = MakeCard("直连 DNS", "决定恢复主路由时使用的 DNS",
WrapControl(_preserveOriginalDirectDns), _preserveHint, _directDnsPolicy);
@@ -333,20 +363,20 @@ internal sealed class SettingsForm : FramelessForm
private Control BuildRulesPage()
{
var page = NewPageStack();
ConfigureText(_interval);
ConfigureText(_probeTimeout);
ConfigureText(_roundTimeout);
var intervalField = ConfigureText(_interval);
var probeTimeoutField = ConfigureText(_probeTimeout);
var roundTimeoutField = ConfigureText(_roundTimeout);
AddRow(page, MakeCard("检测节奏", "检测越频繁,故障发现越快",
Field("检测间隔(秒)", _interval),
Pair(Field("单次检查超时(毫秒)", _probeTimeout), Field("整轮检查预算(毫秒)", _roundTimeout))));
Field("检测间隔(秒)", intervalField),
Pair(Field("单次检查超时(毫秒)", probeTimeoutField), Field("整轮检查预算(毫秒)", roundTimeoutField))));
ConfigureText(_successThreshold);
ConfigureText(_failureThreshold);
ConfigureText(_recoveryCooldown);
ConfigureText(_failureCooldown);
var successThresholdField = ConfigureText(_successThreshold);
var failureThresholdField = ConfigureText(_failureThreshold);
var recoveryCooldownField = ConfigureText(_recoveryCooldown);
var failureCooldownField = ConfigureText(_failureCooldown);
AddRow(page, MakeCard("切换判定", "连续达到阈值并经过冷却后才会自动切换",
Pair(Field("连续成功几次后恢复旁路由", _successThreshold), Field("连续失败几次后切回主路由", _failureThreshold)),
Pair(Field("切回旁路由前的冷却(秒)", _recoveryCooldown), Field("切回主路由前的冷却(秒)", _failureCooldown))));
Pair(Field("连续成功几次后恢复旁路由", successThresholdField), Field("连续失败几次后切回主路由", failureThresholdField)),
Pair(Field("切回旁路由前的冷却(秒)", recoveryCooldownField), Field("切回主路由前的冷却(秒)", failureCooldownField))));
var note = HelpLabel("自动保活开启时才按这些规则切换。手动固定路径后,程序仍会继续监测,但不会自动改回另一条路径。");
AddRow(page, MakeCard("说明", string.Empty, note));
@@ -358,21 +388,20 @@ internal sealed class SettingsForm : FramelessForm
{
var page = NewPageStack();
ConfigureCheck(_useSocks, "同时检查 SOCKS5 可用性");
ConfigureText(_socksPort);
_socksPortField = Field("SOCKS5 端口", _socksPort);
_socksPortField = Field("SOCKS5 端口", ConfigureText(_socksPort));
AddRow(page, MakeCard("探测方式", "用于判断旁路由是否真的可用", WrapControl(_useSocks), _socksPortField));
ConfigureText(_testHosts, multiline: true);
ConfigureText(_requiredTargets);
_testHostsField = ConfigureText(_testHosts, multiline: true);
var requiredTargetsField = ConfigureText(_requiredTargets);
AddRow(page, MakeCard("旁路由探测目标", "走旁路由时验证这些网站",
Field("目标列表(每行一个,最多 5 个)", _testHosts),
Field("至少成功几个目标", _requiredTargets)));
Field("目标列表(每行一个,最多 5 个)", _testHostsField),
Field("至少成功几个目标", requiredTargetsField)));
ConfigureText(_directTestHosts, multiline: true);
ConfigureText(_requiredDirectTargets);
_directTestHostsField = ConfigureText(_directTestHosts, multiline: true);
var requiredDirectTargetsField = ConfigureText(_requiredDirectTargets);
AddRow(page, MakeCard("直连探测目标", "回退到主路由后验证这些网站",
Field("目标列表(每行一个,最多 5 个)", _directTestHosts),
Field("至少成功几个目标", _requiredDirectTargets)));
Field("目标列表(每行一个,最多 5 个)", _directTestHostsField),
Field("至少成功几个目标", requiredDirectTargetsField)));
ConfigureCheck(_notifications, "状态变化时发送通知");
ConfigureCheck(_restoreOnExit, "退出时恢复接管前的网络配置");
@@ -444,7 +473,7 @@ internal sealed class SettingsForm : FramelessForm
ColumnCount = 1,
RowCount = subtitle.Length == 0 ? 1 : 2,
BackColor = UiTheme.Card,
Margin = new Padding(0, 0, 0, 10)
Margin = new Padding(0, 0, 0, 14)
};
host.Controls.Add(new Label
{
@@ -461,8 +490,9 @@ internal sealed class SettingsForm : FramelessForm
Text = subtitle,
AutoSize = true,
Font = UiTheme.Small,
ForeColor = UiTheme.Faint,
BackColor = UiTheme.Card
ForeColor = UiTheme.Muted,
BackColor = UiTheme.Card,
Margin = new Padding(0, 2, 0, 0)
}, 0, 1);
}
return host;
@@ -477,7 +507,7 @@ internal sealed class SettingsForm : FramelessForm
AutoSizeMode = AutoSizeMode.GrowAndShrink,
ColumnCount = 1,
RowCount = 2,
Margin = new Padding(0, 0, 0, 8),
Margin = new Padding(0, 0, 0, 12),
BackColor = UiTheme.Card
};
stack.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
@@ -490,7 +520,7 @@ internal sealed class SettingsForm : FramelessForm
Font = UiTheme.Small,
ForeColor = UiTheme.Muted,
BackColor = UiTheme.Card,
Margin = new Padding(0, 0, 0, 3)
Margin = new Padding(0, 0, 0, 5)
}, 0, 0);
input.Margin = new Padding(0);
input.Dock = DockStyle.Top;
@@ -543,7 +573,7 @@ internal sealed class SettingsForm : FramelessForm
{
Text = text,
Dock = DockStyle.Top,
Height = 42,
Height = 22,
Font = UiTheme.Small,
ForeColor = UiTheme.Muted,
BackColor = UiTheme.Card,
@@ -559,13 +589,17 @@ internal sealed class SettingsForm : FramelessForm
_activeTab = index;
for (var i = 0; i < _tabs.Length; i++)
{
if (_tabs[i] is not null) _tabs[i].Selected = i == index;
if (_tabs[i] is not null)
{
_tabs[i].Selected = i == index;
_tabs[i].Invalidate();
}
if (_pages[i] is not null) _pages[i].Visible = i == index;
}
if (index >= 0 && index < _pages.Length && _pages[index].Visible)
_pages[index].BringToFront();
ApplyPageGutters();
RelayoutHelpLabels();
}
/// <summary>The page itself reserves the scrollbar gutter, so docked cards stay clear of it.</summary>
@@ -643,6 +677,7 @@ internal sealed class SettingsForm : FramelessForm
try
{
var adapters = await _controller.ListAdaptersAsync();
if (IsDisposed || Disposing) return;
FillAdapters(adapters, desired);
SetMessage(adapters.Count == 0 ? "没有找到可用网卡。" : "网卡列表已刷新,请确认选择。", adapters.Count == 0);
}
@@ -678,7 +713,10 @@ internal sealed class SettingsForm : FramelessForm
try
{
await _controller.SaveSettingsAsync(settings);
if (IsDisposed || Disposing) return;
SavedSettings = settings;
// 保存成功就关窗:_closing 让 OnFormClosing 放行这次由我们自己发起的关闭。
_closing = true;
DialogResult = DialogResult.OK;
Close();
}
@@ -798,13 +836,20 @@ internal sealed class SettingsForm : FramelessForm
private void SetMessage(string text, bool error)
{
if (IsDisposed || Disposing) return;
_message.Text = text;
_message.ForeColor = error ? UiTheme.Danger : UiTheme.Muted;
}
private void CloseWithCancel()
{
if (_saving) return;
if (_saving)
{
// 保存中不接受关闭,但要说清楚,别让人以为点了没反应。
SetMessage("正在保存,保存完成后窗口会自动关闭。", false);
return;
}
_closing = true;
DialogResult = DialogResult.Cancel;
Close();
}
@@ -813,7 +858,8 @@ internal sealed class SettingsForm : FramelessForm
protected override void OnFormClosing(FormClosingEventArgs e)
{
if (_saving && e.CloseReason == CloseReason.UserClosing)
// 保存中用户按 ✕ / Esc 一律拒绝;保存成功后由 SaveAsync 自己把 _closing 立起来再关。
if (_saving && !_closing && e.CloseReason == CloseReason.UserClosing)
{
e.Cancel = true;
return;
@@ -826,25 +872,60 @@ internal sealed class SettingsForm : FramelessForm
base.OnLoad(e);
var area = Screen.FromPoint(Cursor.Position).WorkingArea;
var margin = UiTheme.S(this, 8);
var minimumWidth = Math.Min(UiTheme.S(this, 480), Math.Max(320, area.Width - margin * 2));
var minimumHeight = Math.Min(UiTheme.S(this, 400), Math.Max(300, area.Height - margin * 2));
var minimumWidth = Math.Min(UiTheme.S(this, 560), Math.Max(360, area.Width - margin * 2));
var minimumHeight = Math.Min(UiTheme.S(this, 440), Math.Max(320, area.Height - margin * 2));
MinimumSize = new Size(minimumWidth, minimumHeight);
Width = Math.Min(UiTheme.S(this, 620), Math.Max(minimumWidth, area.Width - margin * 2));
Height = Math.Min(UiTheme.S(this, 560), Math.Max(minimumHeight, area.Height - margin * 2));
Width = Math.Min(UiTheme.S(this, 720), Math.Max(minimumWidth, area.Width - margin * 2));
Height = Math.Min(UiTheme.S(this, 620), Math.Max(minimumHeight, area.Height - margin * 2));
_outer.RowStyles[0].Height = UiTheme.S(this, 64);
_outer.RowStyles[2].Height = UiTheme.S(this, 66);
_body.RowStyles[0].Height = UiTheme.S(this, 42);
_testHosts.Height = UiTheme.S(this, 92);
_directTestHosts.Height = UiTheme.S(this, 92);
foreach (var label in _helpLabels) label.Height = UiTheme.S(this, 42);
foreach (var page in _pages) page.Padding = new Padding(UiTheme.S(this, 2), UiTheme.S(this, 8),
UiTheme.S(this, 2), UiTheme.S(this, 8));
_outer.RowStyles[0].Height = UiTheme.S(this, 62);
_outer.RowStyles[2].Height = UiTheme.S(this, 64);
_body.RowStyles[0].Height = UiTheme.S(this, 44);
_testHostsField.Height = UiTheme.S(this, 96);
_directTestHostsField.Height = UiTheme.S(this, 96);
RelayoutHelpLabels();
// 与 ApplyPageGutters 保持一致:首屏和切页后的内容宽度不能差一个滚动条槽位。
foreach (var page in _pages) page.Padding = new Padding(0, UiTheme.S(this, 10), 0, UiTheme.S(this, 10));
ApplyPageGutters();
if (Owner is null)
Location = new Point(area.Left + (area.Width - Width) / 2, area.Top + (area.Height - Height) / 2);
Location = CenteredOn(area);
}
/// <summary>
/// 窗口一律居中到“鼠标所在的那块屏幕”。模态宿主可能只是托盘旁一块很小的浮窗,
/// 交给 CenterParent 会把设置窗塞到屏幕右下角、甚至压到浮窗下面点不到。
/// </summary>
protected override void OnShown(EventArgs e)
{
base.OnShown(e);
RelayoutHelpLabels();
var area = Screen.FromPoint(Cursor.Position).WorkingArea;
if (!area.Contains(Bounds)) Location = CenteredOn(area);
Activate();
BringToFront();
}
/// <summary>说明文字的换行行数取决于实际宽度,按实测高度收放,避免留白或截断。</summary>
private void RelayoutHelpLabels()
{
foreach (var label in _helpLabels)
{
var available = label.Parent is null
? label.Width
: label.Parent.ClientSize.Width - label.Margin.Horizontal;
if (available <= 0) continue;
var measured = TextRenderer.MeasureText(label.Text, label.Font,
new Size(available, int.MaxValue),
TextFormatFlags.WordBreak | TextFormatFlags.TextBoxControl | TextFormatFlags.NoPadding).Height;
var target = Math.Max(UiTheme.S(this, 20), measured + label.Padding.Vertical + UiTheme.S(this, 2));
if (label.Height != target) label.Height = target;
}
}
private Point CenteredOn(Rectangle area) => new(
area.Left + (area.Width - Width) / 2,
area.Top + (area.Height - Height) / 2);
private void BeginDrag(object? sender, MouseEventArgs e)
{
if (e.Button != MouseButtons.Left) return;
@@ -862,33 +943,22 @@ internal sealed class SettingsForm : FramelessForm
private void EndDrag(object? sender, MouseEventArgs e) => _dragging = false;
private static void ConfigureText(TextBox box, bool multiline = false)
private static FieldBox ConfigureText(TextBox box, bool multiline = false)
{
box.BorderStyle = BorderStyle.FixedSingle;
box.BackColor = UiTheme.Input;
box.ForeColor = UiTheme.Text;
box.Font = UiTheme.Body;
box.Multiline = multiline;
box.Dock = DockStyle.Top;
// Five short lines are the supported maximum, so no native light scrollbar is needed.
// 五行的短列表是支持的上限,不需要原生浅色滚动条。
if (multiline)
{
box.ScrollBars = ScrollBars.None;
box.WordWrap = false;
box.AcceptsReturn = true;
}
box.Height = multiline ? 92 : 28;
box.Margin = new Padding(0);
return new FieldBox(box, multiline) { AccessibleName = box.AccessibleName };
}
private static void ConfigureCheck(CheckBox check, string text)
private static CheckLine ConfigureCheck(CheckLine check, string text)
{
check.Text = text;
check.AutoSize = true;
check.Font = UiTheme.Body;
check.ForeColor = UiTheme.Text;
check.BackColor = UiTheme.Card;
check.Margin = new Padding(0, 3, 0, 3);
return check;
}
protected override void Dispose(bool disposing)
+17 -17
View File
@@ -8,17 +8,17 @@ namespace GatewayKeepalive.Tray.Ui;
internal static class UiTheme
{
public static readonly Color Background = Color.FromArgb(0x18, 0x1B, 0x21);
public static readonly Color BackgroundRaised = Color.FromArgb(0x1E, 0x23, 0x2B);
public static readonly Color Card = Color.FromArgb(0x20, 0x25, 0x2D);
public static readonly Color CardHover = Color.FromArgb(0x29, 0x31, 0x3B);
public static readonly Color CardPressed = Color.FromArgb(0x33, 0x3C, 0x48);
public static readonly Color Input = Color.FromArgb(0x1B, 0x20, 0x28);
public static readonly Color Border = Color.FromArgb(0x35, 0x3D, 0x49);
public static readonly Color BorderSoft = Color.FromArgb(0x29, 0x30, 0x3A);
public static readonly Color Text = Color.FromArgb(0xE9, 0xEE, 0xF5);
public static readonly Color Muted = Color.FromArgb(0x9C, 0xA7, 0xB5);
public static readonly Color Faint = Color.FromArgb(0x6D, 0x78, 0x87);
public static readonly Color Background = Color.FromArgb(0x14, 0x17, 0x1C);
public static readonly Color BackgroundRaised = Color.FromArgb(0x19, 0x1D, 0x23);
public static readonly Color Card = Color.FromArgb(0x1C, 0x20, 0x27);
public static readonly Color CardHover = Color.FromArgb(0x23, 0x28, 0x31);
public static readonly Color CardPressed = Color.FromArgb(0x2B, 0x31, 0x3B);
public static readonly Color Input = Color.FromArgb(0x12, 0x15, 0x19);
public static readonly Color Border = Color.FromArgb(0x33, 0x3A, 0x45);
public static readonly Color BorderSoft = Color.FromArgb(0x26, 0x2C, 0x35);
public static readonly Color Text = Color.FromArgb(0xE9, 0xEE, 0xF6);
public static readonly Color Muted = Color.FromArgb(0x97, 0xA1, 0xAF);
public static readonly Color Faint = Color.FromArgb(0x6A, 0x74, 0x82);
public static readonly Color Accent = Color.FromArgb(0x6E, 0xA8, 0xFE);
public static readonly Color AccentSoft = Color.FromArgb(0x8D, 0xBB, 0xFF);
public static readonly Color Healthy = Color.FromArgb(0x4F, 0xCB, 0x8D);
@@ -29,14 +29,14 @@ internal static class UiTheme
private static readonly FontFamily Family = PickFamily();
public static readonly Font Title = new(Family, 13.5f, FontStyle.Bold, GraphicsUnit.Point);
public static readonly Font Title = new(Family, 15f, FontStyle.Bold, GraphicsUnit.Point);
public static readonly Font PanelTitle = new(Family, 10.5f, FontStyle.Bold, GraphicsUnit.Point);
public static readonly Font Hero = new(Family, 23f, FontStyle.Bold, GraphicsUnit.Point);
public static readonly Font Heading = new(Family, 10.5f, FontStyle.Bold, GraphicsUnit.Point);
public static readonly Font Body = new(Family, 9.5f, FontStyle.Regular, GraphicsUnit.Point);
public static readonly Font BodyBold = new(Family, 9.5f, FontStyle.Bold, GraphicsUnit.Point);
public static readonly Font Small = new(Family, 8.25f, FontStyle.Regular, GraphicsUnit.Point);
public static readonly Font SmallBold = new(Family, 8.25f, FontStyle.Bold, GraphicsUnit.Point);
public static readonly Font Heading = new(Family, 11.5f, FontStyle.Bold, GraphicsUnit.Point);
public static readonly Font Body = new(Family, 10f, FontStyle.Regular, GraphicsUnit.Point);
public static readonly Font BodyBold = new(Family, 10f, FontStyle.Bold, GraphicsUnit.Point);
public static readonly Font Small = new(Family, 9f, FontStyle.Regular, GraphicsUnit.Point);
public static readonly Font SmallBold = new(Family, 9f, FontStyle.Bold, GraphicsUnit.Point);
public static readonly Font Number = new(Family, 15f, FontStyle.Bold, GraphicsUnit.Point);
public static float Scale(Control control) => Math.Max(1f, control.DeviceDpi / 96f);
@@ -1,3 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup><ProjectReference Include="../GatewayKeepalive.Core/GatewayKeepalive.Core.csproj" /></ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>GatewayKeepalive.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>
+470 -21
View File
@@ -1,64 +1,488 @@
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Security.Principal;
using System.Text;
using System.Xml;
using System.Xml.Linq;
namespace GatewayKeepalive.Windows;
/// <summary>
/// 登录自启:为当前用户注册一个“登录时触发 + 最高权限”的计划任务,登录后静默起托盘,不弹 UAC。
/// 注册主路径交给 schtasks.exe(它的参数解析与权限处理是系统自带实现,比手工拼 COM 参数稳),
/// 失败时回退到任务计划 COM 接口;两条路径都会写入日志,便于按日志定位。
/// 读回状态用 COM 查询(不依赖控制台编码),注册后一定读回校验,拒绝“报成功但实际没生效”。
/// </summary>
public sealed class LoginStartup
{
private static string UserSid { get { using var identity = WindowsIdentity.GetCurrent(); return identity.User!.Value; } }
private static readonly XNamespace TaskNamespace = "http://schemas.microsoft.com/windows/2004/02/mit/task";
private readonly Action<string>? _log;
private string? _lastFailureSignature;
public LoginStartup(Action<string>? log = null) => _log = log;
private static string UserSid
{
get
{
using var identity = WindowsIdentity.GetCurrent();
return identity.User!.Value;
}
}
private static string TaskName => "GatewayKeepalive-" + UserSid;
public bool IsEnabled
{
get
{
object? service = null, root = null, task = null;
try
{
service = Connect(); root = ((dynamic)service).GetFolder("\\"); task = ((dynamic)root).GetTask(TaskName);
return (bool)((dynamic)task).Enabled;
var enabled = QueryEnabled();
_lastFailureSignature = null;
return enabled;
}
catch (COMException e) when ((uint)e.HResult == 0x80070002 || (uint)e.HResult == 0x8004130f) { return false; }
finally { Release(task); Release(root); Release(service); }
catch (Exception exception)
{
LogFailure(exception);
return false;
}
}
}
/// <summary>
/// “任务不存在”。晚绑定的 COM 抛出的并不总是 <see cref="COMException"/>:CLR 会按 HRESULT 翻译,
/// 0x80070002 变成 <see cref="FileNotFoundException"/>、0x80070003 变成 <see cref="DirectoryNotFoundException"/>。
/// 早先按类型抓 0x80070002,于是一次都没抓到——关自启时读回失败、界面报“找不到文件或目录”。
/// 所以这里只认 HResult,不认类型。
/// </summary>
internal static bool IsTaskMissing(Exception exception)
{
var code = (uint)exception.HResult;
return code is 0x80070002 or 0x80070003 or 0x8004130f;
}
/// <summary>同一个失败签名只记一次,避免每个检查周期都往日志里刷同一条。</summary>
private void LogFailure(Exception exception)
{
var signature = exception.GetType().FullName + "|" + exception.HResult.ToString("X8", CultureInfo.InvariantCulture);
if (signature == _lastFailureSignature) return;
_lastFailureSignature = signature;
Log($"读取登录自启状态失败:{exception.GetType().Name} 0x{(uint)exception.HResult:X8} {exception.Message}");
}
public void SetEnabled(bool enabled)
{
if (!new WindowsNetworkBackend().IsAdministrator) throw new UnauthorizedAccessException("更改最高权限登录任务需要管理员权限。");
var isAdministrator = new WindowsNetworkBackend().IsAdministrator;
Log($"登录自启写入请求:启用={(enabled ? "是" : "否")} 管理员={isAdministrator} 进程={Environment.ProcessPath}");
if (!isAdministrator)
throw new UnauthorizedAccessException("更改最高权限登录任务需要管理员权限。");
var executable = Environment.ProcessPath ?? throw new InvalidOperationException("无法确定程序路径。");
if (Path.GetFileNameWithoutExtension(executable).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("请从发布后的 GatewayKeepalive.exe 配置登录启动。");
object? service = null, root = null, definition = null;
try
{
service = Connect(); root = ((dynamic)service).GetFolder("\\");
if (Path.GetFileNameWithoutExtension(executable).Equals("dotnet", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("请从发布后的 GatewayKeepalive.exe 配置登录启动。");
if (!enabled)
{
try { ((dynamic)root).DeleteTask(TaskName, 0); }
catch (COMException e) when ((uint)e.HResult == 0x80070002) { }
RemoveTask();
if (QueryEnabled()) throw new InvalidOperationException("登录自启任务仍然存在,删除没有生效。");
Log("已取消登录自启。");
return;
}
var failures = new List<string>();
try
{
CreateWithSchtasks(executable);
if (QueryEnabled())
{
Log($"已创建登录自启任务:{TaskName} → {executable} --minimized");
return;
}
failures.Add("schtasks 注册后读回任务未启用");
}
catch (Exception exception)
{
Log("schtasks 注册登录自启失败:" + exception.Message);
failures.Add("schtasks:" + exception.Message);
}
try
{
CreateWithCom(executable);
if (QueryEnabled())
{
Log($"已通过任务计划接口创建登录自启任务:{TaskName} → {executable} --minimized");
return;
}
failures.Add("任务计划接口注册后读回任务未启用");
}
catch (Exception exception)
{
Log("任务计划接口注册登录自启失败:" + exception.Message);
failures.Add("任务计划接口:" + exception.Message);
}
throw new InvalidOperationException("两条注册路径都没有成功(" + string.Join(";", failures) + ")。");
}
/// <summary>任务里登记的可执行文件;任务不存在时返回 null。</summary>
public string? RegisteredExecutable => ReadRegisteredAction()?.Executable;
private sealed record RegisteredTask(string Executable, string Arguments);
/// <summary>任务里登记的动作(可执行文件 + 参数);任务不存在时返回 null。</summary>
private RegisteredTask? ReadRegisteredAction()
{
object? service = null, root = null, task = null, definition = null, actions = null, action = null;
try
{
service = Connect();
root = ((dynamic)service).GetFolder("\\");
task = ((dynamic)root).GetTask(TaskName);
definition = ((dynamic)task).Definition;
actions = ((dynamic)definition).Actions;
if ((int)((dynamic)actions).Count == 0) return null;
action = ((dynamic)actions).Item(1);
var path = (string?)((dynamic)action).Path;
if (string.IsNullOrEmpty(path)) return null;
var arguments = (string?)((dynamic)action).Arguments ?? string.Empty;
return new RegisteredTask(path, arguments);
}
catch (Exception exception) when (IsTaskMissing(exception))
{
return null;
}
catch (Exception exception)
{
Log("读取登录自启任务动作失败:" + exception.Message);
return null;
}
finally
{
Release(action);
Release(actions);
Release(definition);
Release(task);
Release(root);
Release(service);
}
}
/// <summary>
/// 程序换了位置(重新发布、换目录)之后,旧任务会“显示已开启却起不来”。
/// 启动时若发现任务指向的不是当前可执行文件,就把它改写到当前路径。
/// </summary>
public void RepairIfStale()
{
var executable = Environment.ProcessPath;
if (string.IsNullOrEmpty(executable)) return;
if (Path.GetFileNameWithoutExtension(executable).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) return;
try
{
if (!IsEnabled) return;
var registered = ReadRegisteredAction();
if (registered is null) return;
var samePath = string.Equals(registered.Executable, executable, StringComparison.OrdinalIgnoreCase);
var hasMinimized = registered.Arguments.Contains("--minimized", StringComparison.OrdinalIgnoreCase);
if (samePath && hasMinimized) return;
if (!new WindowsNetworkBackend().IsAdministrator)
{
Log($"登录自启任务指向 {registered.Executable} {registered.Arguments},与当前程序 {executable} --minimized 不一致;需要管理员权限才能改写。");
return;
}
SetEnabled(true);
Log($"登录自启任务原为 {registered.Executable} {registered.Arguments},已改写为 {executable} --minimized。");
}
catch (Exception exception)
{
Log("校正登录自启任务失败:" + exception.Message);
}
}
/// <summary>把任务定义写成 XML 交给 schtasks.exe,避免手工拼 /TR 时的引号转义问题。</summary>
private void CreateWithSchtasks(string executable)
{
var file = Path.Combine(Path.GetTempPath(), "gatewaykeepalive-autostart-" + Guid.NewGuid().ToString("N") + ".xml");
try
{
WriteTaskXml(file, executable);
var info = new ProcessStartInfo("schtasks.exe")
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
};
foreach (var argument in new[] { "/Create", "/F", "/TN", TaskName, "/XML", file }) info.ArgumentList.Add(argument);
info.StandardOutputEncoding = ConsoleEncoding();
info.StandardErrorEncoding = ConsoleEncoding();
using var process = Process.Start(info) ?? throw new InvalidOperationException("无法启动 schtasks.exe。");
// 两条流并发读完再等退出:顺序 ReadToEnd 会被写满的 stderr 缓冲区卡住,20 秒超时就形同虚设。
var outputTask = process.StandardOutput.ReadToEndAsync();
var errorTask = process.StandardError.ReadToEndAsync();
if (!process.WaitForExit(20000))
{
try { process.Kill(entireProcessTree: true); } catch (Exception killException) when (killException is InvalidOperationException or System.ComponentModel.Win32Exception) { }
throw new TimeoutException("schtasks.exe 创建任务超时。");
}
var output = outputTask.GetAwaiter().GetResult();
var error = errorTask.GetAwaiter().GetResult();
if (process.ExitCode != 0)
{
var detail = string.Join(" ", new[] { error, output }
.Select(x => x.Trim())
.Where(x => x.Length > 0));
throw new InvalidOperationException($"schtasks 返回 {process.ExitCode}:{Ui(Text(detail))}");
}
}
finally
{
try { if (File.Exists(file)) File.Delete(file); }
catch (Exception cleanupException) when (cleanupException is IOException or UnauthorizedAccessException) { }
}
}
private static void WriteTaskXml(string path, string executable)
{
var settings = new XmlWriterSettings { Encoding = Encoding.Unicode, Indent = true };
using var writer = XmlWriter.Create(path, settings);
TaskDocument(executable).Save(writer);
}
/// <summary>任务 XML 的文本形式(离线可验证,不需要真的注册任务)。</summary>
internal static string BuildTaskXml(string executable) => TaskDocument(executable).ToString();
private static XDocument TaskDocument(string executable)
{
var workingDirectory = Path.GetDirectoryName(executable) ?? string.Empty;
return new XDocument(
new XDeclaration("1.0", "UTF-16", null),
new XElement(TaskNamespace + "Task",
new XAttribute("version", "1.2"),
new XElement(TaskNamespace + "RegistrationInfo",
new XElement(TaskNamespace + "Description", "当前用户登录后启动网关保活托盘工具(最高权限,免 UAC 提示)。")),
new XElement(TaskNamespace + "Triggers",
new XElement(TaskNamespace + "LogonTrigger",
new XElement(TaskNamespace + "Enabled", "true"),
new XElement(TaskNamespace + "UserId", UserSid))),
new XElement(TaskNamespace + "Principals",
new XElement(TaskNamespace + "Principal",
new XAttribute("id", "Author"),
new XElement(TaskNamespace + "UserId", UserSid),
new XElement(TaskNamespace + "LogonType", "InteractiveToken"),
new XElement(TaskNamespace + "RunLevel", "HighestAvailable"))),
new XElement(TaskNamespace + "Settings",
new XElement(TaskNamespace + "MultipleInstancesPolicy", "IgnoreNew"),
new XElement(TaskNamespace + "DisallowStartIfOnBatteries", "false"),
new XElement(TaskNamespace + "StopIfGoingOnBatteries", "false"),
new XElement(TaskNamespace + "AllowHardTerminate", "true"),
new XElement(TaskNamespace + "StartWhenAvailable", "true"),
new XElement(TaskNamespace + "RunOnlyIfNetworkAvailable", "false"),
new XElement(TaskNamespace + "IdleSettings",
new XElement(TaskNamespace + "StopOnIdleEnd", "false"),
new XElement(TaskNamespace + "RestartOnIdle", "false")),
new XElement(TaskNamespace + "AllowStartOnDemand", "true"),
new XElement(TaskNamespace + "ExecutionTimeLimit", "PT0S"),
new XElement(TaskNamespace + "Enabled", "true"),
new XElement(TaskNamespace + "Hidden", "false"),
new XElement(TaskNamespace + "RunOnlyIfIdle", "false"),
new XElement(TaskNamespace + "WakeToRun", "false"),
new XElement(TaskNamespace + "Priority", "7")),
new XElement(TaskNamespace + "Actions",
new XAttribute("Context", "Author"),
new XElement(TaskNamespace + "Exec",
new XElement(TaskNamespace + "Command", executable),
new XElement(TaskNamespace + "Arguments", "--minimized"),
new XElement(TaskNamespace + "WorkingDirectory", workingDirectory)))));
}
private void CreateWithCom(string executable)
{
object? service = null, root = null, definition = null, registered = null;
try
{
service = Connect();
root = ((dynamic)service).GetFolder("\\");
definition = ((dynamic)service).NewTask(0);
dynamic task = definition;
task.RegistrationInfo.Description = "当前用户登录后启动网关保活托盘工具。";
task.Principal.UserId = UserSid;
task.Principal.LogonType = 3; // TASK_LOGON_INTERACTIVE_TOKEN: show tray in this user's session.
task.Principal.LogonType = 3; // TASK_LOGON_INTERACTIVE_TOKEN:在本用户会话里显示托盘。
task.Principal.RunLevel = 1; // TASK_RUNLEVEL_HIGHEST
task.Settings.DisallowStartIfOnBatteries = false;
task.Settings.StopIfGoingOnBatteries = false;
task.Settings.StartWhenAvailable = true;
task.Settings.ExecutionTimeLimit = "PT0S";
task.Settings.MultipleInstances = 2;
dynamic trigger = task.Triggers.Create(9);
dynamic trigger = task.Triggers.Create(9); // TASK_TRIGGER_LOGON
trigger.UserId = UserSid;
dynamic action = task.Actions.Create(0);
dynamic action = task.Actions.Create(0); // TASK_ACTION_EXEC
action.Path = executable;
action.Arguments = "--minimized";
action.WorkingDirectory = Path.GetDirectoryName(executable);
object registered = ((dynamic)root).RegisterTaskDefinition(TaskName, task, 6, UserSid, null, 3, null);
Release(registered); Release(action); Release(trigger);
registered = ((dynamic)root).RegisterTaskDefinition(TaskName, task, 6, UserSid, null, 3, null);
Release(action);
Release(trigger);
}
finally
{
Release(registered);
Release(definition);
Release(root);
Release(service);
}
}
private void RemoveTask()
{
var failures = new List<string>();
try
{
var info = new ProcessStartInfo("schtasks.exe")
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
};
foreach (var argument in new[] { "/Delete", "/F", "/TN", TaskName }) info.ArgumentList.Add(argument);
info.StandardOutputEncoding = ConsoleEncoding();
info.StandardErrorEncoding = ConsoleEncoding();
using var process = Process.Start(info) ?? throw new InvalidOperationException("无法启动 schtasks.exe。");
process.StandardOutput.ReadToEnd();
process.StandardError.ReadToEnd();
if (!process.WaitForExit(20000))
{
try { process.Kill(entireProcessTree: true); } catch (Exception killException) when (killException is InvalidOperationException or System.ComponentModel.Win32Exception) { }
failures.Add("schtasks 删除任务超时");
}
else if (process.ExitCode != 0 && QueryEnabled())
{
failures.Add($"schtasks 返回 {process.ExitCode}");
}
}
catch (Exception exception)
{
failures.Add("schtasks:" + exception.Message);
}
if (failures.Count > 0)
{
object? service = null, root = null;
try
{
service = Connect();
root = ((dynamic)service).GetFolder("\\");
((dynamic)root).DeleteTask(TaskName, 0);
failures.Clear();
}
catch (Exception exception) when (IsTaskMissing(exception))
{
failures.Clear(); // 任务本来就不存在,等同于删除成功。
}
catch (Exception exception)
{
Log("任务计划接口删除登录自启失败:" + exception.Message);
}
finally
{
Release(root);
Release(service);
}
}
if (failures.Count > 0) throw new InvalidOperationException("删除登录自启任务失败(" + string.Join(";", failures) + ")。");
}
/// <summary>
/// 读回任务状态。任务计划 COM 偶尔读不到“刚建好/刚改过”的任务(同一台机器上 schtasks 却看得见),
/// 所以 COM 读不到时用 schtasks 复核一次:宁可多花十几毫秒,也不能把状态读错——
/// 读错会让界面把“已启用”显示成关闭,用户再点一次又会重复注册任务。
/// </summary>
private static bool QueryEnabled()
{
var viaCom = QueryViaCom(out var comError);
if (viaCom == true) return true;
var viaSchtasks = QueryViaSchtasks();
if (viaSchtasks is not null) return viaSchtasks.Value;
if (comError is not null) throw comError; // 两条路都读不到,才向上报错。
return false; // 任务确实不存在。
}
private static bool? QueryViaCom(out Exception? error)
{
error = null;
object? service = null, root = null, task = null;
try
{
service = Connect();
root = ((dynamic)service).GetFolder("\\");
task = ((dynamic)root).GetTask(TaskName);
return (bool)((dynamic)task).Enabled;
}
catch (Exception exception) when (IsTaskMissing(exception))
{
return null;
}
catch (Exception exception)
{
error = exception;
return null;
}
finally
{
Release(task);
Release(root);
Release(service);
}
}
/// <summary>用 schtasks /Query /XML 复核任务是否存在且启用;null 表示问不出来。</summary>
private static bool? QueryViaSchtasks()
{
try
{
var info = new ProcessStartInfo("schtasks.exe")
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = ConsoleEncoding(),
StandardErrorEncoding = ConsoleEncoding()
};
foreach (var argument in new[] { "/Query", "/TN", TaskName, "/XML" }) info.ArgumentList.Add(argument);
using var process = Process.Start(info);
if (process is null) return null;
var output = process.StandardOutput.ReadToEndAsync();
_ = process.StandardError.ReadToEndAsync();
if (!process.WaitForExit(10000))
{
try { process.Kill(entireProcessTree: true); } catch (Exception killException) when (killException is InvalidOperationException or System.ComponentModel.Win32Exception) { }
return null;
}
if (process.ExitCode != 0) return null;
var document = XDocument.Parse(output.GetAwaiter().GetResult());
var enabled = document.Root?
.Element(TaskNamespace + "Settings")?
.Element(TaskNamespace + "Enabled")?
.Value;
return enabled is null || !enabled.Equals("false", StringComparison.OrdinalIgnoreCase);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
return null;
}
finally { Release(definition); Release(root); Release(service); }
}
private static object Connect()
@@ -68,5 +492,30 @@ public sealed class LoginStartup
service.Connect();
return service;
}
private static void Release(object? value) { if (value is not null && Marshal.IsComObject(value)) Marshal.ReleaseComObject(value); }
private static void Release(object? value)
{
if (value is not null && Marshal.IsComObject(value)) Marshal.ReleaseComObject(value);
}
private static string Text(string value) => string.IsNullOrWhiteSpace(value) ? "无输出。" : value.Replace("\r", " ").Replace("\n", " ").Trim();
private static readonly Lazy<Encoding> ConsoleText = new(() =>
{
try
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(CultureInfo.CurrentCulture.TextInfo.OEMCodePage);
}
catch (Exception exception) when (exception is ArgumentException or NotSupportedException)
{
return Encoding.UTF8;
}
});
private static Encoding ConsoleEncoding() => ConsoleText.Value;
private static string Ui(string value) => value;
private void Log(string message) => _log?.Invoke(message);
}
@@ -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;
}
}
+2
View File
@@ -51,6 +51,8 @@ internal static class Program
("NetworkComparison: transaction recovery accepts only exact before/intended rows", NetworkComparisonTests.RecoveryRejectsForeignRowsAsync),
("JsonStore: round trip, backup, and failed serialization preserve the last complete file", JsonStoreTests.AtomicReplacementAsync),
("Windows NativeLayout: x64 structure sizes and metric offsets", NativeLayoutTests.X64LayoutAsync),
("LoginStartup: logon task XML starts minimized with highest privileges", LoginStartupTests.LogonTaskXmlAsync),
("LoginStartup: missing-task detection follows HRESULT, not exception type", LoginStartupTests.TaskMissingDetectionAsync),
};
var failures = 0;
+22
View File
@@ -0,0 +1,22 @@
# 去掉发布目录里 exe 的“来自网络”标记,减少首次运行时系统弹出的“未知发布者”安全警告。
# 用法: .\tools\unblock-publish.ps1 [-Path artifacts\publish]
param(
[string]$Path = (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts\publish')
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path -LiteralPath $Path)) {
Write-Host "找不到目录:$Path(先运行 .\build.ps1 -Publish)"
exit 1
}
$cleared = 0
Get-ChildItem -LiteralPath $Path -Recurse -File -Include *.exe, *.dll | ForEach-Object {
$zone = Get-Item -LiteralPath $_.FullName -Stream Zone.Identifier -ErrorAction SilentlyContinue
if ($null -eq $zone) { return }
Remove-Item -LiteralPath $_.FullName -Stream Zone.Identifier -ErrorAction SilentlyContinue
Write-Host "已解除标记:$($_.FullName)"
$cleared++
}
if ($cleared -eq 0) { Write-Host '发布目录里没有需要解除的网络标记。' }
Write-Host ''
Write-Host '说明:本程序未做代码签名,因此 Windows 仍可能在首次运行时提示“未知发布者”并请求管理员权限。'
Write-Host '这两次提示都来自系统(安全警告 + UAC 提权),程序本身不联网、不安装服务、不写注册表自启项。'
+7 -9
View File
@@ -1,21 +1,19 @@
param([switch]$SelfContained)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$dotnet = Join-Path $env:USERPROFILE '.dotnet-sdk\dotnet.exe'
$flavor = if ($SelfContained) { 'standalone' } else { 'runtime-dependent' }
$out = Join-Path $root "artifacts\publish\$flavor"
$out = Join-Path $root 'artifacts\publish'
$env:DOTNET_ROOT = Split-Path -Parent $dotnet
& $dotnet publish (Join-Path $root 'src\GatewayKeepalive.Tray\GatewayKeepalive.Tray.csproj') -c Release -r win-x64 --self-contained $SelfContained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=embedded -o $out | Select-Object -Last 2
& $dotnet publish (Join-Path $root 'src\GatewayKeepalive.Tray\GatewayKeepalive.Tray.csproj') -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true -p:DebugType=embedded -o $out | Select-Object -Last 2
if ($LASTEXITCODE -ne 0) { throw 'publish failed' }
& $dotnet publish (Join-Path $root 'src\GatewayKeepalive.Cli\GatewayKeepalive.Cli.csproj') -c Release -r win-x64 --self-contained $SelfContained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=embedded -o (Join-Path $out 'diagnostics') | Select-Object -Last 2
& $dotnet publish (Join-Path $root 'src\GatewayKeepalive.Cli\GatewayKeepalive.Cli.csproj') -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true -p:DebugType=embedded -o (Join-Path $out 'diagnostics') | Select-Object -Last 2
if ($LASTEXITCODE -ne 0) { throw 'cli publish failed' }
$preview = Join-Path $root "artifacts\preview-$flavor"
$preview = Join-Path $root 'artifacts\preview'
Remove-Item -LiteralPath $preview -Recurse -Force -ErrorAction SilentlyContinue
$stderr = Join-Path $root "artifacts\$flavor-stderr.txt"
$stderr = Join-Path $root 'artifacts\gui-stderr.txt'
# Arguments containing spaces must be quoted explicitly; Start-Process does not escape them.
$process = Start-Process -FilePath (Join-Path $out 'GatewayKeepalive.exe') -ArgumentList @('--preview', ('"' + $preview + '"')) -WindowStyle Hidden -PassThru -Wait -RedirectStandardError $stderr
$pngs = @(Get-ChildItem -LiteralPath $preview -Filter '*.png' -ErrorAction SilentlyContinue).Count
$report = Join-Path $root "artifacts\$flavor-diagnostics.json"
$report = Join-Path $root 'artifacts\diagnostics.json'
& (Join-Path $out 'diagnostics\GatewayKeepalive.Cli.exe') --diagnose --output $report | Out-Null
$cliExit = $LASTEXITCODE
$diagnostics = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json
@@ -24,4 +22,4 @@ if (Test-Path -LiteralPath $stderr) {
$rawError = Get-Content -LiteralPath $stderr -Raw -ErrorAction SilentlyContinue
if ($null -ne $rawError) { $guiError = $rawError.Trim() }
}
[pscustomobject]@{ Flavor = $flavor; GuiExit = $process.ExitCode; PreviewPngs = $pngs; GuiStderr = $guiError; CliExit = $cliExit; ReadOnly = $diagnostics.ReadOnly; Gateway = $diagnostics.BestRoute.Gateway; Interface = $diagnostics.BestRoute.InterfaceIndex; Files = @(Get-ChildItem -LiteralPath $out -File | Select-Object Name, Length) } | ConvertTo-Json -Depth 4
[pscustomobject]@{ GuiExit = $process.ExitCode; PreviewPngs = $pngs; GuiStderr = $guiError; CliExit = $cliExit; ReadOnly = $diagnostics.ReadOnly; Gateway = $diagnostics.BestRoute.Gateway; Interface = $diagnostics.BestRoute.InterfaceIndex; Files = @(Get-ChildItem -LiteralPath $out -File | Select-Object Name, Length) } | ConvertTo-Json -Depth 4