diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..df5aebf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+**/bin/
+**/obj/
+.vs/
+artifacts/
+*.user
+*.log
+test-results/
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..a6264af
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,11 @@
+
+
+ net10.0-windows
+ enable
+ enable
+ latest
+ true
+ true
+ x64
+
+
diff --git a/GatewayKeepalive.sln b/GatewayKeepalive.sln
new file mode 100644
index 0000000..d7c0a10
--- /dev/null
+++ b/GatewayKeepalive.sln
@@ -0,0 +1,116 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatewayKeepalive.Core", "src\GatewayKeepalive.Core\GatewayKeepalive.Core.csproj", "{2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatewayKeepalive.Windows", "src\GatewayKeepalive.Windows\GatewayKeepalive.Windows.csproj", "{8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatewayKeepalive.Tray", "src\GatewayKeepalive.Tray\GatewayKeepalive.Tray.csproj", "{54E6EF44-96F1-48CF-B3CB-3C831746E593}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatewayKeepalive.Cli", "src\GatewayKeepalive.Cli\GatewayKeepalive.Cli.csproj", "{158421BD-A4D3-4525-AE09-9D6380B07792}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatewayKeepalive.Tests", "tests\GatewayKeepalive.Tests\GatewayKeepalive.Tests.csproj", "{94893D6A-C069-423D-838F-12AA9F88E27B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatewayKeepalive.ProbeTests", "tests\GatewayKeepalive.ProbeTests\GatewayKeepalive.ProbeTests.csproj", "{9E755A76-F85A-49D8-91CF-EFB9462B152A}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Debug|x64.Build.0 = Debug|Any CPU
+ {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Debug|x86.Build.0 = Debug|Any CPU
+ {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Release|x64.ActiveCfg = Release|Any CPU
+ {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Release|x64.Build.0 = Release|Any CPU
+ {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Release|x86.ActiveCfg = Release|Any CPU
+ {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Release|x86.Build.0 = Release|Any CPU
+ {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Debug|x64.Build.0 = Debug|Any CPU
+ {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Debug|x86.Build.0 = Debug|Any CPU
+ {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Release|x64.ActiveCfg = Release|Any CPU
+ {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Release|x64.Build.0 = Release|Any CPU
+ {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Release|x86.ActiveCfg = Release|Any CPU
+ {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Release|x86.Build.0 = Release|Any CPU
+ {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Debug|x64.Build.0 = Debug|Any CPU
+ {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Debug|x86.Build.0 = Debug|Any CPU
+ {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Release|Any CPU.Build.0 = Release|Any CPU
+ {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Release|x64.ActiveCfg = Release|Any CPU
+ {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Release|x64.Build.0 = Release|Any CPU
+ {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Release|x86.ActiveCfg = Release|Any CPU
+ {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Release|x86.Build.0 = Release|Any CPU
+ {158421BD-A4D3-4525-AE09-9D6380B07792}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {158421BD-A4D3-4525-AE09-9D6380B07792}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {158421BD-A4D3-4525-AE09-9D6380B07792}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {158421BD-A4D3-4525-AE09-9D6380B07792}.Debug|x64.Build.0 = Debug|Any CPU
+ {158421BD-A4D3-4525-AE09-9D6380B07792}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {158421BD-A4D3-4525-AE09-9D6380B07792}.Debug|x86.Build.0 = Debug|Any CPU
+ {158421BD-A4D3-4525-AE09-9D6380B07792}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {158421BD-A4D3-4525-AE09-9D6380B07792}.Release|Any CPU.Build.0 = Release|Any CPU
+ {158421BD-A4D3-4525-AE09-9D6380B07792}.Release|x64.ActiveCfg = Release|Any CPU
+ {158421BD-A4D3-4525-AE09-9D6380B07792}.Release|x64.Build.0 = Release|Any CPU
+ {158421BD-A4D3-4525-AE09-9D6380B07792}.Release|x86.ActiveCfg = Release|Any CPU
+ {158421BD-A4D3-4525-AE09-9D6380B07792}.Release|x86.Build.0 = Release|Any CPU
+ {94893D6A-C069-423D-838F-12AA9F88E27B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {94893D6A-C069-423D-838F-12AA9F88E27B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {94893D6A-C069-423D-838F-12AA9F88E27B}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {94893D6A-C069-423D-838F-12AA9F88E27B}.Debug|x64.Build.0 = Debug|Any CPU
+ {94893D6A-C069-423D-838F-12AA9F88E27B}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {94893D6A-C069-423D-838F-12AA9F88E27B}.Debug|x86.Build.0 = Debug|Any CPU
+ {94893D6A-C069-423D-838F-12AA9F88E27B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {94893D6A-C069-423D-838F-12AA9F88E27B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {94893D6A-C069-423D-838F-12AA9F88E27B}.Release|x64.ActiveCfg = Release|Any CPU
+ {94893D6A-C069-423D-838F-12AA9F88E27B}.Release|x64.Build.0 = Release|Any CPU
+ {94893D6A-C069-423D-838F-12AA9F88E27B}.Release|x86.ActiveCfg = Release|Any CPU
+ {94893D6A-C069-423D-838F-12AA9F88E27B}.Release|x86.Build.0 = Release|Any CPU
+ {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Debug|x64.Build.0 = Debug|Any CPU
+ {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Debug|x86.Build.0 = Debug|Any CPU
+ {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Release|x64.ActiveCfg = Release|Any CPU
+ {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Release|x64.Build.0 = Release|Any CPU
+ {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Release|x86.ActiveCfg = Release|Any CPU
+ {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {54E6EF44-96F1-48CF-B3CB-3C831746E593} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {158421BD-A4D3-4525-AE09-9D6380B07792} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {94893D6A-C069-423D-838F-12AA9F88E27B} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {9E755A76-F85A-49D8-91CF-EFB9462B152A} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ EndGlobalSection
+EndGlobal
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..ed1d8b1
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,156 @@
+# Gateway Keepalive:.NET 托盘版重写方案
+
+日期:2026-10-23。本文是待实施方案。
+
+源码基线:`main`,`a18914bd4e38feb2984398b7dc3619e794366697`(提交说明:重构网关切换逻辑,待测试)。已通过本机 `lion-desktop` SSH 密钥克隆。此次仅阅读源码、验证独立的参数绑定问题并编写方案,未运行原保活脚本,未修改系统网络设置。
+
+## 1. 目标与推荐选型
+
+做成一个 Windows 常驻托盘工具:检测旁路由是否可用,故障时切回主路由,恢复稳定后切回旁路由;可以看状态、手动指定模式、暂停自动切换和恢复网络配置。
+
+推荐 **C# + .NET 10 LTS + WinForms**,目标框架 `net10.0-windows`,第一版面向 x64,支持平台以 .NET 10 官方 Windows 支持矩阵为准。用 `ApplicationContext + NotifyIcon` 管理常驻生命周期,参考隔壁鼠标工具的深色弹出面板、自绘状态图标、悬停气泡和单实例唤醒。
+
+隔壁工具使用 .NET Framework 4.8 和系统 csc,优点是产物小、无需另装运行时。本项目优先使用现代 .NET 的异步网络、取消与测试支持;不以它的 82 KB 作为体积目标。发布提供两种包:本机已有 Desktop Runtime 时使用较小的依赖运行时版本,其他机器使用包含运行时的独立版本。单 exe 的原生依赖打包与解包行为需在发布阶段验证,第一版不启用裁剪或 Native AOT。
+
+规划时本机已有 .NET 10 Desktop Runtime,尚未安装 SDK。实施阶段已在用户目录准备并校验 .NET 10 SDK;实现与验证结果见 `docs/validation.md`。
+
+## 2. 原项目的实际行为
+
+| 文件 | 作用 |
+| --- | --- |
+| `GateWay-KeepAlive.ps1` | 提权、选择网卡、读取和切换默认路由、设置 DNS、运行健康检查循环 |
+| `proxy-test.ps1` | SOCKS5 无认证握手与目标 CONNECT 探测 |
+| `config.ps1` | 网关、网卡关键字、探测与防抖参数 |
+| `logger.ps1` | 调试控制台输出与 Windows 事件日志 |
+
+现有配置:主路由 `192.168.6.1`,旁路由 `192.168.6.2`,SOCKS5 `192.168.6.2:1070`;旁路由 DNS 也使用 `.2`;目标 `www.google.com:443`;网卡名称包含 `Ethernet`;每轮检查结束后等待 5 秒;连续成功/失败 3 次,切换冷却 30 秒。直连 DNS 配置为空时重置为自动获取。
+
+当前属于通过更换默认网关接入旁路由的方案,不是设置 Windows 系统代理。SOCKS5 只是旁路由能力探针;SOCKS 可用不能单独证明经旁路由转发的默认路径可用。实际故障反应时间还要加上探测耗时和冷却剩余时间,不能简单承诺为 15 秒。
+
+## 3. 重写时必须处理的问题
+
+| 源码现状 | 新版处理 |
+| --- | --- |
+| `Test-HostReachable` 使用参数 `$Host`,与 PowerShell 只读自动变量冲突 | 改成 C# 探针;已用独立参数绑定示例确认会报只读变量错误,无需执行原脚本 |
+| 先删除目标网卡所有 IPv4 默认路由,再添加新网关 | 只操作明确归属的路由,分阶段切换、读回验证、失败补偿,保留原路径到切换成功 |
+| DNS 与路由分开写入,出错没有回滚 | 保存切换前快照与操作日志;任何一步失败都进入补偿流程,恢复失败单独告警 |
+| 直连 DNS 恢复默认按 DHCP,未保存原始静态设置;`originalDns`、`TestIP` 没进入实际检查链 | 区分自动 DNS 与有序静态 DNS 列表;恢复真实基线;合并配置定义与实际使用 |
+| 根据是否等于旁路由网关判为代理/直连,第三方网关也会算直连 | 识别主路由、旁路由、外部配置、无路由和网卡断开,不擅自覆盖外部配置 |
+| 仅按网卡名称取第一个 Up 网卡,循环固定旧索引 | 首次显式选择并保存网卡 GUID;索引随枚举更新;网络环境变化重新校验 |
+| SOCKS 响应假设一次 Read 足够,连接回复固定读 10 字节 | 精确读取、检查版本/方法/返回码,按 IPv4、域名、IPv6 地址类型消费完整回复 |
+| 依赖 ICMP 且部分探测缺少统一总超时 | ICMP 默认只做诊断;网络操作均有超时、整轮预算与取消 |
+| DNS/SOCKS 成功后还可能默认路径不通,探测也可能经另一张网卡出网 | 分开显示各层结果;校验目标网卡与有效路由,必要时绑定探测源地址,切换后验证实际转发路径 |
+
+## 4. 托盘交互
+
+左键托盘图标开关面板;点外部或按 Esc 收起。首次启动打开配置向导,后续启动只驻留托盘。关闭面板继续工作;“退出程序”才结束常驻。
+
+主面板约 400 × 430 逻辑像素,最终尺寸以中文排版实测为准:
+
+```text
+网关保活 自动 · 旁路由
+当前网关 192.168.6.2 检测正常
+Ethernet · 当前 DNS 192.168.6.2
+
+旁路由连接 正常 2 ms
+旁路由 DNS 正常 12 ms
+SOCKS5 正常 180 ms
+当前出口 正常 220 ms
+
+[ 自动 ] [ 固定直连 ] [ 固定旁路由 ]
+连续失败 0/3 · 距上次切换 2 分钟
+最近事件:旁路由已恢复,自动切回
+
+[立即检测] [暂停自动切换] [设置] [日志]
+```
+
+状态图标同时使用颜色和形状/标记:绿色表示旁路由工作,蓝色表示主路由直连,黄色表示降级或切换中,红色表示网络故障,灰色表示暂停或目标网卡断开。悬停只显示当前模式、网关及最近异常,不启动额外网络检测。
+
+右键菜单:打开面板、立即检测、模式选择、暂停/继续自动切换、恢复接管前配置、登录后启动、查看日志、退出。设置使用独立窗口,包含网卡、主/旁路由、DNS 策略、探测目标与端口、阈值/间隔/冷却、提醒和退出行为。
+
+“暂停”仅停止自动改配置,仍展示网络状态;固定模式完成一次指定切换后持续监测与告警,不被自动恢复逻辑覆盖。恢复接管前配置后进入暂停,防止马上又被自动切走。默认退出停止监测并保留当前网络,提供可选的“退出时恢复接管前配置”,界面明确显示该行为。
+
+通知只在模式真正变化、故障持续、回滚失败等事件发生时提示,相同状态不每 5 秒弹一次。
+
+## 5. 后台状态机与健康检查
+
+把“用户策略”和“实际网络状态”分开保存。
+
+- 用户策略:`Auto`、`ForceDirect`、`ForceProxy`、`Paused`。
+- 网络状态:`Initializing`、`Direct`、`Proxy`、`Switching`、`Degraded`、`Disconnected`、`ExternalConfiguration`、`RecoveryRequired`。
+
+使用一个异步控制循环串行处理定时检查、立即检测、手动切换、配置更新和网卡变化事件。探针可在预算内并行,但路由/DNS 写入始终串行;立即检测请求合并,避免重复点击产生多个检查循环。通过状态快照推送到 UI 线程,窗体事件不直接改网络。
+
+自动模式保留默认的 5 秒间隔、成功/失败 3 次和 30 秒冷却,均可配置。成功与失败计数分别要求连续;进入新环境或切换完成后重置;冷却用单调时间计算,系统时间校正不影响它。失败回退和恢复旁路由可以配置不同冷却,避免恢复后的长冷却拖延紧急回退。网卡断开不是旁路由故障,断开期间停止累计与切换。
+
+健康检查分层记录耗时与失败原因:旁路由 TCP 可达、指定旁路由 DNS 查询、可选 SOCKS5 CONNECT、当前默认路径 HTTPS/TLS。DNS 查询明确指定 DNS 服务器,不能用只走系统 DNS 的 `Dns.GetHostAddressesAsync` 冒充指定服务器检测。优先封装 Windows DNS API;SOCKS5 使用 `TcpClient`/`NetworkStream` 自行实现精确帧读取。
+
+旁路由恢复候选检查即使处于直连也可以运行。切回旁路由后必须验证实际出口;未通过就回退并进入恢复退避,避免“SOCKS 好用、默认转发坏了”造成循环切换。支持多个可配置探测目标及成功策略,减少单站点故障误判;第一版默认沿用现有目标,同时在配置中明确展示它。
+
+初始每探针超时建议 1.5~3 秒,整轮预算建议 4 秒,以实测调整。超过预算记一次失败并释放 socket;下轮不与本轮重叠,不在恢复后补跑积压轮次。休眠唤醒、DHCP 更新、网络改变时清零旧计数、等待环境稳定后重新枚举。
+
+## 6. 网络接管、回滚与权限
+
+通过 Windows IP Helper API 操作 IPv4 默认路由:`GetIpForwardTable2`、`GetIpInterfaceEntry`、`CreateIpForwardEntry2`、`SetIpForwardEntry2`、`DeleteIpForwardEntry2`,用 `GetBestRoute2` 验证有效选路;通过 `GetInterfaceDnsSettings` / `SetInterfaceDnsSettings` 读取和设置 DNS。所有结构布局、字符串生命周期、返回码与原生内存释放封装在 Windows 层。DNS API 的最低系统版本为 Windows 10 Build 19041,程序同时检查运行时平台要求。
+
+切换流程:
+
+1. 验证目标网卡 GUID、当前网络指纹、网关可达范围、当前路由/DNS 与上次快照是否一致。
+2. 原子保存本次操作前快照与切换日志,包含路由 metric、接口 metric、DNS 自动/静态来源与有序列表。
+3. 准备目标路由,优先保留原路由,通过精确路由项与 metric 控制优先级,考虑全机其他接口的有效路由。
+4. 按目标模式应用 DNS 与路由优先级,读回验证两者,执行出口检查;系统没有跨路由和 DNS 的原子事务,界面显示切换中。
+5. 成功才发布新状态和记录冷却;失败按操作日志恢复刚才改过的项,读回验证;补偿失败停止自动切换并展示恢复操作。
+
+第 3 步的 metric 策略需先做原生 API 原型验证:DHCP 路由可能被系统重建,不能靠假设永久提高一次 metric 就一直有效;不能影响 VPN、其他网卡、特定前缀路由或 IPv6。第一版只管理选定网卡的 IPv4 默认路径,面板明确标注这个范围,不能把 IPv6 绕行误报为 IPv4 代理成功。
+
+“切换前快照”与“接管前直连基线”分开保存。若启动时已处于旁路由,不能把当前旁路由误存成直连基线;优先使用同一网络且验证过的历史直连基线,或要求配置主路由与 DNS 策略。不同网络环境不重放旧快照。发现程序外修改时进入外部配置状态,暂停写入并允许重新接管。
+
+路由写入需要管理员权限。第一版采用一个管理员权限托盘进程,启动时提权,拒绝提权则明确退出,不启动半工作的自动切换。登录自启使用任务计划程序,以当前用户、交互式登录、最高权限运行;首次创建/修改该计划在已提权进程中完成。不能直接套用鼠标工具的 HKCU Run 启动方式,也不使用无交互 Session 0 运行托盘。
+
+不在第一版额外引入 Windows 服务。以后若需要未登录也保活,再拆成低权限托盘 + 管理员服务;现有 Core/Windows 边界便于迁移。崩溃后程序本身无法立即回滚,第一版靠持久化日志在下次启动时识别未完成切换,验证现场后进入恢复流程,不能承诺进程退出后仍有实时恢复能力。
+
+## 7. 工程划分与数据
+
+```text
+GatewayKeepalive.sln
+src/
+ GatewayKeepalive.Core/ 配置模型、状态机、切换计划、探针/时钟/系统接口
+ GatewayKeepalive.Windows/ 网卡枚举、路由/DNS API、权限、任务计划
+ GatewayKeepalive.Tray/ Program、ApplicationContext、托盘、面板、设置、日志
+tests/
+ GatewayKeepalive.Tests/ 状态机、故障注入、SOCKS5 假服务、配置迁移
+docs/
+ network-behavior.md 网络修改范围、恢复语义、手动恢复步骤
+```
+
+Core 不依赖 WinForms。关键接口包括 `INetworkConfiguration`、`IHealthProbe`、`ISnapshotStore` 和可替换时钟;用假实现验证切换,不依赖真实网关。
+
+配置放 `%LocalAppData%\GatewayKeepalive\settings.json`,保留 schemaVersion、网卡 GUID、网关、DNS 策略、探测与 UI 设置;快照和未完成切换日志独立保存。所有持久化采用临时文件写入再替换、校验与必要备份。按天滚动文件日志记录探测摘要、切换原因、系统错误码和恢复结果,设置保留期限;Windows 事件日志作为可选集成。
+
+原 `config.ps1` 保留为历史参考。迁移只映射明确支持的赋值项,遇到表达式则让用户手动填写;不通过执行 PowerShell 配置来导入。首次展示原配置中的 `.1`、`.2`、1070 和阈值供确认,不能仅凭名称猜中网卡就开始改网络。
+
+参考隔壁的 `TrayApp.cs`、`PanelForm.cs`、`Theme.cs`、`DarkForm.cs`、`TextTip.cs`、`TrayIconRenderer.cs` 和单实例交互;保留体验,按现代 WinForms 生命周期重构。使用 PerMonitorV2 和随目标显示器 DPI 更新布局,避免直接照搬隔壁的系统 DPI 固定缩放;悬停定时器仅气泡显示时运行。
+
+## 8. 实施顺序与验收
+
+| 阶段 | 交付与验收 |
+| --- | --- |
+| 1:只读诊断 | SDK/解决方案、网卡和路由/DNS快照、分层探针、`--diagnose`。不写系统设置,展示实际选路与失败原因 |
+| 2:可恢复切换 | 原生 API 原型、手动切换、操作日志与补偿、`--dry-run`。故障注入验证任一步失败均不冒报成功;真实双网关环境验证读回和回退 |
+| 3:自动保活 | 状态机、防抖、冷却、恢复退避、网络变更/唤醒处理。验证 2 次失败不切、3 次达阈值才切、冷却阻止抖动、固定/暂停策略不被覆盖 |
+| 4:托盘成品 | 面板、设置、单实例、图标/气泡、滚动日志、登录自启与发布。验证 100%/150%/200% 缩放、混合 DPI 多屏、Explorer 重启、退出语义与注销行为 |
+
+有价值的自动测试:用假时钟检验计数与冷却;在路由成功/DNS失败、DNS成功/出口失败、补偿失败等边界注入故障;本地 SOCKS5 假服务器分片、截断、延迟、返回不同地址类型;验证旧快照不会跨网卡/跨网络恢复。测试不修改开发机真实网络。
+
+真机集成覆盖旁路由关闭、DNS服务停止、SOCKS端口关闭、默认转发失效但SOCKS正常、目标站点单独故障、拔网线、DHCP续租、VPN共存、双网卡、重启程序时仍在旁路由模式。第一阶段完成后再进入真实网络切换验收;当前没有实际测量性能、执行故障演练或构建新版。
+
+第一版完成标准:平时托盘常驻;自动回退/恢复原因清楚;手动模式稳定;任何写入失败都有可验证的恢复结果;未知网络不自动套用旧配置;能够从日志解释每一次切换。
+
+## 9. 官方技术依据
+
+- [.NET 支持策略](https://dotnet.microsoft.com/en-us/platform/support/policy):.NET 10 为 LTS,支持至 2028 年 11 月。
+- [WinForms NotifyIcon](https://learn.microsoft.com/en-us/dotnet/desktop/winforms/controls/notifyicon-component-windows-forms):托盘图标、菜单与后台进程交互。
+- [SetIpForwardEntry2](https://learn.microsoft.com/en-us/windows/win32/api/netioapi/nf-netioapi-setipforwardentry2):管理员要求、路由项键与路由/接口 metric 的关系。
+- [GetInterfaceDnsSettings](https://learn.microsoft.com/en-us/windows/win32/api/netioapi/nf-netioapi-getinterfacednssettings) / [SetInterfaceDnsSettings](https://learn.microsoft.com/en-us/windows/win32/api/netioapi/nf-netioapi-setinterfacednssettings):接口 DNS 读写与最低系统版本。
+- [任务计划的安全上下文](https://learn.microsoft.com/en-us/windows/win32/taskschd/security-contexts-for-running-tasks):交互式登录与最高权限注册要求。
+- [.NET 单文件部署](https://learn.microsoft.com/en-us/dotnet/core/deploying/single-file/overview):依赖运行时/独立部署、原生依赖解包与体积取舍。
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..682d41c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,71 @@
+# 网关保活 · .NET 托盘版
+
+Windows 上的主路由 / 旁路由保活工具。旁路由连续检测失败时回退直连,稳定恢复后切回旁路由;也可以固定模式或暂停自动切换。
+
+基于 [PowerShell 原项目](https://git.lionwebsite.xyz/lion/Gateway-keepaliveForPowershell) 重写,使用 C#、.NET 10、WinForms。托盘交互参考相邻的雷蛇鼠标控制器项目。完整设计见 [PLAN.md](PLAN.md)。
+
+## 当前版本
+
+这是 `0.1.0` 初版,包含托盘面板、中文设置、分层探测、自动策略、可补偿的路由/DNS切换、快照恢复、日志与最高权限登录任务。
+
+默认未配置且暂停。首次运行必须显式选择网卡并保存,程序不会按网卡名字猜测并立即修改网络。只管理选择网卡的 IPv4 默认路径;IPv6、VPN 路由和其他网卡不接管。
+
+
+
+| 故障回退到主路由 | 首次未配置 |
+| --- | --- |
+|  |  |
+
+设置分为连接、保活规则、检测与提醒三页,默认只展示当前页需要的选项。
+
+| 连接 | 保活规则 | 检测与提醒 |
+| --- | --- | --- |
+|  |  |  |
+
+## 运行
+
+使用 Windows x64。依赖运行时版需要 .NET 10 Desktop Runtime,独立版包含运行时。托盘模式会请求管理员权限;只读诊断和预览不提权。
+
+- 左键图标:显示/隐藏面板;Esc 或点外部:收起面板。
+- 自动:按连续阈值和冷却切换;直连/旁路由:固定模式,失败时提示。
+- 暂停:继续监测,停止自动改网络。
+- 恢复:恢复接管前快照并暂停。存在未完成切换时,先处理那次切换的恢复。
+- 默认退出保留当前网络;“退出时恢复”可在设置中开启。
+- 登录后启动:创建当前用户的交互式最高权限计划任务,程序路径移动后重新设置。
+
+首次设置的示例网关为 `192.168.6.1` / `192.168.6.2`,SOCKS5 端口为 `1070`,请按实际网络调整。旁路由探测默认使用 `www.google.com`;直连探测另用 `www.baidu.com`、`www.microsoft.com`,避免直连无法访问代理目标导致错误回滚。默认各要求至少一个目标成功。
+
+## 构建与测试
+
+安装 .NET 10 SDK 后运行:
+
+```powershell
+.\build.ps1 -Test
+.\build.ps1 -Test -Publish # 依赖运行时版
+.\build.ps1 -Test -Publish -SelfContained # 包含运行时版
+```
+
+脚本优先使用 `$env:GATEWAY_DOTNET`、`$env:USERPROFILE\.dotnet-sdk\dotnet.exe`,其次使用 PATH 中的 dotnet。产物在 `artifacts/publish/`,编译与测试不会修改系统网络。
+
+测试不依赖外部 NuGet 包,采用有退出码的控制台测试集,覆盖状态机、故障补偿、外部修改保护、持久化、SOCKS5 分片和 DNS 报文边界;不是用 `dotnet test` 运行。当前结果见 [验证记录](docs/validation.md)。
+
+## 只读诊断
+
+发布目录的 `diagnostics/GatewayKeepalive.Cli.exe` 不修改网络,也不创建自启任务:
+
+```powershell
+.\GatewayKeepalive.Cli.exe --diagnose --output report.json
+.\GatewayKeepalive.Cli.exe --probe --adapter <网卡GUID>
+.\GatewayKeepalive.Cli.exe --dry-run --adapter <网卡GUID> --proxy
+.\GatewayKeepalive.Cli.exe --dry-run --adapter <网卡GUID> --direct
+```
+
+`--config 路径` 指定 JSON 配置;`--probe` 会发送 DNS / SOCKS5 / TLS 网络请求,仍不会改网关。`--dry-run` 只生成计划。托盘 exe 也支持上述参数与 `--preview 目录`。
+
+## 数据与恢复
+
+数据位于 `%LocalAppData%\GatewayKeepalive\`:`settings.json`、`session.json`、`switch-journal.json`、`logs/`。JSON 采用临时文件写入、刷新落盘与替换,保留上一版 `.bak`。日志保留 14 天。
+
+路由与 DNS 没有系统级跨操作原子事务。程序写前保存记录,分步写入、读回验证并探测真实 IPv4 TLS 出口;失败时补偿。发现外部修改、陌生网络或补偿失败会停止自动写入。进程被强行终止后,需要下次启动识别未完成记录再恢复;没有独立后台服务替已退出的进程实时修复网络。
+
+接管范围与手动恢复步骤见 [网络行为说明](docs/network-behavior.md)。界面截图在 `artifacts/preview/`(`./build.ps1` 后运行托盘程序 `--preview <目录>` 可重新生成)。
diff --git a/build.ps1 b/build.ps1
new file mode 100644
index 0000000..28c5fef
--- /dev/null
+++ b/build.ps1
@@ -0,0 +1,37 @@
+param(
+ [switch]$Test,
+ [switch]$Publish,
+ [switch]$SelfContained
+)
+$ErrorActionPreference = 'Stop'
+$sdkCandidate = Join-Path $env:USERPROFILE '.dotnet-sdk\dotnet.exe'
+$dotnet = if ($env:GATEWAY_DOTNET) { $env:GATEWAY_DOTNET } elseif (Test-Path -LiteralPath $sdkCandidate) { $sdkCandidate } else { (Get-Command dotnet -ErrorAction Stop).Source }
+$previousRoot = $env:DOTNET_ROOT
+$previousTelemetry = $env:DOTNET_CLI_TELEMETRY_OPTOUT
+try {
+ $env:DOTNET_ROOT = Split-Path -Parent $dotnet
+ $env:DOTNET_CLI_TELEMETRY_OPTOUT = '1'
+ & $dotnet build (Join-Path $PSScriptRoot 'GatewayKeepalive.sln') -c Release --nologo
+ if ($LASTEXITCODE -ne 0) { throw '构建失败。' }
+ if ($Test) {
+ foreach ($project in @('GatewayKeepalive.Tests', 'GatewayKeepalive.ProbeTests')) {
+ & $dotnet run --project (Join-Path $PSScriptRoot "tests\$project\$project.csproj") -c Release --no-build
+ if ($LASTEXITCODE -ne 0) { throw "$project 验证失败。" }
+ }
+ }
+ if ($Publish) {
+ $flavor = if ($SelfContained) { 'standalone' } else { 'runtime-dependent' }
+ $destination = Join-Path $PSScriptRoot "artifacts\publish\$flavor"
+ $contained = if ($SelfContained) { 'true' } else { 'false' }
+ & $dotnet publish (Join-Path $PSScriptRoot 'src\GatewayKeepalive.Tray\GatewayKeepalive.Tray.csproj') -c Release -r win-x64 --self-contained $contained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=embedded -o $destination
+ if ($LASTEXITCODE -ne 0) { throw '托盘版发布失败。' }
+ $cli = Join-Path $destination 'diagnostics'
+ & $dotnet publish (Join-Path $PSScriptRoot 'src\GatewayKeepalive.Cli\GatewayKeepalive.Cli.csproj') -c Release -r win-x64 --self-contained $contained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=embedded -o $cli
+ if ($LASTEXITCODE -ne 0) { throw '只读诊断工具发布失败。' }
+ Write-Host "发布完成:$destination"
+ }
+}
+finally {
+ $env:DOTNET_ROOT = $previousRoot
+ $env:DOTNET_CLI_TELEMETRY_OPTOUT = $previousTelemetry
+}
diff --git a/docs/network-behavior.md b/docs/network-behavior.md
new file mode 100644
index 0000000..33dd15a
--- /dev/null
+++ b/docs/network-behavior.md
@@ -0,0 +1,35 @@
+# 网络修改与恢复
+
+## 修改范围
+
+仅处理显式选择网卡的 IPv4 `0.0.0.0/0` 默认路由和 IPv4 接口 DNS。不会修改 IPv6、代理注册表、网卡 IP 地址、其他网卡、VPN 或特定前缀路由。
+
+旁路由模式为目标网关准备 metric 1 的默认路由,保留该网卡原路由并把其他受管默认路由 metric 至少提高到 101。直连模式采取相同优先级策略。Windows 有效 metric 还包含接口 metric;最终通过全机有效选路和绑定源 IP 的 TLS 探测验证,若 VPN / 其他路径抢占则不冒报切换成功。
+
+路由位于活动配置,未主动写入持久路由。DHCP 更新或外部程序更改配置时,快照比较会阻止继续覆盖,需核对设置后重新保存以接管。未连接网卡不累计旁路由故障计数。
+
+## 网络身份
+
+未接管诊断使用 Windows 网络配置文件 ID;接管后使用网卡 GUID、IPv4 子网与已配置主路由的 ARP 硬件地址作为网络指纹,防止本程序更换默认网关导致 NLM 配置文件变化而误判换网。无法验证主路由身份时拒绝写入。该识别用于避免误操作,不是抵御恶意局域网的身份认证。
+
+同一网络内接口索引变化也会停止复用旧快照。静态 DNS 与自动 DNS 分开保存;默认直连恢复接管时的真实 DNS,也可明确选择自动/静态直连 DNS。网络配置文件层的 DNS 不覆盖。
+
+## 切换日志
+
+写入前持久化 `switch-journal.json`,保存 Before/Intended 与阶段。新路由先准备,再改变优先级和 DNS,最后读回并验证出口;只有控制状态已落盘才删除切换日志。补偿只接受仍处于该事务前值/目标值组合的现场,发现外部路由或 DNS 不继续覆盖。
+
+程序崩溃后不会有仍在运行的恢复服务。下次启动显示“需恢复”,点击恢复处理未完成切换。记录包含原网卡、路由与 DNS,请在查清故障前保留。
+
+## 手动核对
+
+以管理员 PowerShell 读取当前网卡、默认路由和 DNS:
+
+```powershell
+Get-NetAdapter
+Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0'
+Get-DnsClientServerAddress -AddressFamily IPv4
+```
+
+与 `session.json` 中的 Baseline 和 `switch-journal.json` 中的 Before 比较。恢复具体接口时只处理快照中该接口的具体路由行,不删除全机默认路由。DNS 的 `Automatic=true` 表示恢复自动获取,否则恢复 Servers 中原有顺序。优先使用程序的恢复功能,它会验证接口、网络身份、操作归属和读回结果。
+
+如网络已经改变或原主路由无法识别,程序会拒绝自动重放。先由使用者在 Windows 网络设置中修复连通性,再核对配置并重新接管;不要删日志来强行跳过恢复保护。
diff --git a/docs/screenshots/gateway-panel-diagnostics-expanded.png b/docs/screenshots/gateway-panel-diagnostics-expanded.png
new file mode 100644
index 0000000..7844c58
Binary files /dev/null and b/docs/screenshots/gateway-panel-diagnostics-expanded.png differ
diff --git a/docs/screenshots/gateway-panel-fault-direct.png b/docs/screenshots/gateway-panel-fault-direct.png
new file mode 100644
index 0000000..08c055c
Binary files /dev/null and b/docs/screenshots/gateway-panel-fault-direct.png differ
diff --git a/docs/screenshots/gateway-panel-healthy-proxy.png b/docs/screenshots/gateway-panel-healthy-proxy.png
new file mode 100644
index 0000000..8ee5cf4
Binary files /dev/null and b/docs/screenshots/gateway-panel-healthy-proxy.png differ
diff --git a/docs/screenshots/gateway-panel-paused-initial.png b/docs/screenshots/gateway-panel-paused-initial.png
new file mode 100644
index 0000000..71ea5d0
Binary files /dev/null and b/docs/screenshots/gateway-panel-paused-initial.png differ
diff --git a/docs/screenshots/gateway-settings-detection.png b/docs/screenshots/gateway-settings-detection.png
new file mode 100644
index 0000000..ce836cd
Binary files /dev/null and b/docs/screenshots/gateway-settings-detection.png differ
diff --git a/docs/screenshots/gateway-settings-preview.png b/docs/screenshots/gateway-settings-preview.png
new file mode 100644
index 0000000..a15fc0c
Binary files /dev/null and b/docs/screenshots/gateway-settings-preview.png differ
diff --git a/docs/screenshots/gateway-settings-rules.png b/docs/screenshots/gateway-settings-rules.png
new file mode 100644
index 0000000..40b8c84
Binary files /dev/null and b/docs/screenshots/gateway-settings-rules.png differ
diff --git a/docs/validation.md b/docs/validation.md
new file mode 100644
index 0000000..3becbec
--- /dev/null
+++ b/docs/validation.md
@@ -0,0 +1,26 @@
+# 验证记录
+
+日期:2026-10-23。环境:Windows 10/11 x64,开发机网卡 `以太网`(索引 16),IPv4 地址 `192.168.6.119/24`,主路由 `192.168.6.1`。
+
+## 已实际验证
+
+- 解决方案 Debug/Release 构建:0 警告、0 错误(`TreatWarningsAsErrors` 已开启)。
+- 核心离线测试:45/45 通过,覆盖状态机计数与冷却、切换故障补偿、外部修改保护、跨网络恢复拒绝、无变化跳过写入、持久的暂停状态、Stop 恢复基线、写入前现场复核、原子 JSON 持久化与原生结构布局。
+- 探测离线测试:16/16 通过,覆盖 SOCKS5 分片/截断/慢读取消/多种地址类型、DNS 压缩与 TCP 回退、畸形响应、指定 DNS 与系统 DNS 分离、旁路由候选不依赖当前出口、按实际网关选择出口探测目标。
+- 只读诊断:成功读取网卡、IPv4 默认路由、DNS 配置和有效选路;`NativeLayout` 为 104/168/64 字节,路由 metric 偏移 84,接口 metric 偏移 148,与 x64 SDK 结构一致。运行前后系统网关保持 `192.168.6.1`。
+- 切换计划预演(`--dry-run --proxy`):生成目标 metric 1 的旁路由默认路由,原主路由降为 metric 101,DNS 指向 `192.168.6.2`,未写系统。
+- 真实分层探测:主路由 TLS 出口 2/2 通过;`192.168.6.2` 的 TCP/DNS/SOCKS 探测超时,判为旁路由不可用,符合当前设备实际状态。
+- 发布包:依赖运行时单文件约 490 KB,独立单文件约 116 MB;两者托盘 `--preview` 均能启动并输出 10 张界面图,无异常输出;独立版只读诊断通过。
+- 界面预览渲染:主面板正常/故障回退/未配置、诊断展开,设置三页及滚动后状态,均在 200% 缩放下检查过无裁切、无浅色系统控件。
+
+## 未验证,需真机确认
+
+以下都需要真实管理员的网络写入,本次刻意未执行,以免改变用户当前网络:
+
+- 实际切换到旁路由并读回路由/DNS/出口;旁路由断开后的自动回退;恢复后回切旁路由。
+- 旁路由 DNS 正常但默认转发失效、SOCKS5 正常但转发失效等不对称故障。
+- VPN 或其他网卡同时持有默认路由、DHCP 续租、拔插网线、休眠唤醒后的行为。
+- 登录后最高权限计划任务的创建、注销/重启后启动,以及程序移动路径后的重新设置。
+- 崩溃后重启识别未完成切换日志的实际恢复流程。
+
+首次在目标机启用前,建议先运行发布的 `diagnostics/GatewayKeepalive.Cli.exe --diagnose`,确认网卡 GUID、主/旁路由和 DNS 与计划一致,再用 `--dry-run` 核对计划,最后通过托盘界面保存并手动启用自动保活。
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..caf4b9b
--- /dev/null
+++ b/global.json
@@ -0,0 +1 @@
+{"sdk":{"version":"10.0.100","rollForward":"latestFeature"}}
diff --git a/src/GatewayKeepalive.Cli/GatewayKeepalive.Cli.csproj b/src/GatewayKeepalive.Cli/GatewayKeepalive.Cli.csproj
new file mode 100644
index 0000000..4f02637
--- /dev/null
+++ b/src/GatewayKeepalive.Cli/GatewayKeepalive.Cli.csproj
@@ -0,0 +1,4 @@
+
+ Exe
+
+
diff --git a/src/GatewayKeepalive.Cli/Program.cs b/src/GatewayKeepalive.Cli/Program.cs
new file mode 100644
index 0000000..dc597bb
--- /dev/null
+++ b/src/GatewayKeepalive.Cli/Program.cs
@@ -0,0 +1,8 @@
+using GatewayKeepalive.Windows;
+
+if (args.Length == 0 || args.Contains("--help"))
+{
+ Console.WriteLine("网关保活只读诊断\n --diagnose [--adapter GUID] [--output report.json]\n --probe --adapter GUID [--config settings.json]\n --dry-run --adapter GUID [--direct|--proxy] [--config settings.json]\n此工具只读取/探测网络和生成计划,不修改路由、DNS或启动项。");
+ return 0;
+}
+return await DiagnosticCommands.RunAsync(args);
diff --git a/src/GatewayKeepalive.Core/GatewayController.cs b/src/GatewayKeepalive.Core/GatewayController.cs
new file mode 100644
index 0000000..ed35fab
--- /dev/null
+++ b/src/GatewayKeepalive.Core/GatewayController.cs
@@ -0,0 +1,237 @@
+namespace GatewayKeepalive.Core;
+
+public sealed class GatewayController : ITrayController
+{
+ private readonly INetworkBackend _backend;
+ private readonly IHealthProbe _probe;
+ private readonly JsonStore _store;
+ private readonly SwitchService _switcher;
+ private readonly SwitchPolicy _policy = new();
+ private readonly Action _log;
+ private readonly Func _getAutostart;
+ private readonly Action _setAutostart;
+ private readonly SemaphoreSlim _gate = new(1, 1);
+ private readonly CancellationTokenSource _stop = new();
+ private Task? _loop;
+ private SessionState? _session;
+ private AppSettings _settings;
+ private AppStatus _status = AppStatus.Initial;
+ private bool _sessionLoaded;
+ private bool _stopped;
+ private int _refreshPending;
+ private DateTimeOffset? _lastSwitchAt;
+ private long? _lastHealthCheckTimestamp;
+ private string? _lastEvent;
+ private DateTimeOffset? _lastEventAt;
+ public AppSettings Settings => _settings;
+ public AppStatus Status => _status;
+ public string DataDirectory => _store.DirectoryPath;
+ public bool AutostartEnabled => _getAutostart();
+ public event Action? StatusChanged;
+
+ public GatewayController(INetworkBackend backend, IHealthProbe probe, JsonStore store, Action log, Func? getAutostart = null, Action? setAutostart = null)
+ {
+ _backend = backend; _probe = probe; _store = store; _log = log;
+ _getAutostart = getAutostart ?? (() => false);
+ _setAutostart = setAutostart ?? (_ => throw new NotSupportedException("自启不可用。"));
+ _settings = store.Read("settings.json") ?? new();
+ _settings.Validate(false);
+ _backend.ConfigureIdentity(_settings);
+ _session = store.Read("session.json");
+ _sessionLoaded = true;
+ _switcher = new(backend, probe, store, Log);
+ }
+
+ public void Start()
+ {
+ if (_loop is not null) return;
+ _loop = Task.Run(async () =>
+ {
+ while (!_stop.IsCancellationRequested)
+ {
+ try { await RefreshAsync(); }
+ catch (OperationCanceledException) when (_stop.IsCancellationRequested) { break; }
+ catch (Exception e) { Log("后台检查异常:" + e); Publish(_status with { Mode = NetworkMode.Degraded, Message = e.Message }); }
+ try { await Task.Delay(TimeSpan.FromSeconds(_settings.IntervalSeconds), _stop.Token); }
+ catch (OperationCanceledException) { break; }
+ }
+ });
+ }
+
+ public Task> ListAdaptersAsync() => Task.Run(() => _backend.ListAdaptersAsync(_stop.Token));
+ public void SetAutostart(bool enabled) => _setAutostart(enabled);
+
+ public async Task RefreshAsync()
+ {
+ if (Interlocked.Exchange(ref _refreshPending, 1) != 0) return;
+ try { await Locked(CheckCoreAsync); }
+ finally { Interlocked.Exchange(ref _refreshPending, 0); }
+ }
+
+ private async Task CheckCoreAsync()
+ {
+ var now = TimeProvider.System.GetTimestamp();
+ if (_lastHealthCheckTimestamp is { } previous && TimeProvider.System.GetElapsedTime(previous, now).TotalSeconds > Math.Max(30, _settings.IntervalSeconds * 3 + _settings.RoundTimeoutMs / 1000))
+ {
+ _policy.Reset();
+ Log("检测中断较久(可能休眠唤醒),清零连续计数并重新确认网络。");
+ }
+ _lastHealthCheckTimestamp = now;
+ if (!_settings.Configured) { Publish(AppStatus.Initial); return; }
+ NetworkSnapshot current;
+ try { current = await _backend.CaptureAsync(_settings.AdapterId, _stop.Token); }
+ catch (Exception e) when (e is not OperationCanceledException)
+ {
+ _policy.Reset();
+ Publish(_status with { Mode = NetworkMode.Disconnected, Network = null, Health = null, Message = e.Message, SuccessCount = 0, FailureCount = 0 }); return;
+ }
+ if (!_sessionLoaded) { _session = _store.Read("session.json"); _sessionLoaded = true; }
+ var journal = _store.Read(SwitchService.JournalFile);
+ if (journal is not null)
+ {
+ if (journal.Stage == "Verified" && _session is not null && NetworkComparison.SameConfiguration(_session.Expected, current) && NetworkComparison.SameConfiguration(journal.Intended, current)) _store.Delete(SwitchService.JournalFile);
+ else { _policy.Reset(); Publish(new(NetworkMode.RecoveryRequired, ControlPolicy.Paused, current, null, 0, 0, "发现未完成切换,请点击恢复处理;自动写入已停止。", _lastSwitchAt)); return; }
+ }
+ if (!current.Adapter.Up || current.Adapter.Addresses.Length == 0) { _policy.Reset(); Publish(new(NetworkMode.Disconnected, _settings.Policy, current, null, 0, 0, "目标网卡未连接", _lastSwitchAt)); return; }
+ if (_session is null || _session.SettingsFingerprint != SettingsFingerprint(_settings) || !NetworkComparison.SameConfiguration(_session.Expected, current))
+ {
+ _policy.Reset(); Publish(new(NetworkMode.ExternalConfiguration, ControlPolicy.Paused, current, null, 0, 0, "网络或配置发生变化,请核对设置并重新保存以接管。", _lastSwitchAt)); return;
+ }
+ var actualMode = Classify(current);
+ if (actualMode == NetworkMode.ExternalConfiguration) { _policy.Reset(); Publish(new(actualMode, ControlPolicy.Paused, current, null, 0, 0, "当前网关不是配置的主/旁路由。", _lastSwitchAt)); return; }
+ var health = await _probe.CheckAsync(_settings, current, _stop.Token);
+ var decision = _policy.Evaluate(_settings, actualMode, health);
+ var degraded = !health.InternetHealthy || (actualMode == NetworkMode.Proxy && !health.ProxyHealthy);
+ Publish(new(degraded ? NetworkMode.Degraded : actualMode, _settings.Policy, current, health, _policy.SuccessCount, _policy.FailureCount,
+ !health.InternetHealthy ? "当前 IPv4 出口检测失败" : actualMode == NetworkMode.Proxy && !health.ProxyHealthy ? "旁路由健康检查失败,当前出口仍可达" : actualMode == NetworkMode.Direct && !health.ProxyHealthy ? "主路由直连正常,旁路由暂不可用" : _settings.Policy == ControlPolicy.Paused ? "已暂停自动切换,继续监测" : "检测正常", _lastSwitchAt));
+ Log($"检查 {actualMode} 旁路由={health.ProxyHealthy} 出口={health.InternetHealthy} 成功={_policy.SuccessCount} 失败={_policy.FailureCount}");
+ if (decision.HasValue)
+ {
+ try { await SwitchCoreAsync(decision.Value); }
+ catch (Exception e) when (e is not OperationCanceledException) { Log("自动切换失败:" + e.Message); }
+ }
+ }
+
+ public Task SaveSettingsAsync(AppSettings settings) => Locked(async () =>
+ {
+ settings.Validate();
+ if (_store.Read(SwitchService.JournalFile) is not null) throw new RecoveryRequiredException("请先恢复未完成的切换。");
+ _backend.ConfigureIdentity(settings);
+ var snapshot = await _backend.CaptureAsync(settings.AdapterId, _stop.Token);
+ _switcher.Plan(settings, snapshot, false, null);
+ _switcher.Plan(settings, snapshot, true, null);
+ var sameSession = _session is not null && NetworkComparison.SameConfiguration(_session.Expected, snapshot);
+ var baseline = sameSession ? _session!.Baseline : snapshot;
+ var originalDirectDns = snapshot.Gateway == settings.DirectGateway ? snapshot.Dns : sameSession ? _session!.DirectDns : null;
+ if (settings.PreserveOriginalDirectDns && originalDirectDns is null) throw new InvalidOperationException("当前已在旁路由且没有可信的原直连 DNS。请取消“直连恢复原 DNS”,明确选择自动或静态 DNS 后保存。");
+ var directDns = settings.PreserveOriginalDirectDns ? originalDirectDns : settings.DirectDnsAutomatic ? DnsConfiguration.Auto : new(false, settings.DirectDnsServers, []);
+ var newSession = new SessionState(baseline, snapshot, directDns, SettingsFingerprint(settings));
+ _store.Write("session.json", newSession);
+ _store.Write("settings.json", settings);
+ _session = newSession; _sessionLoaded = true; _settings = settings;
+ _policy.Reset();
+ Publish(new(Classify(snapshot), settings.Policy, snapshot, null, 0, 0, "配置已保存并接管当前网络", _lastSwitchAt));
+ if (settings.Policy is ControlPolicy.ForceDirect or ControlPolicy.ForceProxy) await SwitchCoreAsync(settings.Policy == ControlPolicy.ForceProxy);
+ });
+
+ public Task SetPolicyAsync(ControlPolicy policy) => Locked(async () =>
+ {
+ if (!Enum.IsDefined(policy)) throw new ArgumentException("模式无效。");
+ if (!_settings.Configured) throw new InvalidOperationException("请先打开设置并选择网卡。");
+ var current = await _backend.CaptureAsync(_settings.AdapterId, _stop.Token);
+ if (_session is null || _session.SettingsFingerprint != SettingsFingerprint(_settings) || !NetworkComparison.SameConfiguration(_session.Expected, current)) throw new InvalidOperationException("请核对并重新保存设置后接管当前网络。");
+ if (_store.Read(SwitchService.JournalFile) is not null) throw new RecoveryRequiredException("请先处理未完成切换。");
+ var changed = _settings with { Policy = policy };
+ _store.Write("settings.json", changed); _settings = changed; _policy.Reset();
+ Publish(_status with { Policy = policy, Message = "已切换控制策略" });
+ if (policy is ControlPolicy.ForceDirect or ControlPolicy.ForceProxy) await SwitchCoreAsync(policy == ControlPolicy.ForceProxy);
+ });
+
+ private async Task SwitchCoreAsync(bool proxy)
+ {
+ if (_session is null) throw new InvalidOperationException("尚未接管网络。");
+ Publish(_status with { Mode = NetworkMode.Switching, Message = proxy ? "正在切换旁路由…" : "正在恢复直连…" });
+ try
+ {
+ var actual = await _switcher.SwitchAsync(_settings, _session.Expected, proxy, _session.DirectDns, _stop.Token);
+ var session = _session with { Expected = actual };
+ _store.Write("session.json", session); _session = session;
+ _store.Delete(SwitchService.JournalFile);
+ _lastSwitchAt = DateTimeOffset.Now; _policy.Switched();
+ Publish(new(proxy ? NetworkMode.Proxy : NetworkMode.Direct, _settings.Policy, actual, null, 0, 0, proxy ? "已切换旁路由并验证出口" : "已恢复主路由直连并验证出口", _lastSwitchAt));
+ }
+ catch (Exception e)
+ {
+ _policy.Failed();
+ var recovery = e is RecoveryRequiredException || _store.Read(SwitchService.JournalFile) is not null;
+ if (recovery) { var paused = _settings with { Policy = ControlPolicy.Paused }; _store.Write("settings.json", paused); _settings = paused; }
+ Publish(_status with { Mode = recovery ? NetworkMode.RecoveryRequired : Classify(_session.Expected), Policy = _settings.Policy, Message = e.Message, SuccessCount = 0, FailureCount = 0 });
+ throw;
+ }
+ }
+
+ public Task RestoreAsync() => Locked(async () =>
+ {
+ // Pause must reach durable storage BEFORE any restore can change the network.
+ var settings = _settings with { Policy = ControlPolicy.Paused };
+ _store.Write("settings.json", settings); _settings = settings; _policy.Reset();
+ var journal = _store.Read(SwitchService.JournalFile);
+ NetworkSnapshot restored;
+ if (journal is not null) restored = await _switcher.RollbackAsync(journal, _stop.Token, retainJournal: true);
+ else
+ {
+ if (_session is null) throw new InvalidOperationException("没有可恢复的接管前快照。");
+ restored = await _switcher.RestoreBaselineAsync(_session.Expected, _session.Baseline, _stop.Token);
+ }
+ var session = _session is not null && NetworkComparison.SameNetwork(_session.Baseline, restored) ? _session with { Expected = restored } : new SessionState(restored, restored, null, SettingsFingerprint(_settings));
+ _store.Write("session.json", session); _session = session; _sessionLoaded = true;
+ _store.Delete(SwitchService.JournalFile); _policy.Reset();
+ Publish(new(Classify(restored), ControlPolicy.Paused, restored, null, 0, 0, "配置已恢复并暂停自动切换", _lastSwitchAt));
+ });
+
+ public async Task StopAsync()
+ {
+ if (_stopped) return;
+ await Locked(() =>
+ {
+ if (!_sessionLoaded) { _session = _store.Read("session.json"); _sessionLoaded = true; }
+ return Task.CompletedTask;
+ });
+ // Restore while the serial gate still accepts work; failure leaves the application available.
+ if (_settings.RestoreOnExit && (_session is not null || _store.Read(SwitchService.JournalFile) is not null))
+ {
+ await RestoreAsync();
+ if (_session is not null && !NetworkComparison.SameConfiguration(_session.Expected, _session.Baseline)) await RestoreAsync();
+ }
+ _stop.Cancel();
+ if (_loop is not null) await _loop;
+ await _gate.WaitAsync();
+ try { _stopped = true; }
+ finally { _gate.Release(); }
+ }
+
+ private Task Locked(Func work) => Task.Run(async () =>
+ {
+ await _gate.WaitAsync(_stop.Token);
+ try { if (_stopped) return; await work(); }
+ finally { _gate.Release(); }
+ });
+
+ private NetworkMode Classify(NetworkSnapshot snapshot) => snapshot.Gateway == _settings.ProxyGateway ? NetworkMode.Proxy : snapshot.Gateway == _settings.DirectGateway ? NetworkMode.Direct : NetworkMode.ExternalConfiguration;
+ private static string SettingsFingerprint(AppSettings settings) => Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(settings with { Policy = ControlPolicy.Paused, Notifications = true, RestoreOnExit = false }, JsonStore.Options)));
+ private void Log(string text) { try { _log(text); } catch (IOException) { } catch (UnauthorizedAccessException) { } }
+ private void Publish(AppStatus status)
+ {
+ if ((status.LastSwitchAt is not null && status.LastSwitchAt != _status.LastSwitchAt) ||
+ (status.Mode != _status.Mode && status.Mode is NetworkMode.RecoveryRequired or NetworkMode.ExternalConfiguration or NetworkMode.Disconnected or NetworkMode.Degraded) ||
+ status.Message.StartsWith("切换未成功", StringComparison.Ordinal) || status.Message.StartsWith("配置已恢复", StringComparison.Ordinal))
+ {
+ _lastEvent = status.Message;
+ _lastEventAt = DateTimeOffset.Now;
+ }
+ status = status with { LastEvent = _lastEvent, LastEventAt = _lastEventAt, ActivePath = status.Network is null ? status.Mode == NetworkMode.Initializing ? NetworkMode.Initializing : NetworkMode.Disconnected : Classify(status.Network) };
+ _status = status;
+ foreach (Action handler in StatusChanged?.GetInvocationList() ?? [])
+ try { handler(status); } catch (Exception e) { Log("界面订阅失败:" + e.Message); }
+ }
+}
diff --git a/src/GatewayKeepalive.Core/GatewayKeepalive.Core.csproj b/src/GatewayKeepalive.Core/GatewayKeepalive.Core.csproj
new file mode 100644
index 0000000..6b512ec
--- /dev/null
+++ b/src/GatewayKeepalive.Core/GatewayKeepalive.Core.csproj
@@ -0,0 +1 @@
+
diff --git a/src/GatewayKeepalive.Core/Models.cs b/src/GatewayKeepalive.Core/Models.cs
new file mode 100644
index 0000000..d1d3722
--- /dev/null
+++ b/src/GatewayKeepalive.Core/Models.cs
@@ -0,0 +1,120 @@
+using System.Net;
+using System.Net.Sockets;
+
+namespace GatewayKeepalive.Core;
+
+public enum ControlPolicy { Auto, ForceDirect, ForceProxy, Paused }
+public enum NetworkMode { Initializing, Direct, Proxy, Switching, Degraded, Disconnected, ExternalConfiguration, RecoveryRequired }
+
+public sealed record AppSettings
+{
+ public int SchemaVersion { get; init; } = 1;
+ public bool Configured { get; init; }
+ public Guid AdapterId { get; init; }
+ public string DirectGateway { get; init; } = "192.168.6.1";
+ public string ProxyGateway { get; init; } = "192.168.6.2";
+ public string ProxyDns { get; init; } = "192.168.6.2";
+ public bool PreserveOriginalDirectDns { get; init; } = true;
+ public bool DirectDnsAutomatic { get; init; } = true;
+ public string[] DirectDnsServers { get; init; } = [];
+ public bool UseSocks { get; init; } = true;
+ public int SocksPort { get; init; } = 1070;
+ public string[] TestHosts { get; init; } = ["www.google.com"];
+ public int RequiredHealthyTargets { get; init; } = 1;
+ public string[] DirectTestHosts { get; init; } = ["www.baidu.com", "www.microsoft.com"];
+ public int RequiredDirectHealthyTargets { get; init; } = 1;
+ public int IntervalSeconds { get; init; } = 5;
+ public int SuccessThreshold { get; init; } = 3;
+ public int FailureThreshold { get; init; } = 3;
+ public int RecoveryCooldownSeconds { get; init; } = 30;
+ public int FailureCooldownSeconds { get; init; } = 5;
+ public int ProbeTimeoutMs { get; init; } = 2500;
+ public int RoundTimeoutMs { get; init; } = 4000;
+ public ControlPolicy Policy { get; init; } = ControlPolicy.Paused;
+ public bool RestoreOnExit { get; init; }
+ public bool Notifications { get; init; } = true;
+
+ public void Validate(bool requireConfigured = true)
+ {
+ if (SchemaVersion != 1) throw new ArgumentException("配置版本不受支持。");
+ if ((requireConfigured && !Configured) || ((requireConfigured || Configured) && AdapterId == Guid.Empty)) throw new ArgumentException("请先选择网卡并保存配置。");
+ static bool Ipv4(string s) => IPAddress.TryParse(s, out var ip) && ip.AddressFamily == AddressFamily.InterNetwork && !ip.Equals(IPAddress.Any) && !ip.Equals(IPAddress.Broadcast) && !IPAddress.IsLoopback(ip) && ip.GetAddressBytes()[0] < 224;
+ if (!Ipv4(DirectGateway) || !Ipv4(ProxyGateway) || !Ipv4(ProxyDns)) throw new ArgumentException("网关和旁路由 DNS 必须是有效 IPv4 地址。");
+ if (DirectGateway == ProxyGateway) throw new ArgumentException("主路由与旁路由不能相同。");
+ if (!DirectDnsAutomatic && (DirectDnsServers.Length == 0 || DirectDnsServers.Any(x => !Ipv4(x)))) throw new ArgumentException("请填写有效的直连 DNS 地址。");
+ if (SocksPort is < 1 or > 65535) throw new ArgumentException("SOCKS5 端口范围为 1~65535。");
+ static bool Target(string s) => !string.IsNullOrWhiteSpace(s) && s.Length <= 253 && !s.Contains('/') && (IPAddress.TryParse(s, out var ip) ? ip.AddressFamily == AddressFamily.InterNetwork : Uri.CheckHostName(s) == UriHostNameType.Dns);
+ if (TestHosts.Length is < 1 or > 5 || TestHosts.Any(x => !Target(x))) throw new ArgumentException("探测目标需为 1~5 个主机名或 IPv4 地址,不包含协议和路径。");
+ if (RequiredHealthyTargets < 1 || RequiredHealthyTargets > TestHosts.Length) throw new ArgumentException("成功目标数超出探测目标数量。");
+ if (DirectTestHosts.Length is < 1 or > 5 || DirectTestHosts.Any(x => !Target(x))) throw new ArgumentException("直连探测目标需为 1~5 个主机名或 IPv4 地址。");
+ if (RequiredDirectHealthyTargets < 1 || RequiredDirectHealthyTargets > DirectTestHosts.Length) throw new ArgumentException("直连成功目标数超出探测目标数量。");
+ if (IntervalSeconds is < 1 or > 3600 || SuccessThreshold is < 1 or > 100 || FailureThreshold is < 1 or > 100) throw new ArgumentException("检测间隔或连续阈值超出范围。");
+ if (RecoveryCooldownSeconds is < 0 or > 3600 || FailureCooldownSeconds is < 0 or > 3600) throw new ArgumentException("冷却时间范围为 0~3600 秒。");
+ if (ProbeTimeoutMs is < 100 or > 30000 || RoundTimeoutMs < ProbeTimeoutMs || RoundTimeoutMs > 60000) throw new ArgumentException("探针超时需为 100~30000 毫秒,整轮预算不能小于探针超时且不得超过 60000 毫秒。");
+ if (!Enum.IsDefined(Policy)) throw new ArgumentException("控制模式无效。");
+ }
+}
+
+public sealed record Ipv4Address(string Address, int PrefixLength);
+public sealed record AdapterInfo(Guid Id, int Index, string Name, bool Up, Ipv4Address[] Addresses, uint InterfaceMetric, string NetworkFingerprint);
+public sealed record RouteEntry(string Gateway, uint Metric, ulong InterfaceLuid, uint InterfaceIndex, uint Protocol = 3, uint ValidLifetime = uint.MaxValue, uint PreferredLifetime = uint.MaxValue, bool Publish = false, bool Immortal = false);
+public sealed record DnsConfiguration(bool Automatic, string[] Servers, string[] ProfileServers)
+{
+ public static DnsConfiguration Auto { get; } = new(true, [], []);
+}
+public sealed record NetworkSnapshot(AdapterInfo Adapter, RouteEntry[] Routes, DnsConfiguration Dns, string[] EffectiveDns, DateTimeOffset CapturedAt)
+{
+ public RouteEntry? PreferredRoute => Routes.OrderBy(x => x.Metric).FirstOrDefault();
+ public string? Gateway => PreferredRoute?.Gateway;
+}
+public sealed record ProbeResult(string Name, bool Success, long ElapsedMs, string Detail);
+public sealed record HealthReport(ProbeResult[] Checks, bool ProxyHealthy, bool InternetHealthy, DateTimeOffset CheckedAt);
+public sealed record AppStatus(NetworkMode Mode, ControlPolicy Policy, NetworkSnapshot? Network, HealthReport? Health, int SuccessCount, int FailureCount, string Message, DateTimeOffset? LastSwitchAt = null)
+{
+ public string? LastEvent { get; init; }
+ public DateTimeOffset? LastEventAt { get; init; }
+ public NetworkMode ActivePath { get; init; } = NetworkMode.Initializing;
+ public static AppStatus Initial { get; } = new(NetworkMode.Initializing, ControlPolicy.Paused, null, null, 0, 0, "请先选择网卡并保存配置");
+}
+
+public interface IHealthProbe
+{
+ Task CheckAsync(AppSettings settings, NetworkSnapshot snapshot, CancellationToken cancellationToken);
+ Task VerifyInternetAsync(AppSettings settings, NetworkSnapshot snapshot, CancellationToken cancellationToken);
+}
+public interface INetworkBackend
+{
+ void ConfigureIdentity(AppSettings settings) { }
+ Task> ListAdaptersAsync(CancellationToken cancellationToken);
+ Task CaptureAsync(Guid adapterId, CancellationToken cancellationToken);
+ Task WriteRoutesAsync(NetworkSnapshot desired, CancellationToken cancellationToken);
+ Task WriteDnsAsync(Guid adapterId, DnsConfiguration desired, CancellationToken cancellationToken);
+ async Task WriteRoutesGuardedAsync(NetworkSnapshot desired, NetworkSnapshot expected, CancellationToken cancellationToken)
+ {
+ var current = await CaptureAsync(expected.Adapter.Id, cancellationToken);
+ if (!NetworkComparison.SameConfiguration(expected, current)) throw new InvalidOperationException("路由写入前现场已变化,停止覆盖外部配置。");
+ await WriteRoutesAsync(desired, cancellationToken);
+ }
+ async Task WriteDnsGuardedAsync(Guid adapterId, DnsConfiguration desired, NetworkSnapshot expected, CancellationToken cancellationToken)
+ {
+ var current = await CaptureAsync(adapterId, cancellationToken);
+ if (!NetworkComparison.SameConfiguration(expected, current)) throw new InvalidOperationException("DNS 写入前现场已变化,停止覆盖外部配置。");
+ await WriteDnsAsync(adapterId, desired, cancellationToken);
+ }
+ bool IsAdministrator { get; }
+}
+public interface ITrayController
+{
+ AppSettings Settings { get; }
+ AppStatus Status { get; }
+ string DataDirectory { get; }
+ bool AutostartEnabled { get; }
+ event Action? StatusChanged;
+ Task> ListAdaptersAsync();
+ Task RefreshAsync();
+ Task SetPolicyAsync(ControlPolicy policy);
+ Task SaveSettingsAsync(AppSettings settings);
+ Task RestoreAsync();
+ Task StopAsync();
+ void SetAutostart(bool enabled);
+}
diff --git a/src/GatewayKeepalive.Core/Persistence.cs b/src/GatewayKeepalive.Core/Persistence.cs
new file mode 100644
index 0000000..6b4b6ee
--- /dev/null
+++ b/src/GatewayKeepalive.Core/Persistence.cs
@@ -0,0 +1,78 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace GatewayKeepalive.Core;
+
+public sealed class JsonStore(string directory)
+{
+ public string DirectoryPath { get; } = Path.GetFullPath(directory);
+ public static JsonSerializerOptions Options { get; } = new() { WriteIndented = true, PropertyNameCaseInsensitive = true, RespectNullableAnnotations = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.Create(System.Text.Unicode.UnicodeRanges.All), Converters = { new JsonStringEnumConverter() } };
+
+ public T? Read(string file) where T : class
+ {
+ var path = Path.Combine(DirectoryPath, file);
+ return File.Exists(path) ? JsonSerializer.Deserialize(File.ReadAllText(path), Options) ?? throw new InvalidDataException($"配置文件为空:{file}") : null;
+ }
+
+ public void Write(string file, T value)
+ {
+ Directory.CreateDirectory(DirectoryPath);
+ var path = Path.Combine(DirectoryPath, file);
+ var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
+ try
+ {
+ using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough))
+ {
+ JsonSerializer.Serialize(stream, value, Options);
+ stream.Flush(true);
+ }
+ if (File.Exists(path)) File.Replace(temporary, path, path + ".bak");
+ else File.Move(temporary, path);
+ }
+ finally { if (File.Exists(temporary)) File.Delete(temporary); }
+ }
+
+ public void Delete(string file) => File.Delete(Path.Combine(DirectoryPath, file));
+}
+
+public sealed class FileLog(string directory)
+{
+ private readonly object _gate = new();
+ public void Write(string message)
+ {
+ lock (_gate)
+ {
+ Directory.CreateDirectory(directory);
+ var today = DateTime.Now;
+ File.AppendAllText(Path.Combine(directory, $"gateway-{today:yyyy-MM-dd}.log"), $"[{today:yyyy-MM-dd HH:mm:ss.fff}] {message}{Environment.NewLine}");
+ foreach (var file in Directory.EnumerateFiles(directory, "gateway-*.log"))
+ if (File.GetLastWriteTimeUtc(file) < DateTime.UtcNow.AddDays(-14))
+ try { File.Delete(file); } catch (IOException) { }
+ }
+ }
+}
+
+public sealed record SwitchJournal(NetworkSnapshot Before, NetworkSnapshot Intended, string Stage, DateTimeOffset StartedAt);
+public sealed record SessionState(NetworkSnapshot Baseline, NetworkSnapshot Expected, DnsConfiguration? DirectDns, string SettingsFingerprint = "");
+
+public static class NetworkComparison
+{
+ public static bool SameNetwork(NetworkSnapshot left, NetworkSnapshot right) =>
+ left.Adapter.Id == right.Adapter.Id && left.Adapter.Index == right.Adapter.Index &&
+ !string.IsNullOrWhiteSpace(left.Adapter.NetworkFingerprint) && left.Adapter.NetworkFingerprint == right.Adapter.NetworkFingerprint;
+
+ public static bool SameDns(DnsConfiguration a, DnsConfiguration b) => a.Automatic == b.Automatic && a.Servers.SequenceEqual(b.Servers, StringComparer.OrdinalIgnoreCase) && a.ProfileServers.SequenceEqual(b.ProfileServers, StringComparer.OrdinalIgnoreCase);
+ private static string Key(RouteEntry r) => $"{r.InterfaceLuid}/{r.InterfaceIndex}/{r.Gateway}";
+ private static bool SameRoute(RouteEntry a, RouteEntry b) => Key(a) == Key(b) && a.Metric == b.Metric && a.Protocol == b.Protocol && a.Publish == b.Publish && a.Immortal == b.Immortal;
+ public static bool SameRoutes(RouteEntry[] a, RouteEntry[] b) => a.Length == b.Length && a.All(x => b.Any(y => SameRoute(x, y)));
+ public static bool SameConfiguration(NetworkSnapshot a, NetworkSnapshot b) => SameNetwork(a, b) && SameRoutes(a.Routes, b.Routes) && SameDns(a.Dns, b.Dns);
+
+ // Recovery accepts only exact before/intended values. Foreign additions and changes stop it.
+ public static bool BelongsToTransaction(SwitchJournal journal, NetworkSnapshot current)
+ {
+ if (!SameNetwork(journal.Before, current)) return false;
+ if (!SameDns(current.Dns, journal.Before.Dns) && !SameDns(current.Dns, journal.Intended.Dns)) return false;
+ if (current.Routes.Any(r => !journal.Before.Routes.Concat(journal.Intended.Routes).Any(x => SameRoute(x, r)))) return false;
+ return journal.Before.Routes.Where(r => journal.Intended.Routes.Any(x => Key(x) == Key(r))).All(r => current.Routes.Any(x => Key(x) == Key(r)));
+ }
+}
diff --git a/src/GatewayKeepalive.Core/Probes/DnsProbe.cs b/src/GatewayKeepalive.Core/Probes/DnsProbe.cs
new file mode 100644
index 0000000..35383f3
--- /dev/null
+++ b/src/GatewayKeepalive.Core/Probes/DnsProbe.cs
@@ -0,0 +1,269 @@
+using System.Buffers.Binary;
+using System.Globalization;
+using System.Net;
+using System.Net.Sockets;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace GatewayKeepalive.Core;
+
+public sealed class DnsQueryException : IOException
+{
+ public DnsQueryException(int responseCode, string message) : base(message) => ResponseCode = responseCode;
+
+ public int ResponseCode { get; }
+}
+
+/// A strict IPv4 DNS A-query client that only contacts explicitly supplied servers.
+public static class DnsProbe
+{
+ public const int DefaultPort = 53;
+ private const int HeaderLength = 12;
+ private const int MaximumMessageLength = 65535;
+
+ public static Task QueryAAsync(
+ IPAddress server,
+ string host,
+ IPAddress? sourceAddress,
+ CancellationToken ct) => QueryAAsync(server, DefaultPort, host, sourceAddress, ct);
+
+ public static async Task QueryAAsync(
+ IPAddress server,
+ int serverPort,
+ string host,
+ IPAddress? sourceAddress,
+ CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(server);
+ if (server.AddressFamily != AddressFamily.InterNetwork)
+ throw new ArgumentException("DNS 服务器必须是 IPv4 地址。", nameof(server));
+ if (serverPort is < 1 or > 65535) throw new ArgumentOutOfRangeException(nameof(serverPort));
+ if (sourceAddress is not null && sourceAddress.AddressFamily != AddressFamily.InterNetwork)
+ throw new ArgumentException("DNS 源地址必须是 IPv4 地址。", nameof(sourceAddress));
+ ct.ThrowIfCancellationRequested();
+
+ var query = BuildQuery(host, out var transactionId);
+ var endpoint = new IPEndPoint(server, serverPort);
+ byte[] response;
+ using (var udp = new UdpClient(AddressFamily.InterNetwork))
+ {
+ if (sourceAddress is not null) udp.Client.Bind(new IPEndPoint(sourceAddress, 0));
+ await udp.SendAsync(query, endpoint, ct).ConfigureAwait(false);
+ var received = await udp.ReceiveAsync(ct).ConfigureAwait(false);
+ if (!received.RemoteEndPoint.Equals(endpoint))
+ throw new InvalidDataException(
+ $"DNS 响应来源不匹配:期望 {endpoint},实际 {received.RemoteEndPoint}。");
+ response = received.Buffer;
+ }
+
+ if (response.Length < HeaderLength)
+ throw new InvalidDataException("DNS 响应短于固定头。");
+ if (ReadUInt16(response, 0) != transactionId)
+ throw new InvalidDataException("DNS 事务 ID 不匹配。");
+ if (IsTruncated(response))
+ response = await QueryTcpAsync(endpoint, query, sourceAddress, ct).ConfigureAwait(false);
+ if (IsTruncated(response))
+ throw new InvalidDataException("DNS TCP 响应仍标记为截断。");
+
+ var addresses = ParseResponse(response, transactionId, host);
+ if (addresses.Count == 0) throw new InvalidDataException($"DNS 未返回 {host} 的 A 记录。");
+ return [.. addresses];
+ }
+
+ /// Validates and parses a complete DNS response, including compressed names.
+ public static IReadOnlyList ParseResponse(ReadOnlySpan response, ushort transactionId, string host)
+ {
+ if (response.Length < HeaderLength) throw new InvalidDataException("DNS 响应短于固定头。");
+ if (ReadUInt16(response, 0) != transactionId) throw new InvalidDataException("DNS 事务 ID 不匹配。");
+ var flags = ReadUInt16(response, 2);
+ if ((flags & 0x8000) == 0) throw new InvalidDataException("DNS 响应缺少 QR 标志。");
+ if (((flags >> 11) & 0x0f) != 0) throw new InvalidDataException("DNS 响应操作码不匹配。");
+ var responseCode = flags & 0x0f;
+ if (responseCode != 0) throw new DnsQueryException(responseCode, $"DNS 返回错误码 {responseCode}。");
+ if (ReadUInt16(response, 4) != 1) throw new InvalidDataException("DNS 响应问题段数量无效。");
+
+ var questionCount = ReadUInt16(response, 4);
+ var answerCount = ReadUInt16(response, 6);
+ var authorityCount = ReadUInt16(response, 8);
+ var additionalCount = ReadUInt16(response, 10);
+ var offset = HeaderLength;
+ var actualHost = ReadName(response, ref offset);
+ var expectedHost = NormalizeHost(host);
+ if (!string.Equals(actualHost, expectedHost, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidDataException($"DNS 问题名称不匹配:{actualHost}。");
+ EnsureAvailable(response, offset, 4);
+ if (ReadUInt16(response, offset) != 1 || ReadUInt16(response, offset + 2) != 1)
+ throw new InvalidDataException("DNS 响应问题类型或类别无效。");
+ offset += 4;
+
+ var addresses = new List();
+ for (var i = 0; i < questionCount - 1; i++)
+ {
+ _ = ReadName(response, ref offset);
+ EnsureAvailable(response, offset, 4);
+ offset += 4;
+ }
+ ReadRecords(response, ref offset, answerCount, addresses, collectA: true);
+ ReadRecords(response, ref offset, authorityCount, addresses, collectA: false);
+ ReadRecords(response, ref offset, additionalCount, addresses, collectA: false);
+ if (offset != response.Length)
+ throw new InvalidDataException("DNS 响应在段结束前仍有未消费数据。");
+ return addresses;
+ }
+
+ private static void ReadRecords(
+ ReadOnlySpan data,
+ ref int offset,
+ int count,
+ List addresses,
+ bool collectA)
+ {
+ for (var i = 0; i < count; i++)
+ {
+ _ = ReadName(data, ref offset);
+ EnsureAvailable(data, offset, 10);
+ var type = ReadUInt16(data, offset);
+ var @class = ReadUInt16(data, offset + 2);
+ var length = ReadUInt16(data, offset + 8);
+ offset += 10;
+ EnsureAvailable(data, offset, length);
+ if (collectA && type == 1 && @class == 1 && length == 4)
+ addresses.Add(new IPAddress(data.Slice(offset, 4)));
+ offset += length;
+ }
+ }
+
+ private static string ReadName(ReadOnlySpan data, ref int offset)
+ {
+ var labels = new List();
+ var position = offset;
+ var nextOffset = -1;
+ var jumps = 0;
+ var totalLength = 0;
+ while (true)
+ {
+ if ((uint)position >= (uint)data.Length) throw new InvalidDataException("DNS 名称越界。");
+ var length = data[position++];
+ if (length == 0)
+ {
+ offset = nextOffset >= 0 ? nextOffset : position;
+ return string.Join('.', labels);
+ }
+ if ((length & 0xc0) == 0xc0)
+ {
+ if (position >= data.Length) throw new InvalidDataException("DNS 压缩指针不完整。");
+ var pointer = ((length & 0x3f) << 8) | data[position++];
+ if (nextOffset < 0) nextOffset = position;
+ if (pointer < HeaderLength || pointer >= data.Length || pointer >= position || ++jumps > 32)
+ throw new InvalidDataException("DNS 压缩指针无效或形成循环。");
+ position = pointer;
+ continue;
+ }
+ if ((length & 0xc0) != 0 || length > 63 || position + length > data.Length)
+ throw new InvalidDataException("DNS 标签长度无效。");
+ totalLength += length + 1;
+ if (totalLength > 254) throw new InvalidDataException("DNS 名称过长。");
+ labels.Add(Encoding.ASCII.GetString(data.Slice(position, length)));
+ position += length;
+ }
+ }
+
+ private static bool IsTruncated(ReadOnlySpan response) =>
+ response.Length >= HeaderLength && (ReadUInt16(response, 2) & 0x0200) != 0;
+
+ private static async Task QueryTcpAsync(
+ IPEndPoint endpoint,
+ byte[] query,
+ IPAddress? sourceAddress,
+ CancellationToken ct)
+ {
+ using var client = new TcpClient(AddressFamily.InterNetwork);
+ if (sourceAddress is not null) client.Client.Bind(new IPEndPoint(sourceAddress, 0));
+ await client.ConnectAsync(endpoint, ct).ConfigureAwait(false);
+ await using var stream = client.GetStream();
+ var prefix = new byte[2];
+ BinaryPrimitives.WriteUInt16BigEndian(prefix, checked((ushort)query.Length));
+ await stream.WriteAsync(prefix, ct).ConfigureAwait(false);
+ await stream.WriteAsync(query, ct).ConfigureAwait(false);
+ await stream.FlushAsync(ct).ConfigureAwait(false);
+
+ await ReadExactlyAsync(stream, prefix, ct).ConfigureAwait(false);
+ var length = BinaryPrimitives.ReadUInt16BigEndian(prefix);
+ if (length < HeaderLength) throw new InvalidDataException("DNS TCP 响应长度无效。");
+ var response = new byte[length];
+ await ReadExactlyAsync(stream, response, ct).ConfigureAwait(false);
+ return response;
+ }
+
+ private static async Task ReadExactlyAsync(Stream stream, Memory buffer, CancellationToken ct)
+ {
+ var read = 0;
+ while (read < buffer.Length)
+ {
+ var count = await stream.ReadAsync(buffer[read..], ct).ConfigureAwait(false);
+ if (count == 0) throw new EndOfStreamException("DNS TCP 响应在完整读取前结束。");
+ read += count;
+ }
+ }
+
+ private static byte[] BuildQuery(string host, out ushort transactionId)
+ {
+ var name = EncodeName(host);
+ transactionId = (ushort)RandomNumberGenerator.GetInt32(0, 65536);
+ var query = new byte[HeaderLength + name.Length + 4];
+ BinaryPrimitives.WriteUInt16BigEndian(query.AsSpan(0, 2), transactionId);
+ BinaryPrimitives.WriteUInt16BigEndian(query.AsSpan(2, 2), 0x0100); // recursion desired
+ BinaryPrimitives.WriteUInt16BigEndian(query.AsSpan(4, 2), 1);
+ name.CopyTo(query, HeaderLength);
+ BinaryPrimitives.WriteUInt16BigEndian(query.AsSpan(HeaderLength + name.Length, 2), 1);
+ BinaryPrimitives.WriteUInt16BigEndian(query.AsSpan(HeaderLength + name.Length + 2, 2), 1);
+ return query;
+ }
+
+ private static byte[] EncodeName(string host)
+ {
+ if (string.IsNullOrWhiteSpace(host)) throw new ArgumentException("DNS 查询名称不能为空。", nameof(host));
+ var normalized = NormalizeHost(host);
+ if (IPAddress.TryParse(normalized, out _))
+ throw new ArgumentException("DNS A 查询需要域名,不能使用 IP 地址。", nameof(host));
+ if (normalized.Length > 253) throw new ArgumentException("DNS 查询名称过长。", nameof(host));
+ var labels = normalized.Split('.');
+ var length = 1;
+ foreach (var label in labels)
+ {
+ if (label.Length is 0 or > 63) throw new ArgumentException("DNS 标签长度无效。", nameof(host));
+ length += label.Length + 1;
+ }
+ var wire = new byte[length];
+ var offset = 0;
+ foreach (var label in labels)
+ {
+ wire[offset++] = (byte)label.Length;
+ var bytes = Encoding.ASCII.GetBytes(label);
+ bytes.CopyTo(wire, offset);
+ offset += bytes.Length;
+ }
+ wire[offset] = 0;
+ return wire;
+ }
+
+ private static string NormalizeHost(string host)
+ {
+ if (string.IsNullOrWhiteSpace(host)) throw new ArgumentException("DNS 名称不能为空。", nameof(host));
+ var ascii = new IdnMapping().GetAscii(host.Trim()).TrimEnd('.');
+ if (ascii.Length == 0) throw new ArgumentException("DNS 名称不能为空。", nameof(host));
+ return ascii;
+ }
+
+ private static ushort ReadUInt16(ReadOnlySpan data, int offset)
+ {
+ EnsureAvailable(data, offset, 2);
+ return BinaryPrimitives.ReadUInt16BigEndian(data.Slice(offset, 2));
+ }
+
+ private static void EnsureAvailable(ReadOnlySpan data, int offset, int length)
+ {
+ if (offset < 0 || length < 0 || offset > data.Length - length)
+ throw new InvalidDataException("DNS 响应被截断或长度字段无效。");
+ }
+}
diff --git a/src/GatewayKeepalive.Core/Probes/NetworkHealthProbe.cs b/src/GatewayKeepalive.Core/Probes/NetworkHealthProbe.cs
new file mode 100644
index 0000000..f5ca862
--- /dev/null
+++ b/src/GatewayKeepalive.Core/Probes/NetworkHealthProbe.cs
@@ -0,0 +1,585 @@
+using System.Diagnostics;
+using System.Globalization;
+using System.Net;
+using System.Net.Security;
+using System.Net.Sockets;
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("GatewayKeepalive.ProbeTests")]
+
+namespace GatewayKeepalive.Core;
+
+/// Performs bounded IPv4 checks for the selected adapter without using system DNS or ICMP.
+public sealed class NetworkHealthProbe : IHealthProbe
+{
+ private const int TlsPort = 443;
+ private readonly Func? _routeValidator;
+
+ public NetworkHealthProbe(Func? routeValidator = null) =>
+ _routeValidator = routeValidator;
+
+ public async Task CheckAsync(
+ AppSettings settings,
+ NetworkSnapshot snapshot,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(settings);
+ ArgumentNullException.ThrowIfNull(snapshot);
+ settings.Validate(requireConfigured: false);
+ cancellationToken.ThrowIfCancellationRequested();
+ var proxyTargets = ParseTargets(settings.TestHosts);
+ var egressSelection = SelectEgressTargets(settings, snapshot);
+ var egressTargets = ParseTargets(egressSelection.Hosts);
+ var proxyDnsServers = ProxyDnsServers(settings);
+ var effectiveDnsServers = EffectiveDnsServers(snapshot);
+
+ using var round = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ round.CancelAfter(settings.RoundTimeoutMs);
+ var roundToken = round.Token;
+
+ var proxyTask = RunAsync(
+ "旁路由连接",
+ token => ProbeProxyTcpAsync(settings, snapshot, token),
+ detail => new ProbeResult("旁路由连接", true, 0, detail),
+ _ => null,
+ settings,
+ roundToken,
+ cancellationToken);
+ var proxyDnsTask = RunAsync(
+ "旁路由 DNS",
+ token => ResolveTargetsCoreAsync(snapshot, proxyTargets, proxyDnsServers, token),
+ stage => new ProbeResult(
+ "旁路由 DNS",
+ MeetsHealthyTargets(stage.ResolvedCount, settings.RequiredHealthyTargets),
+ 0,
+ BuildDnsDetail(stage, settings.RequiredHealthyTargets, proxyTargets.Length)),
+ failure => new DnsStage(
+ proxyTargets.Select(x => new Resolution(x, [], failure.Detail)).ToArray(),
+ 0),
+ settings,
+ roundToken,
+ cancellationToken);
+ Task> socksTask = settings.UseSocks
+ ? RunAsync(
+ "SOCKS5",
+ token => ProbeSocksAllAsync(settings, snapshot, proxyTargets, token),
+ stage => new ProbeResult(
+ "SOCKS5",
+ MeetsHealthyTargets(stage.HealthyCount, settings.RequiredHealthyTargets),
+ 0,
+ BuildSocksDetail(stage, settings.RequiredHealthyTargets, proxyTargets.Length)),
+ failure => new SocksStage(
+ 0,
+ proxyTargets.Select(x => new SocksOutcome(x.Host, false, failure.Detail)).ToArray()),
+ settings,
+ roundToken,
+ cancellationToken)
+ : Task.FromResult(new ProbeExecution(
+ null,
+ new ProbeResult("SOCKS5", true, 0, "未启用")));
+
+ var egressTask = RunAsync(
+ "当前出口",
+ token => ProbeEgressWithDnsCoreAsync(
+ snapshot, egressTargets, effectiveDnsServers, token),
+ stage => new ProbeResult(
+ "当前出口",
+ MeetsHealthyTargets(stage.HealthyCount, egressSelection.RequiredHealthyTargets),
+ 0,
+ BuildEgressDetail(stage, egressSelection.RequiredHealthyTargets, egressTargets.Length)),
+ failure => new EgressStage(
+ 0,
+ egressTargets.Select(x => new EgressOutcome(
+ x.Host,
+ false,
+ failure.Detail)).ToArray()),
+ settings,
+ roundToken,
+ cancellationToken);
+
+ await Task.WhenAll(proxyTask, proxyDnsTask, socksTask, egressTask).ConfigureAwait(false);
+ var proxy = await proxyTask.ConfigureAwait(false);
+ var proxyDns = await proxyDnsTask.ConfigureAwait(false);
+ var socks = await socksTask.ConfigureAwait(false);
+ var egress = await egressTask.ConfigureAwait(false);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var checks = new List { proxy.Result, proxyDns.Result };
+ if (settings.UseSocks) checks.Add(socks.Result);
+ checks.Add(egress.Result);
+ return AssembleHealthReport(
+ checks.ToArray(),
+ proxy.Result.Success,
+ proxyDns.Result.Success,
+ settings.UseSocks ? socks.Result.Success : null,
+ settings.UseSocks,
+ egress.Value!.HealthyCount,
+ egressSelection.RequiredHealthyTargets,
+ DateTimeOffset.UtcNow);
+ }
+
+ public async Task VerifyInternetAsync(
+ AppSettings settings,
+ NetworkSnapshot snapshot,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(settings);
+ ArgumentNullException.ThrowIfNull(snapshot);
+ settings.Validate(requireConfigured: false);
+ cancellationToken.ThrowIfCancellationRequested();
+ var egressSelection = SelectEgressTargets(settings, snapshot);
+ var targets = ParseTargets(egressSelection.Hosts);
+ var effectiveDnsServers = EffectiveDnsServers(snapshot);
+ using var round = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ round.CancelAfter(settings.RoundTimeoutMs);
+ try
+ {
+ var egress = await ProbeEgressWithDnsCoreAsync(
+ snapshot, targets, effectiveDnsServers, round.Token)
+ .ConfigureAwait(false);
+ cancellationToken.ThrowIfCancellationRequested();
+ return MeetsHealthyTargets(egress.HealthyCount, egressSelection.RequiredHealthyTargets);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (OperationCanceledException)
+ {
+ return false;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
+ internal static HealthReport AssembleHealthReport(
+ ProbeResult[] checks,
+ bool proxyTcpSuccess,
+ bool proxyDnsSuccess,
+ bool? socksSuccess,
+ bool useSocks,
+ int healthyTargets,
+ int requiredHealthyTargets,
+ DateTimeOffset checkedAt)
+ {
+ var internetHealthy = MeetsHealthyTargets(healthyTargets, requiredHealthyTargets);
+ var proxyHealthy = useSocks
+ ? proxyTcpSuccess && proxyDnsSuccess && socksSuccess == true
+ : proxyDnsSuccess;
+ return new HealthReport(checks, proxyHealthy, internetHealthy, checkedAt);
+ }
+
+ internal static bool MeetsHealthyTargets(int healthyCount, int requiredHealthyTargets) =>
+ healthyCount >= requiredHealthyTargets;
+
+ internal static (string[] Hosts, int RequiredHealthyTargets, bool IsProxy) SelectEgressTargets(
+ AppSettings settings,
+ NetworkSnapshot snapshot) =>
+ string.Equals(snapshot.Gateway, settings.ProxyGateway, StringComparison.Ordinal)
+ ? (settings.TestHosts, settings.RequiredHealthyTargets, true)
+ : (settings.DirectTestHosts, settings.RequiredDirectHealthyTargets, false);
+
+ internal static IPAddress[] ProxyDnsServers(AppSettings settings) =>
+ ParseDnsServers([settings.ProxyDns]);
+
+ internal static IPAddress[] EffectiveDnsServers(NetworkSnapshot snapshot) =>
+ ParseDnsServers(snapshot.EffectiveDns);
+
+ internal static IPAddress[] ParseDnsServers(IEnumerable values) =>
+ values.Select(value => IPAddress.TryParse(value.Trim(), out var address) ? address : null)
+ .Where(address => address is not null && address.AddressFamily == AddressFamily.InterNetwork)
+ .Cast()
+ .Distinct()
+ .ToArray();
+
+ private static async Task> RunAsync(
+ string name,
+ Func> operation,
+ Func toResult,
+ Func failureFactory,
+ AppSettings settings,
+ CancellationToken roundToken,
+ CancellationToken callerToken) where T : class
+ {
+ var stopwatch = Stopwatch.StartNew();
+ using var probe = CancellationTokenSource.CreateLinkedTokenSource(callerToken, roundToken);
+ probe.CancelAfter(settings.ProbeTimeoutMs);
+ try
+ {
+ var value = await operation(probe.Token).ConfigureAwait(false);
+ callerToken.ThrowIfCancellationRequested();
+ stopwatch.Stop();
+ var result = toResult(value);
+ return new ProbeExecution(value, result with { ElapsedMs = stopwatch.ElapsedMilliseconds });
+ }
+ catch (OperationCanceledException) when (callerToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (OperationCanceledException)
+ {
+ stopwatch.Stop();
+ var detail = roundToken.IsCancellationRequested
+ ? "整轮预算已耗尽。"
+ : $"超过单探针超时 {settings.ProbeTimeoutMs} ms。";
+ return Failed(name, detail, stopwatch, failureFactory);
+ }
+ catch (Exception ex)
+ {
+ stopwatch.Stop();
+ return Failed(name, ex.GetBaseException().Message, stopwatch, failureFactory);
+ }
+
+ static ProbeExecution Failed(
+ string name,
+ string detail,
+ Stopwatch stopwatch,
+ Func failureFactory)
+ {
+ var result = new ProbeResult(name, false, stopwatch.ElapsedMilliseconds, detail);
+ return new ProbeExecution(failureFactory(result), result);
+ }
+ }
+
+ private static async Task ProbeProxyTcpAsync(
+ AppSettings settings,
+ NetworkSnapshot snapshot,
+ CancellationToken ct)
+ {
+ var hostText = settings.UseSocks ? settings.ProxyGateway : settings.ProxyDns;
+ var port = settings.UseSocks ? settings.SocksPort : DnsProbe.DefaultPort;
+ if (!IPAddress.TryParse(hostText, out var host) || host.AddressFamily != AddressFamily.InterNetwork)
+ throw new InvalidDataException("旁路由 TCP 目标不是 IPv4 地址。");
+ using var client = await ConnectTcpAsync(snapshot, host, port, ct).ConfigureAwait(false);
+ return settings.UseSocks
+ ? $"{host}:{port} TCP 可达"
+ : $"{host}:{port} TCP 可达(未启用 SOCKS)";
+ }
+
+ private static async Task ProbeSocksAllAsync(
+ AppSettings settings,
+ NetworkSnapshot snapshot,
+ TargetSpec[] targets,
+ CancellationToken ct)
+ {
+ if (!IPAddress.TryParse(settings.ProxyGateway, out var proxy) ||
+ proxy.AddressFamily != AddressFamily.InterNetwork)
+ throw new InvalidDataException("SOCKS5 服务器不是 IPv4 地址。");
+ var outcomes = await Task.WhenAll(targets.Select(target =>
+ ProbeSocksOneAsync(settings, snapshot, proxy, target, ct))).ConfigureAwait(false);
+ return new SocksStage(outcomes.Count(x => x.Success), outcomes);
+ }
+
+ private static async Task ProbeSocksOneAsync(
+ AppSettings settings,
+ NetworkSnapshot snapshot,
+ IPAddress proxy,
+ TargetSpec target,
+ CancellationToken ct)
+ {
+ try
+ {
+ using var client = await ConnectTcpAsync(snapshot, proxy, settings.SocksPort, ct)
+ .ConfigureAwait(false);
+ await using var stream = client.GetStream();
+ await Socks5Probe.ConnectAsync(stream, target.Host, TlsPort, ct).ConfigureAwait(false);
+ return new SocksOutcome(target.Host, true, $"{target.Host}:{TlsPort} CONNECT 成功");
+ }
+ catch (Exception ex)
+ {
+ return new SocksOutcome(
+ target.Host,
+ false,
+ $"{target.Host}: {ex.GetBaseException().Message}");
+ }
+ }
+
+ private static async Task ResolveTargetAsync(
+ NetworkSnapshot snapshot,
+ TargetSpec target,
+ IPAddress[] dnsServers,
+ CancellationToken ct)
+ {
+ if (target.Address is not null)
+ return new ResolvedTarget(new Resolution(target, [target.Address], null), true);
+ if (dnsServers.Length == 0)
+ return new ResolvedTarget(
+ new Resolution(target, [], "未提供明确的 IPv4 DNS 服务器,未使用系统 DNS。"),
+ false);
+
+ using var targetAttempts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ var pending = dnsServers
+ .Select(server => ResolveFromServerAsync(snapshot, target, server, targetAttempts.Token))
+ .ToList();
+ var errors = new List();
+ while (pending.Count > 0)
+ {
+ var completed = await Task.WhenAny(pending).ConfigureAwait(false);
+ pending.Remove(completed);
+ var attempt = await completed.ConfigureAwait(false);
+ if (attempt.Addresses is { } addresses)
+ {
+ targetAttempts.Cancel();
+ await Task.WhenAll(pending).ConfigureAwait(false);
+ return new ResolvedTarget(new Resolution(target, addresses, null), true);
+ }
+ if (!string.IsNullOrWhiteSpace(attempt.Error)) errors.Add(attempt.Error);
+ }
+
+ return new ResolvedTarget(
+ new Resolution(target, [], errors.Count == 0 ? "DNS 未返回地址。" : string.Join(";", errors)),
+ false);
+ }
+
+ private static async Task ResolveTargetsCoreAsync(
+ NetworkSnapshot snapshot,
+ TargetSpec[] targets,
+ IPAddress[] dnsServers,
+ CancellationToken ct)
+ {
+ var results = await Task.WhenAll(targets.Select(target =>
+ ResolveTargetAsync(snapshot, target, dnsServers, ct))).ConfigureAwait(false);
+ return new DnsStage(
+ results.Select(x => x.Resolution).ToArray(),
+ results.Count(x => x.Success));
+ }
+
+ private static async Task ResolveFromServerAsync(
+ NetworkSnapshot snapshot,
+ TargetSpec target,
+ IPAddress server,
+ CancellationToken ct)
+ {
+ try
+ {
+ var source = SelectSourceAddress(snapshot, server)
+ ?? throw new InvalidOperationException("网卡没有可用的 IPv4 源地址。");
+ var addresses = await DnsProbe.QueryAAsync(server, target.Host, source, ct)
+ .ConfigureAwait(false);
+ if (addresses.Length == 0) throw new InvalidDataException("DNS A 记录为空。");
+ return new DnsAttempt(addresses, null);
+ }
+ catch (Exception ex)
+ {
+ return new DnsAttempt(null, $"{server}: {ex.GetBaseException().Message}");
+ }
+ }
+
+ private static string BuildDnsDetail(DnsStage stage, int required, int total)
+ {
+ var failures = stage.Resolutions
+ .Where(x => x.Addresses.Length == 0)
+ .Select(x => x.Error)
+ .Where(x => x is not null)
+ .ToArray();
+ var source = stage.Resolutions.All(x => x.Target.Address is not null)
+ ? "目标均为 IPv4 字面量,未使用系统 DNS"
+ : $"指定 DNS 解析成功 {stage.ResolvedCount}/{total},需要 {required}";
+ return failures.Length == 0 ? source : source + ";" + string.Join(";", failures);
+ }
+
+ private static string BuildSocksDetail(SocksStage stage, int required, int total)
+ {
+ var summary = $"SOCKS CONNECT 成功 {stage.HealthyCount}/{total},需要 {required}";
+ var failures = stage.Outcomes.Where(x => !x.Success).Select(x => x.Detail).ToArray();
+ return failures.Length == 0 ? summary : summary + ";" + string.Join(";", failures);
+ }
+
+ private async Task ProbeEgressWithDnsCoreAsync(
+ NetworkSnapshot snapshot,
+ TargetSpec[] targets,
+ IPAddress[] dnsServers,
+ CancellationToken ct)
+ {
+ var outcomes = await Task.WhenAll(targets.Select(target =>
+ ProbeEgressTargetWithDnsAsync(snapshot, target, dnsServers, ct))).ConfigureAwait(false);
+ return new EgressStage(outcomes.Count(x => x.Success), outcomes);
+ }
+
+ private async Task ProbeEgressTargetWithDnsAsync(
+ NetworkSnapshot snapshot,
+ TargetSpec target,
+ IPAddress[] dnsServers,
+ CancellationToken ct)
+ {
+ var resolved = await ResolveTargetAsync(snapshot, target, dnsServers, ct)
+ .ConfigureAwait(false);
+ if (!resolved.Success)
+ return new EgressOutcome(
+ target.Host,
+ false,
+ resolved.Resolution.Error ?? "未解析到 IPv4 地址");
+ return await ProbeEgressTargetAsync(snapshot, resolved.Resolution, ct)
+ .ConfigureAwait(false);
+ }
+
+ private async Task ProbeEgressTargetAsync(
+ NetworkSnapshot snapshot,
+ Resolution resolution,
+ CancellationToken ct)
+ {
+ if (resolution.Addresses.Length == 0)
+ return new EgressOutcome(
+ resolution.Target.Host,
+ false,
+ resolution.Error ?? "未解析到 IPv4 地址");
+
+ var attempts = await Task.WhenAll(resolution.Addresses
+ .Where(x => x.AddressFamily == AddressFamily.InterNetwork)
+ .Take(4)
+ .Select(address => ProbeTlsAddressAsync(snapshot, resolution.Target, address, ct)))
+ .ConfigureAwait(false);
+ var succeeded = attempts.FirstOrDefault(x => x.Success);
+ if (succeeded is not null)
+ return new EgressOutcome(resolution.Target.Host, true, succeeded.Detail);
+
+ var failures = attempts.Select(x => x.Detail).ToArray();
+ return new EgressOutcome(
+ resolution.Target.Host,
+ false,
+ failures.Length == 0 ? "没有可用 IPv4 地址" : string.Join(";", failures));
+ }
+
+ private async Task ProbeTlsAddressAsync(
+ NetworkSnapshot snapshot,
+ TargetSpec target,
+ IPAddress address,
+ CancellationToken ct)
+ {
+ try
+ {
+ if (_routeValidator is not null &&
+ !_routeValidator(address, snapshot.Adapter.Index, snapshot.Gateway))
+ throw new InvalidOperationException(
+ $"目标 {address} 的有效路由不属于网卡 {snapshot.Adapter.Index} 或未经过预期网关 {snapshot.Gateway ?? "(空)"}。");
+ using var client = await ConnectTcpAsync(snapshot, address, TlsPort, ct).ConfigureAwait(false);
+ await using var tcp = client.GetStream();
+ await AuthenticateTlsAsync(tcp, target.Host, ct).ConfigureAwait(false);
+ return new AddressOutcome(true, $"{target.Host} ({address}): TLS 握手与证书验证成功");
+ }
+ catch (Exception ex)
+ {
+ return new AddressOutcome(false, $"{target.Host} ({address}): {ex.GetBaseException().Message}");
+ }
+ }
+
+ internal static async Task AuthenticateTlsAsync(
+ Stream stream,
+ string targetHost,
+ CancellationToken ct)
+ {
+ await using var tls = new SslStream(stream, leaveInnerStreamOpen: false);
+ var options = new SslClientAuthenticationOptions
+ {
+ TargetHost = targetHost,
+ RemoteCertificateValidationCallback = static (_, _, _, errors) =>
+ errors == SslPolicyErrors.None
+ };
+ await tls.AuthenticateAsClientAsync(options, ct).ConfigureAwait(false);
+ }
+
+ private static string BuildEgressDetail(EgressStage stage, int required, int total)
+ {
+ var summary = $"TLS 出口成功 {stage.HealthyCount}/{total},需要 {required}";
+ var failures = stage.Outcomes.Where(x => !x.Success).Select(x => x.Detail).ToArray();
+ return failures.Length == 0 ? summary : summary + ";" + string.Join(";", failures);
+ }
+
+ private static async Task ConnectTcpAsync(
+ NetworkSnapshot snapshot,
+ IPAddress target,
+ int port,
+ CancellationToken ct)
+ {
+ if (target.AddressFamily != AddressFamily.InterNetwork)
+ throw new ArgumentException("所有网络探测目标必须是 IPv4 地址。", nameof(target));
+ var source = SelectSourceAddress(snapshot, target)
+ ?? throw new InvalidOperationException("网卡没有可用的 IPv4 源地址。");
+ var client = new TcpClient(AddressFamily.InterNetwork) { NoDelay = true };
+ try
+ {
+ client.Client.Bind(new IPEndPoint(source, 0));
+ await client.ConnectAsync(new IPEndPoint(target, port), ct).ConfigureAwait(false);
+ return client;
+ }
+ catch
+ {
+ client.Dispose();
+ throw;
+ }
+ }
+
+ private static IPAddress? SelectSourceAddress(NetworkSnapshot snapshot, IPAddress destination)
+ {
+ var candidates = new List<(IPAddress Address, int PrefixLength)>();
+ foreach (var configured in snapshot.Adapter.Addresses)
+ {
+ if (IPAddress.TryParse(configured.Address, out var address) &&
+ address.AddressFamily == AddressFamily.InterNetwork &&
+ !address.Equals(IPAddress.Any) &&
+ !address.Equals(IPAddress.Broadcast))
+ candidates.Add((address, configured.PrefixLength));
+ }
+ if (candidates.Count == 0) return null;
+ foreach (var candidate in candidates)
+ {
+ if (IsSameSubnet(
+ new Ipv4Address(candidate.Address.ToString(), candidate.PrefixLength),
+ destination))
+ return candidate.Address;
+ }
+ return candidates[0].Address;
+ }
+
+ private static bool IsSameSubnet(Ipv4Address local, IPAddress destination)
+ {
+ if (local.PrefixLength is < 1 or > 32 || destination.AddressFamily != AddressFamily.InterNetwork)
+ return false;
+ var left = IPAddress.Parse(local.Address).GetAddressBytes();
+ var right = destination.GetAddressBytes();
+ for (var bit = 0; bit < local.PrefixLength; bit++)
+ if ((left[bit / 8] & (1 << (7 - bit % 8))) != (right[bit / 8] & (1 << (7 - bit % 8))))
+ return false;
+ return true;
+ }
+
+ private static TargetSpec[] ParseTargets(string[] hosts)
+ {
+ var targets = new TargetSpec[hosts.Length];
+ for (var index = 0; index < hosts.Length; index++)
+ {
+ var raw = hosts[index].Trim();
+ if (IPAddress.TryParse(raw, out var ip))
+ {
+ if (ip.AddressFamily != AddressFamily.InterNetwork)
+ throw new ArgumentException("健康探测仅支持 IPv4 目标。", nameof(hosts));
+ targets[index] = new TargetSpec(raw, ip);
+ continue;
+ }
+ var ascii = new IdnMapping().GetAscii(raw).TrimEnd('.');
+ if (ascii.Length is 0 or > 253 || Uri.CheckHostName(ascii) != UriHostNameType.Dns)
+ throw new ArgumentException($"探测目标主机名无效:{hosts[index]}", nameof(hosts));
+ targets[index] = new TargetSpec(ascii, null);
+ }
+ return targets;
+ }
+
+ private sealed record TargetSpec(string Host, IPAddress? Address);
+ private sealed record Resolution(TargetSpec Target, IPAddress[] Addresses, string? Error);
+ private sealed record ResolvedTarget(Resolution Resolution, bool Success);
+ private sealed record DnsAttempt(IPAddress[]? Addresses, string? Error);
+ private sealed record DnsStage(Resolution[] Resolutions, int ResolvedCount);
+ private sealed record SocksOutcome(string Host, bool Success, string Detail);
+ private sealed record SocksStage(int HealthyCount, SocksOutcome[] Outcomes);
+ private sealed record AddressOutcome(bool Success, string Detail);
+ private sealed record EgressOutcome(string Host, bool Success, string Detail);
+ private sealed record EgressStage(int HealthyCount, EgressOutcome[] Outcomes);
+
+ private sealed class ProbeExecution(T? value, ProbeResult? result) where T : class
+ {
+ public T? Value { get; } = value;
+ public ProbeResult Result { get; } = result ?? new ProbeResult(string.Empty, false, 0, string.Empty);
+ }
+}
diff --git a/src/GatewayKeepalive.Core/Probes/Socks5Probe.cs b/src/GatewayKeepalive.Core/Probes/Socks5Probe.cs
new file mode 100644
index 0000000..d64f6cb
--- /dev/null
+++ b/src/GatewayKeepalive.Core/Probes/Socks5Probe.cs
@@ -0,0 +1,137 @@
+using System.Globalization;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+
+namespace GatewayKeepalive.Core;
+
+public sealed class Socks5Exception : IOException
+{
+ public Socks5Exception(byte replyCode, string message) : base(message) => ReplyCode = replyCode;
+
+ public byte ReplyCode { get; }
+}
+
+/// Minimal, strict SOCKS5 CONNECT client used by the health probe.
+public static class Socks5Probe
+{
+ private const byte Version = 5;
+ private const byte NoAuthentication = 0;
+
+ ///
+ /// Negotiates a SOCKS5 CONNECT on an existing stream. The stream remains owned by the caller.
+ /// The method consumes exactly one complete reply and never reads past it.
+ ///
+ public static async Task ConnectAsync(Stream stream, string host, int port, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(stream);
+ ct.ThrowIfCancellationRequested();
+ if (port is < 1 or > 65535) throw new ArgumentOutOfRangeException(nameof(port));
+
+ var address = EncodeTarget(host);
+ await WriteAllAsync(stream, new byte[] { Version, 1, NoAuthentication }, ct).ConfigureAwait(false);
+ await stream.FlushAsync(ct).ConfigureAwait(false);
+
+ var greetingReply = new byte[2];
+ await ReadExactlyAsync(stream, greetingReply, ct).ConfigureAwait(false);
+ if (greetingReply[0] != Version)
+ throw new InvalidDataException($"SOCKS5 握手响应版本无效:{greetingReply[0]}。");
+ if (greetingReply[1] != NoAuthentication)
+ {
+ throw greetingReply[1] switch
+ {
+ 0x02 => new Socks5Exception(greetingReply[1], "SOCKS5 服务要求用户名密码认证;程序仅提供无认证。"),
+ 0xff => new Socks5Exception(greetingReply[1], "SOCKS5 服务不接受无认证方式。"),
+ _ => new Socks5Exception(greetingReply[1], $"SOCKS5 服务选择了未提供的方法:0x{greetingReply[1]:X2}。")
+ };
+ }
+
+ var request = new byte[3 + address.Length + 2];
+ request[0] = Version;
+ request[1] = 1; // CONNECT
+ request[2] = 0;
+ address.CopyTo(request, 3);
+ request[^2] = (byte)(port >> 8);
+ request[^1] = (byte)port;
+ await WriteAllAsync(stream, request, ct).ConfigureAwait(false);
+ await stream.FlushAsync(ct).ConfigureAwait(false);
+
+ var reply = new byte[4];
+ await ReadExactlyAsync(stream, reply, ct).ConfigureAwait(false);
+ if (reply[0] != Version)
+ throw new InvalidDataException($"SOCKS5 CONNECT 响应版本无效:{reply[0]}。");
+ if (reply[2] != 0)
+ throw new InvalidDataException($"SOCKS5 CONNECT 响应保留字段无效:{reply[2]}。");
+
+ // Consume the variable-length bound endpoint even for an error reply, so a
+ // caller can safely inspect or reuse the stream after a policy failure.
+ await ConsumeBoundEndpointAsync(stream, reply[3], ct).ConfigureAwait(false);
+ if (reply[1] != 0)
+ throw new Socks5Exception(reply[1], $"SOCKS5 CONNECT 被拒绝,REP=0x{reply[1]:X2}。");
+ }
+
+ private static byte[] EncodeTarget(string host)
+ {
+ if (string.IsNullOrWhiteSpace(host)) throw new ArgumentException("SOCKS5 目标主机为空。", nameof(host));
+ host = host.Trim();
+ if (host.Contains('\0') || host.Contains('/') || host.Contains('\\'))
+ throw new ArgumentException("SOCKS5 目标主机格式无效。", nameof(host));
+
+ if (IPAddress.TryParse(host, out var ip))
+ {
+ var bytes = ip.GetAddressBytes();
+ if (ip.AddressFamily == AddressFamily.InterNetwork)
+ return [1, .. bytes];
+ if (ip.AddressFamily == AddressFamily.InterNetworkV6)
+ return [4, .. bytes];
+ throw new ArgumentException("SOCKS5 目标地址类型不受支持。", nameof(host));
+ }
+
+ var ascii = new IdnMapping().GetAscii(host).TrimEnd('.');
+ if (ascii.Length is 0 or > 255) throw new ArgumentException("SOCKS5 域名长度无效。", nameof(host));
+ var encoded = Encoding.ASCII.GetBytes(ascii);
+ if (encoded.Length is 0 or > 255) throw new ArgumentException("SOCKS5 域名长度无效。", nameof(host));
+ return [3, (byte)encoded.Length, .. encoded];
+ }
+
+ private static async Task ConsumeBoundEndpointAsync(Stream stream, byte addressType, CancellationToken ct)
+ {
+ var addressLength = addressType switch
+ {
+ 1 => 4,
+ 4 => 16,
+ 3 => await ReadOneAsync(stream, ct).ConfigureAwait(false),
+ _ => throw new InvalidDataException($"SOCKS5 响应 ATYP 无效:0x{addressType:X2}。")
+ };
+ if (addressType == 3 && addressLength == 0)
+ throw new InvalidDataException("SOCKS5 响应中的域名为空。");
+
+ var remainder = new byte[addressLength + 2];
+ await ReadExactlyAsync(stream, remainder, ct).ConfigureAwait(false);
+ }
+
+ private static async Task ReadOneAsync(Stream stream, CancellationToken ct)
+ {
+ var one = new byte[1];
+ await ReadExactlyAsync(stream, one, ct).ConfigureAwait(false);
+ return one[0];
+ }
+
+ internal static async Task ReadExactlyAsync(Stream stream, Memory buffer, CancellationToken ct)
+ {
+ var read = 0;
+ while (read < buffer.Length)
+ {
+ ct.ThrowIfCancellationRequested();
+ var count = await stream.ReadAsync(buffer[read..], ct).ConfigureAwait(false);
+ if (count == 0) throw new EndOfStreamException("SOCKS5 响应在完整读取前结束。");
+ read += count;
+ }
+ }
+
+ private static async Task WriteAllAsync(Stream stream, ReadOnlyMemory buffer, CancellationToken ct)
+ {
+ ct.ThrowIfCancellationRequested();
+ await stream.WriteAsync(buffer, ct).ConfigureAwait(false);
+ }
+}
diff --git a/src/GatewayKeepalive.Core/SwitchPolicy.cs b/src/GatewayKeepalive.Core/SwitchPolicy.cs
new file mode 100644
index 0000000..4d78f94
--- /dev/null
+++ b/src/GatewayKeepalive.Core/SwitchPolicy.cs
@@ -0,0 +1,36 @@
+namespace GatewayKeepalive.Core;
+
+public sealed class SwitchPolicy(TimeProvider? timeProvider = null)
+{
+ private readonly TimeProvider _clock = timeProvider ?? TimeProvider.System;
+ private long? _lastSwitch;
+ private long? _retryAfterFailure;
+ public int SuccessCount { get; private set; }
+ public int FailureCount { get; private set; }
+
+ public void Reset() { SuccessCount = 0; FailureCount = 0; }
+ public void Switched() { Reset(); _lastSwitch = _clock.GetTimestamp(); _retryAfterFailure = null; }
+ public void Failed() { Reset(); _retryAfterFailure = _clock.GetTimestamp(); }
+
+ // null means observe only; true/false requests proxy/direct respectively.
+ public bool? Evaluate(AppSettings settings, NetworkMode mode, HealthReport health)
+ {
+ if (settings.Policy != ControlPolicy.Auto || mode is not (NetworkMode.Direct or NetworkMode.Proxy)) { Reset(); return null; }
+ if (mode == NetworkMode.Proxy)
+ {
+ FailureCount = health.ProxyHealthy && health.InternetHealthy ? 0 : Math.Min(FailureCount + 1, settings.FailureThreshold);
+ SuccessCount = 0;
+ return FailureCount >= settings.FailureThreshold && CooldownExpired(settings.FailureCooldownSeconds) ? false : null;
+ }
+ SuccessCount = health.ProxyHealthy ? Math.Min(SuccessCount + 1, settings.SuccessThreshold) : 0;
+ FailureCount = 0;
+ return SuccessCount >= settings.SuccessThreshold && CooldownExpired(settings.RecoveryCooldownSeconds) ? true : null;
+ }
+
+ private bool CooldownExpired(int seconds)
+ {
+ var now = _clock.GetTimestamp();
+ return (!_lastSwitch.HasValue || _clock.GetElapsedTime(_lastSwitch.Value, now).TotalSeconds >= seconds) &&
+ (!_retryAfterFailure.HasValue || _clock.GetElapsedTime(_retryAfterFailure.Value, now).TotalSeconds >= Math.Max(30, seconds));
+ }
+}
diff --git a/src/GatewayKeepalive.Core/SwitchService.cs b/src/GatewayKeepalive.Core/SwitchService.cs
new file mode 100644
index 0000000..9d081e3
--- /dev/null
+++ b/src/GatewayKeepalive.Core/SwitchService.cs
@@ -0,0 +1,122 @@
+using System.Net;
+
+namespace GatewayKeepalive.Core;
+
+public sealed class RecoveryRequiredException(string message, Exception? inner = null) : Exception(message, inner);
+
+public sealed class SwitchService(INetworkBackend backend, IHealthProbe probe, JsonStore store, Action log)
+{
+ public const string JournalFile = "switch-journal.json";
+
+ public NetworkSnapshot Plan(AppSettings settings, NetworkSnapshot before, bool proxy, DnsConfiguration? directDns)
+ {
+ settings.Validate();
+ if (!before.Adapter.Up) throw new InvalidOperationException("目标网卡未连接。");
+ if (string.IsNullOrWhiteSpace(before.Adapter.NetworkFingerprint)) throw new InvalidOperationException("无法识别 Windows 网络,暂不接管。");
+ if (before.Dns.ProfileServers.Length > 0) throw new InvalidOperationException("该网卡使用网络配置文件 DNS,请先在系统中处理,程序不会覆盖。");
+ if (before.Routes.Length == 0) throw new InvalidOperationException("没有可接管的 IPv4 默认路由。");
+ if (before.Routes.Any(x => x.Gateway != settings.DirectGateway && x.Gateway != settings.ProxyGateway)) throw new InvalidOperationException("检测到配置以外的默认网关,请核对网络后重新接管。");
+ var target = proxy ? settings.ProxyGateway : settings.DirectGateway;
+ if (!before.Adapter.Addresses.Any(x => IsSameSubnet(x, target))) throw new InvalidOperationException("目标网关不在所选网卡的直连 IPv4 子网内。");
+ if (before.Adapter.Addresses.Any(x => x.Address == target)) throw new InvalidOperationException("网关不能是本机地址。");
+ var existing = before.Routes.FirstOrDefault(x => x.Gateway == target);
+ var targetMetric = existing is not null && before.Gateway == target ? existing.Metric : 1u;
+ var route = (existing ?? before.Routes[0] with { Gateway = target, Protocol = 3, ValidLifetime = uint.MaxValue, PreferredLifetime = uint.MaxValue, Publish = false, Immortal = false }) with { Metric = targetMetric };
+ var routes = before.Routes.Where(x => x.Gateway != target).Select(x => x with { Metric = Math.Max(x.Metric, checked(targetMetric + 100)) }).Append(route).ToArray();
+ var dns = proxy ? new DnsConfiguration(false, [settings.ProxyDns], []) : directDns ?? (settings.DirectDnsAutomatic ? DnsConfiguration.Auto : new(false, settings.DirectDnsServers, []));
+ return before with { Routes = routes, Dns = dns };
+ }
+
+ public async Task SwitchAsync(AppSettings settings, NetworkSnapshot expected, bool proxy, DnsConfiguration? directDns, CancellationToken cancellationToken)
+ {
+ if (!backend.IsAdministrator) throw new UnauthorizedAccessException("修改默认网关需要管理员权限。");
+ if (store.Read(JournalFile) is not null) throw new RecoveryRequiredException("存在未完成切换,请先恢复。");
+ var before = await backend.CaptureAsync(settings.AdapterId, cancellationToken);
+ if (!NetworkComparison.SameConfiguration(expected, before)) throw new InvalidOperationException("网络配置已被外部修改,请重新保存配置以接管。");
+ var desired = Plan(settings, before, proxy, directDns);
+ var journal = new SwitchJournal(before, desired, "Prepared", DateTimeOffset.UtcNow);
+ store.Write(JournalFile, journal);
+ try
+ {
+ log($"开始切换到 {(proxy ? "旁路由" : "直连")}:{desired.Gateway}");
+ store.Write(JournalFile, journal with { Stage = "ApplyingRoutes" });
+ if (!NetworkComparison.SameRoutes(before.Routes, desired.Routes)) await backend.WriteRoutesGuardedAsync(desired, before, cancellationToken);
+ store.Write(JournalFile, journal with { Stage = "ApplyingDns" });
+ if (!NetworkComparison.SameDns(before.Dns, desired.Dns)) await backend.WriteDnsGuardedAsync(settings.AdapterId, desired.Dns, before with { Routes = desired.Routes }, cancellationToken);
+ var actual = await backend.CaptureAsync(settings.AdapterId, cancellationToken);
+ if (!NetworkComparison.SameConfiguration(desired, actual)) throw new IOException("路由或 DNS 写入后读回不一致。");
+ using var budget = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ budget.CancelAfter(settings.RoundTimeoutMs);
+ if (!await probe.VerifyInternetAsync(settings, actual, budget.Token)) throw new IOException("切换后 IPv4 出口验证未通过。");
+ // Keep the completed journal until controller session state is durable.
+ store.Write(JournalFile, journal with { Stage = "Verified" });
+ log("路由、DNS 与实际出口验证通过。");
+ return actual;
+ }
+ catch (Exception failure)
+ {
+ log("切换失败,开始恢复:" + failure.Message);
+ try
+ {
+ using var rollbackBudget = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+ await RollbackAsync(journal, rollbackBudget.Token);
+ }
+ catch (Exception rollback)
+ {
+ throw new RecoveryRequiredException("切换失败且恢复未完成,自动控制已停止:" + rollback.Message, new AggregateException(failure, rollback));
+ }
+ throw new IOException("切换未成功,已恢复切换前配置:" + failure.Message, failure);
+ }
+ }
+
+ public async Task RollbackAsync(SwitchJournal journal, CancellationToken cancellationToken, bool retainJournal = false)
+ {
+ var actual = await backend.CaptureAsync(journal.Before.Adapter.Id, cancellationToken);
+ if (!NetworkComparison.BelongsToTransaction(journal, actual)) throw new RecoveryRequiredException("现场已不属于本次切换,停止恢复以保护外部配置。");
+ store.Write(JournalFile, journal with { Stage = "RollingBack" });
+ if (!NetworkComparison.SameRoutes(actual.Routes, journal.Before.Routes)) await backend.WriteRoutesGuardedAsync(journal.Before, actual, cancellationToken);
+ if (!NetworkComparison.SameDns(actual.Dns, journal.Before.Dns)) await backend.WriteDnsGuardedAsync(journal.Before.Adapter.Id, journal.Before.Dns, actual with { Routes = journal.Before.Routes }, cancellationToken);
+ var restored = await backend.CaptureAsync(journal.Before.Adapter.Id, cancellationToken);
+ if (!NetworkComparison.SameConfiguration(journal.Before, restored)) throw new RecoveryRequiredException("恢复后读回不一致。");
+ if (retainJournal) store.Write(JournalFile, journal with { Stage = "RolledBack" });
+ else store.Delete(JournalFile);
+ log("切换前配置已恢复并读回验证。");
+ return restored;
+ }
+
+ public async Task RestoreBaselineAsync(NetworkSnapshot expected, NetworkSnapshot baseline, CancellationToken cancellationToken)
+ {
+ if (!backend.IsAdministrator) throw new UnauthorizedAccessException("恢复网络需要管理员权限。");
+ if (!NetworkComparison.SameNetwork(expected, baseline)) throw new RecoveryRequiredException("原始快照不属于当前网络。");
+ if (store.Read(JournalFile) is not null) throw new RecoveryRequiredException("请先处理未完成的切换日志。");
+ var actual = await backend.CaptureAsync(expected.Adapter.Id, cancellationToken);
+ if (!NetworkComparison.SameConfiguration(expected, actual)) throw new RecoveryRequiredException("现场已被外部修改,不能覆盖。");
+ var journal = new SwitchJournal(actual, baseline, "RestoringBaseline", DateTimeOffset.UtcNow);
+ store.Write(JournalFile, journal);
+ try
+ {
+ if (!NetworkComparison.SameRoutes(actual.Routes, baseline.Routes)) await backend.WriteRoutesGuardedAsync(baseline, actual, cancellationToken);
+ if (!NetworkComparison.SameDns(actual.Dns, baseline.Dns)) await backend.WriteDnsGuardedAsync(baseline.Adapter.Id, baseline.Dns, actual with { Routes = baseline.Routes }, cancellationToken);
+ actual = await backend.CaptureAsync(baseline.Adapter.Id, cancellationToken);
+ if (!NetworkComparison.SameConfiguration(baseline, actual)) throw new IOException("原始配置恢复后读回不一致。");
+ store.Write(JournalFile, journal with { Stage = "Verified" });
+ return actual;
+ }
+ catch (Exception failure)
+ {
+ try { using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(15)); await RollbackAsync(journal, budget.Token); }
+ catch (Exception rollback) { throw new RecoveryRequiredException("恢复失败且补偿未完成:" + rollback.Message, failure); }
+ throw new IOException("恢复失败,已退回恢复操作前的配置。", failure);
+ }
+ }
+
+ private static bool IsSameSubnet(Ipv4Address local, string address)
+ {
+ if (local.PrefixLength is < 1 or > 32) return false;
+ var left = IPAddress.Parse(local.Address).GetAddressBytes();
+ var right = IPAddress.Parse(address).GetAddressBytes();
+ for (var bit = 0; bit < local.PrefixLength; bit++)
+ if ((left[bit / 8] & (1 << (7 - bit % 8))) != (right[bit / 8] & (1 << (7 - bit % 8)))) return false;
+ return true;
+ }
+}
diff --git a/src/GatewayKeepalive.Tray/GatewayKeepalive.Tray.csproj b/src/GatewayKeepalive.Tray/GatewayKeepalive.Tray.csproj
new file mode 100644
index 0000000..fa3cd90
--- /dev/null
+++ b/src/GatewayKeepalive.Tray/GatewayKeepalive.Tray.csproj
@@ -0,0 +1,13 @@
+
+
+ WinExe
+ true
+ app.manifest
+ GatewayKeepalive
+ 0.1.0
+
+
+
+
+
+
diff --git a/src/GatewayKeepalive.Tray/Program.cs b/src/GatewayKeepalive.Tray/Program.cs
new file mode 100644
index 0000000..fb45d6d
--- /dev/null
+++ b/src/GatewayKeepalive.Tray/Program.cs
@@ -0,0 +1,98 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Security.Principal;
+using GatewayKeepalive.Core;
+using GatewayKeepalive.Windows;
+
+namespace GatewayKeepalive.Tray;
+
+internal static class Program
+{
+ [STAThread]
+ private static int Main(string[] args)
+ {
+ TryInitializeWinForms();
+ if (args.Contains("--preview"))
+ {
+ var index = Array.IndexOf(args, "--preview");
+ var path = index + 1 < args.Length ? args[index + 1] : Path.Combine(AppContext.BaseDirectory, "preview");
+ TrayApplicationContext.RenderPreview(path);
+ return 0;
+ }
+ if (args.Any(x => x is "--diagnose" or "--dry-run" or "--probe")) return DiagnosticCommands.RunAsync(args).GetAwaiter().GetResult();
+ if (args.Contains("--help"))
+ {
+ MessageBox.Show("网关保活\n直接运行:托盘程序\n--minimized:仅驻留托盘\n--diagnose --output report.json:只读诊断\n--dry-run --adapter GUID:只生成切换计划\n--preview 目录:离屏界面预览\n控制台详细输出请使用 GatewayKeepalive.Cli。", "网关保活");
+ return 0;
+ }
+ var backend = new WindowsNetworkBackend();
+ if (!backend.IsAdministrator)
+ {
+ try
+ {
+ var start = new ProcessStartInfo(Environment.ProcessPath!) { UseShellExecute = true, Verb = "runas" };
+ if (string.Equals(Path.GetFileNameWithoutExtension(Environment.ProcessPath), "dotnet", StringComparison.OrdinalIgnoreCase))
+ start.ArgumentList.Add(Path.Combine(AppContext.BaseDirectory, "GatewayKeepalive.dll"));
+ foreach (var arg in args) start.ArgumentList.Add(arg);
+ Process.Start(start);
+ return 0;
+ }
+ catch (Win32Exception e) when (e.NativeErrorCode == 1223) { return 1; }
+ }
+
+ using var identity = WindowsIdentity.GetCurrent();
+ var suffix = identity.User!.Value;
+ using var mutex = new Mutex(true, "Local\\GatewayKeepalive-" + suffix, out var created);
+ using var show = new EventWaitHandle(false, EventResetMode.AutoReset, "Local\\GatewayKeepalive-Show-" + suffix);
+ if (!created) { show.Set(); return 0; }
+ var directory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "GatewayKeepalive");
+ var logger = new FileLog(Path.Combine(directory, "logs"));
+ try
+ {
+ var store = new JsonStore(directory);
+ var startup = new LoginStartup();
+ var probe = new NetworkHealthProbe(backend.ValidateRoute);
+ var controller = new GatewayController(backend, probe, store, logger.Write, () => startup.IsEnabled, startup.SetEnabled);
+ using var app = new TrayApplicationContext(controller, !controller.Settings.Configured || !args.Contains("--minimized"));
+ using var dispatcher = new Control();
+ _ = dispatcher.Handle;
+ var wait = ThreadPool.RegisterWaitForSingleObject(show, (_, _) =>
+ {
+ try { if (!dispatcher.IsDisposed) dispatcher.BeginInvoke(() => ShowExisting(app)); }
+ catch (InvalidOperationException) when (dispatcher.IsDisposed || dispatcher.Disposing) { }
+ }, null, Timeout.Infinite, false);
+ Application.ThreadException += (_, e) =>
+ {
+ logger.Write("UI异常:" + e.Exception);
+ MessageBox.Show(e.Exception.Message, "网关保活", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ };
+ controller.Start();
+ try { Application.Run(app); }
+ finally { wait.Unregister(null); controller.StopAsync().GetAwaiter().GetResult(); }
+ return 0;
+ }
+ catch (Exception e)
+ {
+ try { logger.Write("启动/退出失败:" + e); } catch (Exception) { }
+ MessageBox.Show("程序无法继续:" + e.Message + "\n配置与恢复记录保留在:\n" + directory, "网关保活", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ return 1;
+ }
+ }
+
+ private static void ShowExisting(TrayApplicationContext app) => app.ShowPanel();
+
+ ///
+ /// Single-file apps extract the WinForms theming manifest to the temp directory. If that
+ /// location is restricted, visual styles are decorative only for this fully custom-drawn UI;
+ /// the tray must still start.
+ ///
+ private static void TryInitializeWinForms()
+ {
+ try { Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); }
+ catch (Exception e) when (e is InvalidOperationException or PlatformNotSupportedException) { }
+ try { Application.EnableVisualStyles(); }
+ catch (Exception e) when (e is UnauthorizedAccessException or IOException or InvalidOperationException or TypeInitializationException) { }
+ try { Application.SetCompatibleTextRenderingDefault(false); }
+ catch (InvalidOperationException) { }
+ }
+}
diff --git a/src/GatewayKeepalive.Tray/TrayApplicationContext.cs b/src/GatewayKeepalive.Tray/TrayApplicationContext.cs
new file mode 100644
index 0000000..3d192ae
--- /dev/null
+++ b/src/GatewayKeepalive.Tray/TrayApplicationContext.cs
@@ -0,0 +1,794 @@
+using System.Diagnostics;
+using System.Drawing;
+using System.Drawing.Imaging;
+using System.Runtime.ExceptionServices;
+using System.Windows.Forms;
+using GatewayKeepalive.Core;
+using GatewayKeepalive.Tray.Ui;
+
+namespace GatewayKeepalive.Tray;
+
+public sealed class TrayApplicationContext : ApplicationContext
+{
+ private readonly ITrayController _controller;
+ private readonly SynchronizationContext? _initialContext;
+ private readonly int _uiThreadId;
+ private readonly Control _dispatcher;
+ private readonly TrayIconFactory _icons = new();
+ private readonly NotifyIcon _tray;
+ private readonly ContextMenuStrip _menu;
+ private readonly StatusPanelForm _panel;
+ private readonly System.Windows.Forms.Timer _clockTimer;
+ private ToolStripMenuItem _autoPolicyItem = null!;
+ private ToolStripMenuItem _manualMenu = null!;
+ private ToolStripMenuItem _manualDirectItem = null!;
+ private ToolStripMenuItem _manualProxyItem = null!;
+ private ToolStripMenuItem _refreshItem = null!;
+ private ToolStripMenuItem _restoreItem = null!;
+ private ToolStripMenuItem _autostartItem = null!;
+ private ToolStripMenuItem _logsItem = null!;
+ private ToolStripMenuItem _exitItem = null!;
+ private readonly StatusNotificationTracker _statusNotifications = new();
+
+ private AppStatus _status = AppStatus.Initial;
+ private SettingsForm? _settingsForm;
+ private bool _busy;
+ private bool _exitInProgress;
+ private bool _disposed;
+
+ public TrayApplicationContext(ITrayController controller, bool showPanel = false)
+ {
+ ArgumentNullException.ThrowIfNull(controller);
+ TryEnablePerMonitorV2();
+
+ _controller = controller;
+ _uiThreadId = Environment.CurrentManagedThreadId;
+ _initialContext = SynchronizationContext.Current;
+ _dispatcher = new Control { Visible = false, Size = Size.Empty };
+ _ = _dispatcher.Handle;
+
+ _panel = new StatusPanelForm();
+ _panel.RefreshRequested += OnPanelRefresh;
+ _panel.SettingsRequested += OnPanelSettings;
+ _panel.LogsRequested += OnPanelLogs;
+ _panel.PolicyRequested += OnPanelPolicy;
+
+ _menu = BuildMenu();
+ _tray = new NotifyIcon
+ {
+ Visible = true,
+ ContextMenuStrip = _menu,
+ Icon = _icons.Get(AppStatus.Initial),
+ Text = "网关保活"
+ };
+ _tray.MouseClick += OnTrayMouseClick;
+
+ _clockTimer = new System.Windows.Forms.Timer { Interval = 30_000 };
+ _clockTimer.Tick += OnClockTick;
+ _clockTimer.Start();
+
+ _controller.StatusChanged += OnStatusChanged;
+ ApplyStatus(ReadStatus(AppStatus.Initial));
+
+ if (showPanel) ShowPanel();
+ }
+
+ private ContextMenuStrip BuildMenu()
+ {
+ var menu = new ContextMenuStrip
+ {
+ Renderer = new ToolStripProfessionalRenderer(new DarkMenuColors()),
+ BackColor = UiTheme.Card,
+ ForeColor = UiTheme.Text,
+ Font = UiTheme.Body,
+ ShowImageMargin = false,
+ ShowCheckMargin = true,
+ Padding = new Padding(3)
+ };
+ menu.Opening += OnMenuOpening;
+
+ var open = new ToolStripMenuItem("打开面板") { Font = UiTheme.BodyBold };
+ open.Click += (_, _) => ShowPanel();
+ menu.Items.Add(open);
+
+ _refreshItem = new ToolStripMenuItem("立即检测");
+ _refreshItem.Click += (_, _) => StartAction("检测失败", _controller.RefreshAsync);
+ menu.Items.Add(_refreshItem);
+ menu.Items.Add(new ToolStripSeparator());
+
+ _autoPolicyItem = new ToolStripMenuItem("自动保活");
+ _autoPolicyItem.Click += (_, _) =>
+ {
+ var target = _status.Policy == ControlPolicy.Auto ? ControlPolicy.Paused : ControlPolicy.Auto;
+ StartAction("自动保活设置失败", () => _controller.SetPolicyAsync(target));
+ };
+ menu.Items.Add(_autoPolicyItem);
+
+ _manualMenu = new ToolStripMenuItem("手动切换");
+ _manualDirectItem = new ToolStripMenuItem("使用主路由(固定)");
+ _manualDirectItem.ToolTipText = "明确进入固定模式,继续监测但不自动切换";
+ _manualDirectItem.Click += (_, _) => StartAction("切换到主路由失败",
+ () => _controller.SetPolicyAsync(ControlPolicy.ForceDirect));
+ _manualMenu.DropDownItems.Add(_manualDirectItem);
+ _manualProxyItem = new ToolStripMenuItem("使用旁路由(固定)");
+ _manualProxyItem.ToolTipText = "明确进入固定模式,继续监测但不自动切换";
+ _manualProxyItem.Click += (_, _) => StartAction("切换到旁路由失败",
+ () => _controller.SetPolicyAsync(ControlPolicy.ForceProxy));
+ _manualMenu.DropDownItems.Add(_manualProxyItem);
+ menu.Items.Add(_manualMenu);
+ menu.Items.Add(new ToolStripSeparator());
+
+ _restoreItem = new ToolStripMenuItem("恢复接管前配置…");
+ _restoreItem.Click += (_, _) => RequestRestore();
+ menu.Items.Add(_restoreItem);
+
+ _autostartItem = new ToolStripMenuItem("登录后自动启动");
+ _autostartItem.Click += (_, _) => ToggleAutostart();
+ menu.Items.Add(_autostartItem);
+
+ _logsItem = new ToolStripMenuItem("查看日志");
+ _logsItem.Click += (_, _) => OpenLogs();
+ menu.Items.Add(_logsItem);
+ menu.Items.Add(new ToolStripSeparator());
+
+ _exitItem = new ToolStripMenuItem("退出");
+ _exitItem.Click += (_, _) => _ = ExitAsync();
+ menu.Items.Add(_exitItem);
+
+ foreach (ToolStripItem item in menu.Items) item.ForeColor = UiTheme.Text;
+ foreach (ToolStripItem item in _manualMenu.DropDownItems) item.ForeColor = UiTheme.Text;
+ return menu;
+ }
+
+ private void OnTrayMouseClick(object? sender, MouseEventArgs e)
+ {
+ if (e.Button != MouseButtons.Left) return;
+ if (_panel.Visible)
+ {
+ _panel.HidePanel();
+ return;
+ }
+
+ // Deactivate runs just before the tray click and has already fulfilled the close gesture.
+ if (_panel.LastAutoHideAt is { } hiddenAt && DateTime.UtcNow - hiddenAt < TimeSpan.FromMilliseconds(350))
+ return;
+ ShowPanel();
+ }
+
+ public void ShowPanel()
+ {
+ if (_disposed || _exitInProgress) return;
+ _panel.ApplyStatus(_status);
+ _panel.ShowNearTray();
+ }
+
+ private void OnStatusChanged(AppStatus status)
+ {
+ if (status is null) return;
+ Post(() =>
+ {
+ if (!_disposed) ApplyStatus(status);
+ });
+ }
+
+ private void ApplyStatus(AppStatus status)
+ {
+ if (_disposed) return;
+ _status = status;
+ _panel.ApplyStatus(status);
+ RefreshTrayPresentation();
+ var notifications = NotificationsEnabled();
+ foreach (var notification in _statusNotifications.Observe(status))
+ {
+ if (notifications)
+ ShowStatusBalloon(notification.Title, notification.Message, notification.Icon);
+ }
+ }
+
+ private bool NotificationsEnabled()
+ {
+ try { return _controller.Settings.Notifications; }
+ catch (Exception) { return false; }
+ }
+
+ private void ShowStatusBalloon(string title, string message, ToolTipIcon icon)
+ {
+ if (_disposed) return;
+ try { _tray.ShowBalloonTip(6000, UiTheme.Clip(title, 60), message, icon); }
+ catch (Exception balloonException) { _ = balloonException; }
+ }
+
+ private void RefreshTrayPresentation()
+ {
+ if (_disposed) return;
+ try
+ {
+ _tray.Icon = _icons.Get(_status);
+ var path = _status.ActivePath != NetworkMode.Initializing ? _status.ActivePath : _status.Mode;
+ var route = path switch
+ {
+ NetworkMode.Proxy => "旁路由",
+ NetworkMode.Direct => "主路由",
+ NetworkMode.ExternalConfiguration => "外部网络",
+ NetworkMode.Switching => "切换中",
+ NetworkMode.Disconnected => "未连接",
+ _ => "等待接管"
+ };
+ var text = $"网关保活 · {route} · {UiTheme.PolicyName(_status.Policy)}";
+ if (!string.IsNullOrWhiteSpace(_status.Network?.Gateway)) text += " · " + _status.Network.Gateway;
+ _tray.Text = UiTheme.Clip(text, 60);
+ }
+ catch (Exception exception)
+ {
+ _tray.Text = "网关保活";
+ _ = exception;
+ }
+ }
+
+ private void OnClockTick(object? sender, EventArgs e)
+ {
+ if (_disposed) return;
+ _panel.RefreshClock();
+ }
+
+ private void OnMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e)
+ {
+ if (_disposed)
+ {
+ e.Cancel = true;
+ return;
+ }
+ var actionsEnabled = !_busy && !_exitInProgress;
+ _autoPolicyItem.Enabled = actionsEnabled;
+ _manualMenu.Enabled = actionsEnabled;
+ _refreshItem.Enabled = actionsEnabled;
+ _restoreItem.Enabled = actionsEnabled;
+ _autostartItem.Enabled = actionsEnabled;
+ _logsItem.Enabled = !_exitInProgress;
+ _exitItem.Enabled = !_exitInProgress;
+ _autoPolicyItem.Checked = _status.Policy == ControlPolicy.Auto;
+ _manualDirectItem.Checked = _status.Policy == ControlPolicy.ForceDirect;
+ _manualProxyItem.Checked = _status.Policy == ControlPolicy.ForceProxy;
+
+ try
+ {
+ _autostartItem.Checked = _controller.AutostartEnabled;
+ _autostartItem.ToolTipText = _autostartItem.Checked ? "当前已设置登录后启动" : "当前未设置登录后启动";
+ }
+ catch (Exception exception)
+ {
+ _autostartItem.Checked = false;
+ _autostartItem.ToolTipText = UiTheme.ExceptionText(exception);
+ }
+ }
+
+ private void OnPanelRefresh(object? sender, EventArgs e) => StartAction("检测失败", _controller.RefreshAsync);
+
+ private void OnPanelPolicy(ControlPolicy policy) =>
+ StartAction("切换模式失败", () => _controller.SetPolicyAsync(policy));
+
+ private void OnPanelSettings(object? sender, EventArgs e) => _ = OpenSettingsAsync();
+
+ private void OnPanelLogs(object? sender, EventArgs e) => OpenLogs();
+
+ private void RequestRestore()
+ {
+ if (_busy || _exitInProgress) return;
+ var answer = ShowOwnedMessageBox(
+ "恢复接管前的网关、DNS 和路由配置?\n\n恢复成功后会自动暂停保活,避免立刻再次切换。",
+ "恢复网络配置", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2);
+ if (answer == DialogResult.Yes)
+ StartAction("恢复网络失败", _controller.RestoreAsync);
+ }
+
+ private void StartAction(string errorTitle, Func action)
+ {
+ _ = RunActionAsync(errorTitle, action);
+ }
+
+ private async Task RunActionAsync(string errorTitle, Func action)
+ {
+ if (_disposed || _exitInProgress) return;
+ if (_busy)
+ {
+ _panel.ShowNotice("上一项操作尚未完成,请稍候。", false);
+ return;
+ }
+
+ _busy = true;
+ _panel.SetBusy(true);
+ try
+ {
+ await action();
+ if (!_disposed && !_exitInProgress) ApplyStatus(ReadStatus(_status));
+ }
+ catch (Exception exception)
+ {
+ if (!_disposed)
+ {
+ var message = UiTheme.ExceptionText(exception);
+ ApplyStatus(ReadStatus(_status));
+ ReportError(errorTitle, message);
+ }
+ }
+ finally
+ {
+ if (!_disposed)
+ {
+ _busy = false;
+ _panel.SetBusy(false);
+ }
+ }
+ }
+
+ private async Task OpenSettingsAsync()
+ {
+ if (_disposed || _exitInProgress) return;
+ if (_busy)
+ {
+ _panel.ShowNotice("上一项操作尚未完成,请稍候再打开设置。", false);
+ return;
+ }
+
+ _busy = true;
+ _panel.SetBusy(true);
+ var previousAutoHide = _panel.AllowAutoHide;
+ _panel.AllowAutoHide = false;
+ try
+ {
+ IReadOnlyList adapters;
+ AppSettings settings;
+ try
+ {
+ adapters = await _controller.ListAdaptersAsync();
+ settings = _controller.Settings;
+ }
+ catch (Exception exception)
+ {
+ ReportError("无法打开设置", UiTheme.ExceptionText(exception));
+ return;
+ }
+
+ using var form = new SettingsForm(_controller, settings, adapters);
+ _settingsForm = form;
+ var owner = _panel.Visible ? _panel : null;
+ form.ShowDialog(owner);
+ ApplyStatus(ReadStatus(_status));
+ }
+ catch (Exception exception)
+ {
+ ReportError("设置窗口失败", UiTheme.ExceptionText(exception));
+ }
+ finally
+ {
+ _settingsForm = null;
+ _panel.AllowAutoHide = previousAutoHide;
+ if (!_disposed)
+ {
+ _busy = false;
+ _panel.SetBusy(false);
+ if (_panel.Visible) _panel.Activate();
+ }
+ }
+ }
+
+ private void ToggleAutostart()
+ {
+ if (_busy || _exitInProgress) return;
+ bool previous;
+ try
+ {
+ previous = _controller.AutostartEnabled;
+ }
+ catch (Exception exception)
+ {
+ ReportError("开机自启设置失败", UiTheme.ExceptionText(exception));
+ return;
+ }
+
+ var wanted = !previous;
+ try
+ {
+ _controller.SetAutostart(wanted);
+ var actual = _controller.AutostartEnabled;
+ if (actual != wanted)
+ throw new InvalidOperationException("设置调用返回后,系统状态没有变成预期值。");
+ _autostartItem.Checked = actual;
+ _autostartItem.ToolTipText = actual ? "当前已设置登录后启动" : "当前未设置登录后启动";
+ }
+ catch (Exception exception)
+ {
+ var rollback = string.Empty;
+ try
+ {
+ if (_controller.AutostartEnabled != previous) _controller.SetAutostart(previous);
+ if (_controller.AutostartEnabled != previous)
+ rollback = " 回滚后系统状态仍未恢复到原设置,请检查任务计划程序。";
+ else
+ rollback = " 已回滚到原设置。";
+ }
+ catch (Exception rollbackException)
+ {
+ rollback = " 回滚失败:" + UiTheme.ExceptionText(rollbackException);
+ }
+
+ try
+ {
+ _autostartItem.Checked = _controller.AutostartEnabled;
+ }
+ catch
+ {
+ _autostartItem.Checked = previous;
+ }
+ ReportError("开机自启设置失败", UiTheme.ExceptionText(exception) + rollback);
+ }
+ }
+
+ private void OpenLogs()
+ {
+ if (_disposed) return;
+ try
+ {
+ var dataDirectory = _controller.DataDirectory;
+ if (string.IsNullOrWhiteSpace(dataDirectory)) throw new InvalidOperationException("日志目录尚未配置。");
+ var directory = Path.Combine(dataDirectory, "logs");
+ Directory.CreateDirectory(directory);
+ Process.Start(new ProcessStartInfo { FileName = directory, UseShellExecute = true });
+ }
+ catch (Exception exception)
+ {
+ ReportError("无法打开日志", UiTheme.ExceptionText(exception));
+ }
+ }
+
+ private async Task ExitAsync()
+ {
+ if (_disposed || _exitInProgress) return;
+ if (_settingsForm is not null)
+ {
+ ReportError("暂时无法退出", "请先关闭设置窗口,再退出程序。");
+ return;
+ }
+ if (_busy)
+ {
+ ReportError("暂时无法退出", "当前网络操作尚未完成。请稍候再退出,程序不会在操作中途强制结束。");
+ return;
+ }
+
+ _exitInProgress = true;
+ _busy = true;
+ _panel.SetBusy(true);
+ try
+ {
+ await _controller.StopAsync();
+ if (_disposed) return;
+ _panel.HidePanel();
+ Dispose();
+ ExitThread();
+ }
+ catch (Exception exception)
+ {
+ if (_disposed) return;
+ _exitInProgress = false;
+ _busy = false;
+ _panel.SetBusy(false);
+ var message = "停止保活失败,程序仍在运行,没有强制退出。\n\n" + UiTheme.ExceptionText(exception);
+ _panel.ShowNotice(message, true);
+ if (!_panel.Visible) _panel.ShowNearTray();
+ ShowOwnedMessageBox(message, "退出未完成", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ }
+
+ private AppStatus ReadStatus(AppStatus fallback)
+ {
+ try
+ {
+ return _controller.Status;
+ }
+ catch (Exception exception)
+ {
+ return fallback with { Message = UiTheme.ExceptionText(exception) };
+ }
+ }
+
+ private void ReportError(string title, string message)
+ {
+ if (_disposed) return;
+ _panel.ShowNotice(message, true);
+ if (_panel.Visible)
+ {
+ _panel.Activate();
+ return;
+ }
+ try { _tray.ShowBalloonTip(6000, title, message, ToolTipIcon.Error); }
+ catch (Exception balloonException) { _ = balloonException; }
+ }
+
+ private DialogResult ShowOwnedMessageBox(string text, string title, MessageBoxButtons buttons,
+ MessageBoxIcon icon, MessageBoxDefaultButton defaultButton = MessageBoxDefaultButton.Button1)
+ {
+ var previous = _panel.AllowAutoHide;
+ _panel.AllowAutoHide = false;
+ try
+ {
+ var owner = _panel.Visible ? _panel : null;
+ return MessageBox.Show(owner, text, title, buttons, icon, defaultButton);
+ }
+ finally
+ {
+ _panel.AllowAutoHide = previous;
+ if (_panel.Visible && !_disposed) _panel.Activate();
+ }
+ }
+
+ private void Post(Action action)
+ {
+ if (_disposed) return;
+ if (Environment.CurrentManagedThreadId == _uiThreadId)
+ {
+ action();
+ return;
+ }
+
+ void SafeInvoke()
+ {
+ if (!_disposed) action();
+ }
+
+ try
+ {
+ if (_dispatcher.IsHandleCreated)
+ _dispatcher.BeginInvoke((Action)SafeInvoke);
+ else if (_initialContext is not null)
+ _initialContext.Post(_ => SafeInvoke(), null);
+ else
+ throw new InvalidOperationException("界面消息泵尚未初始化。");
+ }
+ catch (ObjectDisposedException) { }
+ catch (InvalidOperationException)
+ {
+ try { _initialContext?.Post(_ => SafeInvoke(), null); }
+ catch (InvalidOperationException) { }
+ }
+ }
+
+ private void TryEnablePerMonitorV2()
+ {
+ try { Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); }
+ catch (InvalidOperationException) { }
+ }
+
+ private void Cleanup()
+ {
+ if (_disposed) return;
+ _disposed = true;
+
+ try { _controller.StatusChanged -= OnStatusChanged; }
+ catch (Exception cleanupException) { _ = cleanupException; }
+
+ _clockTimer.Stop();
+ _clockTimer.Tick -= OnClockTick;
+ _clockTimer.Dispose();
+
+ _panel.RefreshRequested -= OnPanelRefresh;
+ _panel.SettingsRequested -= OnPanelSettings;
+ _panel.LogsRequested -= OnPanelLogs;
+ _panel.PolicyRequested -= OnPanelPolicy;
+ _panel.Dispose();
+
+ _tray.Visible = false;
+ _tray.MouseClick -= OnTrayMouseClick;
+ _tray.ContextMenuStrip = null;
+ _tray.Dispose();
+ _menu.Opening -= OnMenuOpening;
+ _menu.Dispose();
+ _icons.Dispose();
+ _dispatcher.Dispose();
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing) Cleanup();
+ base.Dispose(disposing);
+ }
+
+ public static void RenderPreview(string directory)
+ {
+ if (string.IsNullOrWhiteSpace(directory))
+ throw new ArgumentException("必须提供预览图片输出目录。", nameof(directory));
+ Directory.CreateDirectory(directory);
+
+ Exception? failure = null;
+ var render = () =>
+ {
+ try
+ {
+ try { Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); }
+ catch (InvalidOperationException) { }
+ try { Application.EnableVisualStyles(); }
+ catch (Exception e) when (e is UnauthorizedAccessException or IOException or InvalidOperationException or TypeInitializationException) { }
+
+ var states = new[]
+ {
+ ("gateway-panel-healthy-proxy.png", CreateHealthyProxyPreview()),
+ ("gateway-panel-fault-direct.png", CreateFaultDirectPreview()),
+ ("gateway-panel-paused-initial.png", CreatePausedInitialPreview())
+ };
+ foreach (var (fileName, state) in states)
+ {
+ using var panel = new StatusPanelForm();
+ panel.ApplyStatus(state);
+ RenderForm(panel, Path.Combine(directory, fileName));
+ }
+
+ using (var expandedPanel = new StatusPanelForm())
+ {
+ expandedPanel.ApplyStatus(CreateHealthyProxyPreview());
+ expandedPanel.SetDiagnosticsExpanded(true);
+ RenderForm(expandedPanel, Path.Combine(directory, "gateway-panel-diagnostics-expanded.png"));
+ }
+
+ var adapter = PreviewAdapter();
+ var settings = PreviewSettings(adapter);
+ using var settingsForm = new SettingsForm(new PreviewController(settings, adapter), settings, [adapter]);
+ RenderForm(settingsForm, Path.Combine(directory, "gateway-settings-preview.png"));
+ settingsForm.SelectPageForPreview(1);
+ RenderForm(settingsForm, Path.Combine(directory, "gateway-settings-rules.png"));
+ settingsForm.SelectPageForPreview(2);
+ RenderForm(settingsForm, Path.Combine(directory, "gateway-settings-detection.png"));
+ settingsForm.ScrollPageForPreview(2, 10000);
+ RenderForm(settingsForm, Path.Combine(directory, "gateway-settings-detection-scrolled.png"));
+
+ File.Copy(Path.Combine(directory, "gateway-panel-healthy-proxy.png"),
+ Path.Combine(directory, "gateway-panel-preview.png"), overwrite: true);
+ File.Copy(Path.Combine(directory, "gateway-settings-preview.png"),
+ Path.Combine(directory, "settings-preview.png"), overwrite: true);
+ }
+ catch (Exception exception)
+ {
+ failure = exception;
+ }
+ };
+
+ if (Thread.CurrentThread.GetApartmentState() == ApartmentState.STA)
+ {
+ render();
+ }
+ else
+ {
+ var thread = new Thread(() => render()) { IsBackground = true, Name = "GatewayKeepalive.Preview" };
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ thread.Join();
+ }
+
+ if (failure is not null) ExceptionDispatchInfo.Capture(failure).Throw();
+ }
+
+ private static void RenderForm(Form form, string path)
+ {
+ form.StartPosition = FormStartPosition.Manual;
+ form.Location = new Point(-32000, -32000);
+ form.Show();
+ Application.DoEvents();
+ form.PerformLayout();
+ using var bitmap = new Bitmap(form.Width, form.Height, PixelFormat.Format32bppArgb);
+ form.DrawToBitmap(bitmap, new Rectangle(Point.Empty, form.Size));
+ form.Hide();
+ bitmap.Save(path, ImageFormat.Png);
+ }
+
+ private static AppStatus CreateHealthyProxyPreview()
+ {
+ var now = DateTimeOffset.Now;
+ var adapter = PreviewAdapter();
+ var routes = new[]
+ {
+ new RouteEntry("192.168.6.2", 1, 12, 12),
+ new RouteEntry("192.168.6.1", 101, 12, 12)
+ };
+ var dns = new DnsConfiguration(false, ["192.168.6.2"], []);
+ var network = new NetworkSnapshot(adapter, routes, dns, ["192.168.6.2"], now);
+ var checks = new[]
+ {
+ new ProbeResult("旁路由连接", true, 2, "192.168.6.2:1070 TCP 可达"),
+ new ProbeResult("旁路由 DNS", true, 12, "指定 DNS 查询成功"),
+ new ProbeResult("SOCKS5", true, 45, "握手与 CONNECT 通过"),
+ new ProbeResult("当前出口", true, 220, "TLS 验证通过")
+ };
+ var health = new HealthReport(checks, true, true, now);
+ return new AppStatus(NetworkMode.Proxy, ControlPolicy.Auto, network, health, 3, 0,
+ "检测正常", now.AddMinutes(-2))
+ {
+ ActivePath = NetworkMode.Proxy,
+ LastEvent = "旁路由已恢复,自动切回",
+ LastEventAt = now.AddMinutes(-2)
+ };
+ }
+
+ private static AppStatus CreateFaultDirectPreview()
+ {
+ var now = DateTimeOffset.Now;
+ var adapter = PreviewAdapter();
+ var network = new NetworkSnapshot(adapter,
+ [new RouteEntry("192.168.6.1", 1, 12, 12)],
+ new DnsConfiguration(true, [], []), ["192.168.6.1"], now);
+ var checks = new[]
+ {
+ new ProbeResult("旁路由连接", false, 2500, "192.168.6.2 无法建立探测连接"),
+ new ProbeResult("旁路由 DNS", false, 1200, "未收到有效的 DNS 响应"),
+ new ProbeResult("SOCKS5", false, 2500, "192.168.6.2:1070 连接超时"),
+ new ProbeResult("当前出口", true, 180, "TLS 验证通过 · 当前走主路由")
+ };
+ var health = new HealthReport(checks, false, true, now);
+ return new AppStatus(NetworkMode.Degraded, ControlPolicy.Auto, network, health, 0, 3,
+ "旁路由候选不可用", now.AddSeconds(-45))
+ {
+ ActivePath = NetworkMode.Direct,
+ LastEvent = "旁路由连续失败,已切回主路由",
+ LastEventAt = now.AddSeconds(-45)
+ };
+ }
+
+ private static AppStatus CreatePausedInitialPreview() => new(
+ NetworkMode.Initializing, ControlPolicy.Paused, null, null, 0, 0,
+ "请选择网卡并保存配置")
+ {
+ ActivePath = NetworkMode.Initializing,
+ LastEvent = null,
+ LastEventAt = null
+ };
+
+ private static AdapterInfo PreviewAdapter() => new(
+ Guid.Parse("4c0d9b61-6f43-4f6f-8d07-d8c2867d7f10"),
+ 12, "Ethernet", true, [new Ipv4Address("192.168.6.100", 24)], 25, "preview-network");
+
+ private static AppSettings PreviewSettings(AdapterInfo adapter) => new()
+ {
+ Configured = true,
+ AdapterId = adapter.Id,
+ DirectGateway = "192.168.6.1",
+ ProxyGateway = "192.168.6.2",
+ ProxyDns = "192.168.6.2",
+ PreserveOriginalDirectDns = true,
+ TestHosts = ["www.google.com", "cloudflare.com"],
+ RequiredHealthyTargets = 1,
+ DirectTestHosts = ["www.baidu.com", "www.microsoft.com"],
+ RequiredDirectHealthyTargets = 1,
+ Policy = ControlPolicy.Auto
+ };
+
+ private sealed class PreviewController(AppSettings settings, AdapterInfo adapter) : ITrayController
+ {
+ public AppSettings Settings => settings;
+ public AppStatus Status => CreatePausedInitialPreview();
+ public string DataDirectory => Path.GetTempPath();
+ public bool AutostartEnabled => false;
+ public event Action? StatusChanged { add { } remove { } }
+ public Task> ListAdaptersAsync() => Task.FromResult>([adapter]);
+ public Task RefreshAsync() => Task.CompletedTask;
+ public Task SetPolicyAsync(ControlPolicy policy) => Task.CompletedTask;
+ public Task SaveSettingsAsync(AppSettings value) => Task.CompletedTask;
+ public Task RestoreAsync() => Task.CompletedTask;
+ public Task StopAsync() => Task.CompletedTask;
+ public void SetAutostart(bool enabled) { }
+ }
+
+ private sealed class DarkMenuColors : ProfessionalColorTable
+ {
+ public override Color ToolStripDropDownBackground => UiTheme.Card;
+ public override Color ImageMarginGradientBegin => UiTheme.Card;
+ public override Color ImageMarginGradientMiddle => UiTheme.Card;
+ public override Color ImageMarginGradientEnd => UiTheme.Card;
+ public override Color MenuBorder => UiTheme.Border;
+ public override Color MenuItemBorder => UiTheme.Accent;
+ public override Color MenuItemSelected => UiTheme.CardHover;
+ public override Color MenuItemSelectedGradientBegin => UiTheme.CardHover;
+ public override Color MenuItemSelectedGradientEnd => UiTheme.CardHover;
+ public override Color SeparatorDark => UiTheme.Border;
+ public override Color SeparatorLight => UiTheme.BorderSoft;
+ public override Color CheckBackground => UiTheme.CardHover;
+ public override Color CheckSelectedBackground => UiTheme.CardPressed;
+ public override Color CheckPressedBackground => UiTheme.CardPressed;
+ }
+}
diff --git a/src/GatewayKeepalive.Tray/Ui/Controls.cs b/src/GatewayKeepalive.Tray/Ui/Controls.cs
new file mode 100644
index 0000000..0866c65
--- /dev/null
+++ b/src/GatewayKeepalive.Tray/Ui/Controls.cs
@@ -0,0 +1,945 @@
+using System.ComponentModel;
+using System.Drawing;
+using System.Drawing.Drawing2D;
+using System.Runtime.InteropServices;
+using System.Windows.Forms;
+
+namespace GatewayKeepalive.Tray.Ui;
+
+internal sealed class PanelChip : Control
+{
+ private bool _hovered;
+ private bool _pressed;
+ private Color _accent = UiTheme.Accent;
+
+ public PanelChip()
+ {
+ SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
+ ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw |
+ ControlStyles.Selectable, true);
+ TabStop = true;
+ Cursor = Cursors.Hand;
+ AccessibleRole = AccessibleRole.PushButton;
+ Font = UiTheme.Body;
+ }
+
+ [Browsable(false)]
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
+ public bool Selected { get; set; }
+
+ [Browsable(false)]
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
+ public bool Emphasized { get; set; }
+
+ [Browsable(false)]
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
+ public bool Ghost { get; set; }
+
+ [Browsable(false)]
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
+ public int CornerRadiusDip { get; set; } = 8;
+
+ [Browsable(false)]
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
+ public Color AccentColor
+ {
+ get => _accent;
+ set { _accent = value; Invalidate(); }
+ }
+
+ public void PerformClick()
+ {
+ if (!Enabled) return;
+ Focus();
+ OnClick(EventArgs.Empty);
+ }
+
+ protected override void OnPaint(PaintEventArgs e)
+ {
+ base.OnPaint(e);
+ var graphics = e.Graphics;
+ UiTheme.Configure(graphics);
+
+ var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1));
+ var radius = UiTheme.S(this, CornerRadiusDip);
+ var fill = Ghost && !_hovered && !_pressed && !Selected && !Emphasized
+ ? UiTheme.Background
+ : Emphasized
+ ? UiTheme.WithAlpha(AccentColor, 54)
+ : Selected
+ ? UiTheme.WithAlpha(AccentColor, 38)
+ : _pressed ? UiTheme.CardPressed : _hovered ? UiTheme.CardHover : UiTheme.Card;
+ var border = Ghost && !_hovered && !Selected && !Emphasized
+ ? UiTheme.Background
+ : Selected || Emphasized
+ ? UiTheme.WithAlpha(AccentColor, 165)
+ : _hovered ? UiTheme.Border : UiTheme.BorderSoft;
+
+ UiTheme.FillRounded(graphics, bounds, radius, fill);
+ UiTheme.StrokeRounded(graphics, bounds, radius, border, UiTheme.SF(this, 1f));
+
+ var foreground = !Enabled ? UiTheme.Faint
+ : Selected || Emphasized ? AccentColor
+ : Ghost ? UiTheme.Muted : UiTheme.Text;
+ UiTheme.DrawText(graphics, Text, Font ?? UiTheme.Body, foreground,
+ Rectangle.Inflate(bounds, -UiTheme.S(this, 5), 0),
+ TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
+ TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
+
+ if (Focused && ShowFocusCues)
+ {
+ var focus = Rectangle.Inflate(bounds, -UiTheme.S(this, 3), -UiTheme.S(this, 3));
+ ControlPaint.DrawFocusRectangle(graphics, focus, foreground, Color.Transparent);
+ }
+ }
+
+ 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) OnClick(EventArgs.Empty);
+ }
+
+ 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);
+ }
+}
+
+internal sealed class ThemeButton : Button
+{
+ private readonly bool _primary;
+
+ public ThemeButton(bool primary)
+ {
+ _primary = primary;
+ AutoSize = true;
+ AutoSizeMode = AutoSizeMode.GrowAndShrink;
+ MinimumSize = new Size(82, 36);
+ Padding = new Padding(14, 4, 14, 4);
+ Margin = new Padding(6, 4, 0, 4);
+ FlatStyle = FlatStyle.Flat;
+ FlatAppearance.BorderSize = 1;
+ FlatAppearance.BorderColor = primary ? UiTheme.Accent : UiTheme.Border;
+ FlatAppearance.MouseOverBackColor = primary ? Color.FromArgb(0x5D, 0xE2, 0xBD) : UiTheme.CardHover;
+ FlatAppearance.MouseDownBackColor = primary ? Color.FromArgb(0x3D, 0xBE, 0x9A) : UiTheme.CardPressed;
+ BackColor = primary ? UiTheme.Accent : UiTheme.Card;
+ ForeColor = primary ? UiTheme.Background : UiTheme.Text;
+ Font = UiTheme.BodyBold;
+ UseVisualStyleBackColor = false;
+ UseMnemonic = false;
+ TextAlign = ContentAlignment.MiddleCenter;
+ Cursor = Cursors.Hand;
+ }
+
+ public bool Primary => _primary;
+}
+
+internal sealed class WrapTextLabel : Label
+{
+ public WrapTextLabel()
+ {
+ SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
+ ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
+ AutoSize = false;
+ UseMnemonic = false;
+ AccessibleRole = AccessibleRole.StaticText;
+ }
+
+ protected override void OnPaint(PaintEventArgs e)
+ {
+ e.Graphics.Clear(BackColor);
+ UiTheme.Configure(e.Graphics);
+ var bounds = ClientRectangle;
+ bounds = new Rectangle(bounds.X + Padding.Left, bounds.Y + Padding.Top,
+ Math.Max(0, bounds.Width - Padding.Horizontal),
+ Math.Max(0, bounds.Height - Padding.Vertical));
+ e.Graphics.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit;
+ using var brush = new SolidBrush(ForeColor);
+ using var format = new StringFormat(StringFormat.GenericTypographic)
+ {
+ FormatFlags = StringFormatFlags.LineLimit,
+ Trimming = StringTrimming.EllipsisCharacter,
+ HotkeyPrefix = System.Drawing.Text.HotkeyPrefix.None,
+ Alignment = StringAlignment.Near,
+ LineAlignment = StringAlignment.Near
+ };
+ e.Graphics.DrawString(Text, Font, brush,
+ new RectangleF(bounds.X, bounds.Y, bounds.Width, bounds.Height), format);
+ }
+
+ protected override void OnTextChanged(EventArgs e)
+ {
+ Invalidate();
+ base.OnTextChanged(e);
+ }
+}
+
+internal sealed class ToggleSwitch : Control
+{
+ private bool _checked;
+ private bool _hovered;
+
+ public ToggleSwitch()
+ {
+ SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
+ ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw |
+ ControlStyles.Selectable, true);
+ TabStop = true;
+ Cursor = Cursors.Hand;
+ AccessibleRole = AccessibleRole.CheckButton;
+ Size = new Size(42, 22);
+ }
+
+ [Browsable(false)]
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
+ public bool Checked
+ {
+ get => _checked;
+ set
+ {
+ if (_checked == value) return;
+ _checked = value;
+ Invalidate();
+ }
+ }
+
+ public event EventHandler? UserToggled;
+
+ protected override void OnPaint(PaintEventArgs e)
+ {
+ base.OnPaint(e);
+ UiTheme.Configure(e.Graphics);
+ var scale = UiTheme.Scale(this);
+ var track = new RectangleF(
+ (Width - 38 * scale) / 2f,
+ (Height - 20 * scale) / 2f,
+ 38 * scale,
+ 20 * scale);
+ var radius = track.Height / 2f;
+ var trackColor = !Enabled ? UiTheme.BorderSoft : _checked ? UiTheme.Accent : _hovered ? UiTheme.CardPressed : UiTheme.Border;
+ using (var path = new GraphicsPath())
+ {
+ path.AddArc(track.Left, track.Top, track.Height, track.Height, 90, 180);
+ path.AddArc(track.Right - track.Height, track.Top, track.Height, track.Height, 270, 180);
+ path.CloseFigure();
+ using var fill = new SolidBrush(trackColor);
+ e.Graphics.FillPath(fill, path);
+ }
+
+ var knobSize = track.Height - 4 * scale;
+ var knobX = _checked ? track.Right - knobSize - 2 * scale : track.Left + 2 * scale;
+ using var knob = new SolidBrush(!Enabled ? UiTheme.Paused : UiTheme.Text);
+ e.Graphics.FillEllipse(knob, knobX, track.Top + 2 * scale, knobSize, knobSize);
+ if (Focused && ShowFocusCues)
+ ControlPaint.DrawFocusRectangle(e.Graphics, ClientRectangle, UiTheme.Accent, Color.Transparent);
+ }
+
+ 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)) return;
+ Toggle();
+ }
+
+ protected override bool IsInputKey(Keys keyData) =>
+ keyData is Keys.Space or Keys.Enter || base.IsInputKey(keyData);
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ if (e.KeyCode is Keys.Space or Keys.Enter)
+ {
+ Toggle();
+ e.Handled = true;
+ return;
+ }
+ base.OnKeyDown(e);
+ }
+
+ private void Toggle()
+ {
+ if (!Enabled) return;
+ Focus();
+ Checked = !Checked;
+ UserToggled?.Invoke(this, EventArgs.Empty);
+ }
+}
+
+internal sealed class DisclosureButton : Control
+{
+ private bool _hovered;
+ private bool _expanded;
+ private string _summary = string.Empty;
+
+ public DisclosureButton()
+ {
+ SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
+ ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw |
+ ControlStyles.Selectable, true);
+ TabStop = true;
+ Cursor = Cursors.Hand;
+ AccessibleRole = AccessibleRole.ButtonDropDown;
+ Font = UiTheme.Small;
+ }
+
+ [Browsable(false)]
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
+ public bool Expanded
+ {
+ get => _expanded;
+ set { _expanded = value; Invalidate(); }
+ }
+
+ [Browsable(false)]
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
+ public string Summary
+ {
+ get => _summary;
+ set { _summary = value ?? string.Empty; Invalidate(); }
+ }
+
+ public event EventHandler? Toggled;
+
+ protected override void OnPaint(PaintEventArgs e)
+ {
+ base.OnPaint(e);
+ e.Graphics.Clear(UiTheme.Background);
+ UiTheme.Configure(e.Graphics);
+ if (_hovered)
+ UiTheme.FillRounded(e.Graphics, new Rectangle(0, 0, Width, Height), UiTheme.S(this, 6), UiTheme.BackgroundRaised);
+
+ UiTheme.DrawText(e.Graphics, Text, UiTheme.SmallBold, Enabled ? UiTheme.Text : UiTheme.Paused,
+ new Rectangle(UiTheme.S(this, 2), 0, Width / 2, Height),
+ TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine);
+ UiTheme.DrawText(e.Graphics, Summary, UiTheme.Small, Enabled ? UiTheme.Muted : UiTheme.Faint,
+ new Rectangle(Width / 2, 0, Width / 2 - UiTheme.S(this, 19), Height),
+ TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
+
+ var centerY = Height / 2f;
+ var x = Width - UiTheme.S(this, 12);
+ var d = UiTheme.S(this, 4);
+ using var pen = new Pen(UiTheme.Muted, UiTheme.SF(this, 1.3f))
+ {
+ StartCap = LineCap.Round,
+ EndCap = LineCap.Round
+ };
+ if (_expanded)
+ {
+ e.Graphics.DrawLine(pen, x - d, centerY + d / 2f, x, centerY - d / 2f);
+ e.Graphics.DrawLine(pen, x, centerY - d / 2f, x + d, centerY + d / 2f);
+ }
+ else
+ {
+ e.Graphics.DrawLine(pen, x - d / 2f, centerY - d, x + d / 2f, centerY);
+ e.Graphics.DrawLine(pen, x + d / 2f, centerY, x - d / 2f, centerY + d);
+ }
+
+ if (Focused && ShowFocusCues)
+ ControlPaint.DrawFocusRectangle(e.Graphics, ClientRectangle, UiTheme.Text, Color.Transparent);
+ }
+
+ 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 (Enabled && e.Button == MouseButtons.Left && ClientRectangle.Contains(e.Location)) Toggle();
+ }
+
+ protected override bool IsInputKey(Keys keyData) =>
+ keyData is Keys.Space or Keys.Enter || base.IsInputKey(keyData);
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ if (Enabled && (e.KeyCode is Keys.Space or Keys.Enter))
+ {
+ Toggle();
+ e.Handled = true;
+ return;
+ }
+ base.OnKeyDown(e);
+ }
+
+ private void Toggle()
+ {
+ Focus();
+ Toggled?.Invoke(this, EventArgs.Empty);
+ }
+}
+
+internal sealed class CardPanel : Panel
+{
+ public CardPanel()
+ {
+ SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
+ ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
+ BackColor = UiTheme.Card;
+ ForeColor = UiTheme.Text;
+ Padding = new Padding(14, 12, 14, 14);
+ Margin = new Padding(0, 0, 0, 12);
+ }
+
+ protected override void OnPaint(PaintEventArgs e)
+ {
+ base.OnPaint(e);
+ UiTheme.Configure(e.Graphics);
+ var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1));
+ UiTheme.StrokeRounded(e.Graphics, bounds, UiTheme.S(this, 10), UiTheme.BorderSoft, UiTheme.SF(this, 1f));
+ }
+}
+
+///
+/// Dark drop-down selector. A WinForms ComboBox delegates its arrow and popup chrome to the
+/// system theme, which cannot be recolored without visual styles; this control draws the field
+/// itself and opens a dark ContextMenuStrip, so nothing light leaks into the UI.
+///
+internal sealed class DarkSelect : Control
+{
+ private readonly List