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