diff --git a/README.md b/README.md index 682d41c..fc97ff1 100644 --- a/README.md +++ b/README.md @@ -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/`,编译与测试不会修改系统网络。 diff --git a/build.ps1 b/build.ps1 index 28c5fef..e9197c5 100644 --- a/build.ps1 +++ b/build.ps1 @@ -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 { diff --git a/docs/screenshots/gateway-panel-diagnostics-expanded.png b/docs/screenshots/gateway-panel-diagnostics-expanded.png index 7844c58..3538e44 100644 Binary files a/docs/screenshots/gateway-panel-diagnostics-expanded.png and b/docs/screenshots/gateway-panel-diagnostics-expanded.png differ diff --git a/docs/screenshots/gateway-panel-fault-direct.png b/docs/screenshots/gateway-panel-fault-direct.png index 08c055c..7fc8fc7 100644 Binary files a/docs/screenshots/gateway-panel-fault-direct.png and b/docs/screenshots/gateway-panel-fault-direct.png differ diff --git a/docs/screenshots/gateway-panel-healthy-proxy.png b/docs/screenshots/gateway-panel-healthy-proxy.png index 8ee5cf4..a72816d 100644 Binary files a/docs/screenshots/gateway-panel-healthy-proxy.png and b/docs/screenshots/gateway-panel-healthy-proxy.png differ diff --git a/docs/screenshots/gateway-panel-paused-initial.png b/docs/screenshots/gateway-panel-paused-initial.png index 71ea5d0..7b30d95 100644 Binary files a/docs/screenshots/gateway-panel-paused-initial.png and b/docs/screenshots/gateway-panel-paused-initial.png differ diff --git a/docs/screenshots/gateway-settings-detection.png b/docs/screenshots/gateway-settings-detection.png index ce836cd..e826a41 100644 Binary files a/docs/screenshots/gateway-settings-detection.png and b/docs/screenshots/gateway-settings-detection.png differ diff --git a/docs/screenshots/gateway-settings-preview.png b/docs/screenshots/gateway-settings-preview.png index a15fc0c..7952b81 100644 Binary files a/docs/screenshots/gateway-settings-preview.png and b/docs/screenshots/gateway-settings-preview.png differ diff --git a/docs/screenshots/gateway-settings-rules.png b/docs/screenshots/gateway-settings-rules.png index 40b8c84..3d80ac9 100644 Binary files a/docs/screenshots/gateway-settings-rules.png and b/docs/screenshots/gateway-settings-rules.png differ diff --git a/docs/validation.md b/docs/validation.md index 3becbec..b28259d 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -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` 核对计划,最后通过托盘界面保存并手动启用自动保活。 diff --git a/src/GatewayKeepalive.Core/GatewayController.cs b/src/GatewayKeepalive.Core/GatewayController.cs index ed35fab..eec688e 100644 --- a/src/GatewayKeepalive.Core/GatewayController.cs +++ b/src/GatewayKeepalive.Core/GatewayController.cs @@ -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? StatusChanged; public GatewayController(INetworkBackend backend, IHealthProbe probe, JsonStore store, Action log, Func? getAutostart = null, Action? setAutostart = null) @@ -59,7 +71,23 @@ public sealed class GatewayController : ITrayController } public Task> ListAdaptersAsync() => Task.Run(() => _backend.ListAdaptersAsync(_stop.Token)); - public void SetAutostart(bool enabled) => _setAutostart(enabled); + + /// 给界面层一个写日志的入口:界面上报的错也要能在日志里查到。 + 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() { diff --git a/src/GatewayKeepalive.Core/Models.cs b/src/GatewayKeepalive.Core/Models.cs index d1d3722..331e81b 100644 --- a/src/GatewayKeepalive.Core/Models.cs +++ b/src/GatewayKeepalive.Core/Models.cs @@ -109,6 +109,7 @@ public interface ITrayController AppStatus Status { get; } string DataDirectory { get; } bool AutostartEnabled { get; } + void AppendLog(string text); event Action? StatusChanged; Task> ListAdaptersAsync(); Task RefreshAsync(); diff --git a/src/GatewayKeepalive.Tray/Program.cs b/src/GatewayKeepalive.Tray/Program.cs index fb45d6d..f6c8623 100644 --- a/src/GatewayKeepalive.Tray/Program.cs +++ b/src/GatewayKeepalive.Tray/Program.cs @@ -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, (_, _) => diff --git a/src/GatewayKeepalive.Tray/TrayApplicationContext.cs b/src/GatewayKeepalive.Tray/TrayApplicationContext.cs index 3d192ae..ea612d8 100644 --- a/src/GatewayKeepalive.Tray/TrayApplicationContext.cs +++ b/src/GatewayKeepalive.Tray/TrayApplicationContext.cs @@ -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 diff --git a/src/GatewayKeepalive.Tray/Ui/Controls.cs b/src/GatewayKeepalive.Tray/Ui/Controls.cs index 0866c65..e6a0eff 100644 --- a/src/GatewayKeepalive.Tray/Ui/Controls.cs +++ b/src/GatewayKeepalive.Tray/Ui/Controls.cs @@ -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; } + /// 未选中时不铺底色(分段标签用),避免一排实心方块压过页面内容。 + [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 +/// +/// 圆角按钮。系统 Button 只会画方角并用系统主题色做悬停,深色界面里会露出浅色边缘, +/// 所以整块自绘;仍然实现 IButtonControl,可以继续当 AcceptButton/CancelButton 用。 +/// +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); + } +} + +/// +/// 圆角输入框外壳:里面放一个无边框 TextBox,自己画圆角、边框和聚焦高亮, +/// 避免系统 TextBox 的方角与固定 1px 边线在深色卡片上显得像“一堆临时控件”。 +/// +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; } + + /// 多行框真的出现竖条时,滚轮应当先归它,而不是滚动整页。 + public bool AcceptsWheel => _multiline && Inner.ScrollBars == ScrollBars.Vertical; + + /// 多行框自己滚不动时,把滚轮让给外层页面(单行框一律让出)。 + 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(); + } +} + +/// 自绘勾选项:小圆角方框 + 文字,避免系统浅色复选框破坏深色整体。 +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 _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); + /// + /// 一格滚轮(120)滚多少像素,文本框把滚轮让出来时按同一个步长走。 + /// + public int StepPixels => UiTheme.S(this, 76); + + /// + /// 把滚轮让给最近的页面容器(输入框自己滚不动时调用)。返回是否真的滚了。 + /// + 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; + } + /// Page children are re-hosted into the offset content panel, preserving order. 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); + /// + /// 滚轮归属:鼠标停在“真的有竖条的多行输入框”上时先给它,其余情况才滚动整页; + /// 页面本身不需要滚动时不吃这个事件,交给父级处理。 + /// 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); + + /// 把滚轮消息转给内部 TextBox,让它在自己内部滚动而不是带动整页。 + 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(); } diff --git a/src/GatewayKeepalive.Tray/Ui/SettingsForm.cs b/src/GatewayKeepalive.Tray/Ui/SettingsForm.cs index 437f807..84cecd5 100644 --- a/src/GatewayKeepalive.Tray/Ui/SettingsForm.cs +++ b/src/GatewayKeepalive.Tray/Ui/SettingsForm.cs @@ -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 _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; } + /// 在宿主控件自己的一条边上画细线(底栏上沿、标题区下沿)。 + 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(); } /// The page itself reserves the scrollbar gutter, so docked cards stay clear of it. @@ -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); } + /// + /// 窗口一律居中到“鼠标所在的那块屏幕”。模态宿主可能只是托盘旁一块很小的浮窗, + /// 交给 CenterParent 会把设置窗塞到屏幕右下角、甚至压到浮窗下面点不到。 + /// + 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(); + } + + /// 说明文字的换行行数取决于实际宽度,按实测高度收放,避免留白或截断。 + 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) diff --git a/src/GatewayKeepalive.Tray/Ui/Theme.cs b/src/GatewayKeepalive.Tray/Ui/Theme.cs index f2485d1..043f55c 100644 --- a/src/GatewayKeepalive.Tray/Ui/Theme.cs +++ b/src/GatewayKeepalive.Tray/Ui/Theme.cs @@ -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); diff --git a/src/GatewayKeepalive.Windows/GatewayKeepalive.Windows.csproj b/src/GatewayKeepalive.Windows/GatewayKeepalive.Windows.csproj index a00d348..85c6c82 100644 --- a/src/GatewayKeepalive.Windows/GatewayKeepalive.Windows.csproj +++ b/src/GatewayKeepalive.Windows/GatewayKeepalive.Windows.csproj @@ -1,3 +1,8 @@ + + + <_Parameter1>GatewayKeepalive.Tests + + diff --git a/src/GatewayKeepalive.Windows/LoginStartup.cs b/src/GatewayKeepalive.Windows/LoginStartup.cs index 50df6e1..02cefce 100644 --- a/src/GatewayKeepalive.Windows/LoginStartup.cs +++ b/src/GatewayKeepalive.Windows/LoginStartup.cs @@ -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; +/// +/// 登录自启:为当前用户注册一个“登录时触发 + 最高权限”的计划任务,登录后静默起托盘,不弹 UAC。 +/// 注册主路径交给 schtasks.exe(它的参数解析与权限处理是系统自带实现,比手工拼 COM 参数稳), +/// 失败时回退到任务计划 COM 接口;两条路径都会写入日志,便于按日志定位。 +/// 读回状态用 COM 查询(不依赖控制台编码),注册后一定读回校验,拒绝“报成功但实际没生效”。 +/// 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? _log; + private string? _lastFailureSignature; + + public LoginStartup(Action? 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 (Exception exception) + { + LogFailure(exception); + return false; } - catch (COMException e) when ((uint)e.HResult == 0x80070002 || (uint)e.HResult == 0x8004130f) { return false; } - finally { Release(task); Release(root); Release(service); } } } + /// + /// “任务不存在”。晚绑定的 COM 抛出的并不总是 :CLR 会按 HRESULT 翻译, + /// 0x80070002 变成 、0x80070003 变成 。 + /// 早先按类型抓 0x80070002,于是一次都没抓到——关自启时读回失败、界面报“找不到文件或目录”。 + /// 所以这里只认 HResult,不认类型。 + /// + internal static bool IsTaskMissing(Exception exception) + { + var code = (uint)exception.HResult; + return code is 0x80070002 or 0x80070003 or 0x8004130f; + } + + /// 同一个失败签名只记一次,避免每个检查周期都往日志里刷同一条。 + 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; + if (Path.GetFileNameWithoutExtension(executable).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("请从发布后的 GatewayKeepalive.exe 配置登录启动。"); + + if (!enabled) + { + RemoveTask(); + if (QueryEnabled()) throw new InvalidOperationException("登录自启任务仍然存在,删除没有生效。"); + Log("已取消登录自启。"); + return; + } + + var failures = new List(); try { - service = Connect(); root = ((dynamic)service).GetFolder("\\"); - if (!enabled) + CreateWithSchtasks(executable); + if (QueryEnabled()) { - try { ((dynamic)root).DeleteTask(TaskName, 0); } - catch (COMException e) when ((uint)e.HResult == 0x80070002) { } + 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) + ")。"); + } + + /// 任务里登记的可执行文件;任务不存在时返回 null。 + public string? RegisteredExecutable => ReadRegisteredAction()?.Executable; + + private sealed record RegisteredTask(string Executable, string Arguments); + + /// 任务里登记的动作(可执行文件 + 参数);任务不存在时返回 null。 + 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); + } + } + + /// + /// 程序换了位置(重新发布、换目录)之后,旧任务会“显示已开启却起不来”。 + /// 启动时若发现任务指向的不是当前可执行文件,就把它改写到当前路径。 + /// + 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); + } + } + + /// 把任务定义写成 XML 交给 schtasks.exe,避免手工拼 /TR 时的引号转义问题。 + 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); + } + + /// 任务 XML 的文本形式(离线可验证,不需要真的注册任务)。 + 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(); + 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) + ")。"); + } + + /// + /// 读回任务状态。任务计划 COM 偶尔读不到“刚建好/刚改过”的任务(同一台机器上 schtasks 却看得见), + /// 所以 COM 读不到时用 schtasks 复核一次:宁可多花十几毫秒,也不能把状态读错—— + /// 读错会让界面把“已启用”显示成关闭,用户再点一次又会重复注册任务。 + /// + 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); + } + } + + /// 用 schtasks /Query /XML 复核任务是否存在且启用;null 表示问不出来。 + 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 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); } diff --git a/tests/GatewayKeepalive.Tests/LoginStartupTests.cs b/tests/GatewayKeepalive.Tests/LoginStartupTests.cs new file mode 100644 index 0000000..992009d --- /dev/null +++ b/tests/GatewayKeepalive.Tests/LoginStartupTests.cs @@ -0,0 +1,67 @@ +using System.Xml.Linq; +using GatewayKeepalive.Windows; + +namespace GatewayKeepalive.Tests; + +/// +/// 登录自启任务的定义完全靠 XML 表达,错了只会在“下次登录”时才暴露。 +/// 这里把生成出来的 XML 当场验一遍:启动命令、隐藏启动、最高权限、电源与超时设置。 +/// +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; + } + + /// + /// 晚绑定 COM 抛出的异常类型由 CLR 按 HRESULT 翻译:任务不存在时拿到的是 , + /// 不是 。早先只按类型抓 0x80070002, + /// 于是一次都没抓到——“关闭登录自启”永远在读回时失败,界面报「找不到文件或目录」。 + /// + 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; + } +} diff --git a/tests/GatewayKeepalive.Tests/Program.cs b/tests/GatewayKeepalive.Tests/Program.cs index 157de3f..8922bc2 100644 --- a/tests/GatewayKeepalive.Tests/Program.cs +++ b/tests/GatewayKeepalive.Tests/Program.cs @@ -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; diff --git a/tools/unblock-publish.ps1 b/tools/unblock-publish.ps1 new file mode 100644 index 0000000..b2d6fdc --- /dev/null +++ b/tools/unblock-publish.ps1 @@ -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 提权),程序本身不联网、不安装服务、不写注册表自启项。' diff --git a/tools/verify-publish.ps1 b/tools/verify-publish.ps1 index ba31e6d..cb12979 100644 --- a/tools/verify-publish.ps1 +++ b/tools/verify-publish.ps1 @@ -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