C# + WPF 版 WebClone 的 Rust + winsafe 1:1 复刻。图形界面走 winsafe 的
原生 Win32 控件(不引入任何 UI 框架),命令行版与 C# 版完全同参。
对应 wget 的两种用法:
| 模式 | 等价 wget 命令 | 说明 |
|---|---|---|
| 单页模板 | wget -r -E -np -c -k --ignore-tags=a <url> |
只抓当前页与它引用的资源,不递归 a 链接 |
| 整站克隆 | wget -r -np -c -k <url> |
递归追踪站内链接(不向上级目录),抓全站 |
webclone-rs/
├── Cargo.toml workspace 定义(共享依赖版本 + release profile)
├── build.sh / build.ps1 一键构建脚本(Windows 上优先用 build.ps1,理由见下)
├── assets/app.ico 程序图标(与 C# 版 WebClone/assets/app.ico 同一份,6 个尺寸)
├── build/embed_icon.rs 图标嵌入逻辑,被两个 crate 的 build.rs 用 include! 共用
├── crates/
│ ├── webclone-core/ 核心引擎(纯 Rust,AOT 友好、零反射)
│ ├── webclone-cli/ 命令行版(bin 名 webclone,与 C# 版同参)
│ │ └── build.rs 把 app.ico 嵌进 exe
│ └── webclone-gui/ 图形界面(winsafe 原生 Win32)
│ ├── src/main.rs 窗口、控件、事件、布局
│ ├── build.rs 嵌入 app.ico + 视觉样式 manifest(见"程序图标""界面观感"两节)
│ └── webclone-gui.manifest comctl32 v6 依赖声明,决定控件用现代主题还是经典外观
└── tests/
├── make_test_site.py 生成功能验证站点(含 GBK 页面、查询参数资源、CSS 嵌套引用等)
├── test_server.py 支持 ETag / 304 / Range 续传的本地测试服务器
├── test_proxy.py 极简 HTTP / SOCKS5 双模代理
├── ua_server.py UA 记录服务器(验证 --user-agent 是否真的生效)
├── regression.ps1 CLI 一键回归(6 组场景)
├── gui_probe.py GUI 界面探针(59 条断言 = 50 首跑 + 9 配置恢复,含截图)
├── gui_smoke.sh GUI 冒烟入口(起进程、跑探针、收尾;自动沙箱隔离)
├── pe_resource.py PE 资源区 / .ico 解析器(零依赖,给 check_icon 用)
├── check_icon.py 校验 exe 里的图标与 assets/app.ico 逐字节一致
└── check_winver.py 校验产物实际需要的最低 Windows 版本(PE 导入表逐符号比对)
webclone-core 的模块划分刻意与 C# 版 WebClone.Core 一一对应,方便对照阅读:
| C# | Rust |
|---|---|
CloneEngine.cs |
engine.rs |
CloneOptions.cs / ProxySettings.cs |
options.rs |
Models/* |
models.rs |
Net/ResourceDownloader.cs |
net/downloader.rs |
Net/HttpClientFactory.cs |
net/http.rs |
Net/Socks5Connector.cs |
由 reqwest 的 socks 特性承担 |
Parse/HtmlScanner.cs / CssScanner.cs |
parse/html_scanner.rs / parse/css_scanner.rs |
Storage/PathMapper.cs / SafeFileName.cs |
storage/path_mapper.rs / storage/safe_name.rs |
Text/EncodingDetector.cs / CharsetRewriter.cs |
text/encoding.rs / text/charset.rs |
- 两种模式:单页模板 / 整站递归,整站模式遵循
-np语义(不下载上级目录)。 - 文件名处理:URL 带查询参数时生成
原名_8位哈希.ext(如style_78fe4a82.css), 合法且可读;同时处理 Windows 保留名、非法字符、路径穿越。 - 路径改写:所有资源引用改写为相对路径;
<base href>重定向为.; 跨域 CDN 资源按各自域名归档后同样以相对路径引用。 - 重定向归一:下载时跟随服务端重定向,最终地址与请求地址共享同一本地文件,
整站克隆不会因链接写法差异(
/doc与/doc/)存两份。 - 类型修正:下载完成后按响应的真实 Content-Type 修正落盘扩展名 ——
像 Google Fonts 的
/css?family=...这类无扩展名动态样式表会被正确存为.css。 - 单页模式防跳转:指向未下载页面的 a 链接统一替换为
#;整站模式保留外站原链。 - 代理:HTTP(S) 代理与 SOCKS5 代理。SOCKS5 走 reqwest 的
socks5h://(域名交给代理解析,与 C# 版手写 RFC 1928 实现里按 ATYP=域名 发出的行为一致), 支持用户名密码认证。 - 增量与断点续传:
- 图片、字体等二进制资源用
If-None-Match/If-Modified-Since条件请求,304 直接跳过; - 中断的下载用
Range+If-Range续传(资源变化时服务端自动降级为 200 全量); - 一律先写
.part再原子改名,不留残缺成品; - HTML / CSS 因为落盘前会被改写,始终取原始内容。
- 图片、字体等二进制资源用
- 编码:按 BOM → HTTP charset → meta charset /
@charset→ 严格 UTF-8 嗅探 → GB18030 兜底的顺序识别,统一转 UTF-8 保存并改写编码声明,GBK 页面不乱码。 - 解析器自研:HTML / CSS 扫描器为零依赖状态机,记录原文下标做原地替换,
不依赖任何 HTML 库。覆盖
<link>、<script>、<img>、srcset、<video>/<audio>/<source>、<object>/<embed>、<iframe>、内联 style、<style>正文、CSSurl()/@import/@font-face,以及data-src等懒加载属性。 - 新增:自定义浏览器 UA —— 图形界面的"下载选项"里多了一个
使用自定义浏览器 UA复选框与配套文本框(默认填 Edge 的 UA,默认不勾选)。 勾选后用文本框内容作为 User-Agent 发起请求,并同样持久化到webclone-gui.json。 CLI 侧对应--user-agent "<UA>"。
Windows 上优先用 PowerShell 脚本:
.\build.ps1 # CLI + GUI 全部构建,产物汇总到 dist\
.\build.ps1 -SkipGui # 只构建命令行版
.\build.ps1 -SkipCli # 只构建图形界面版
.\build.ps1 -Clean # 先清 dist\ 与 target\ 再全量构建
.\build.ps1 -Profile debug # 用 debug profileGit Bash / MSYS 下也可以:
./build.sh # CLI + GUI 全部构建
./build.sh cli # 只构建命令行版
./build.sh gui # 只构建图形界面版
./build.sh clean # 先清 dist/ 再全量构建为什么 Windows 上优先
build.ps1:build.sh里调bash,而部分环境下嵌套bash会被 WSL 接走,那会在同一个target/里编出 Linux 产物,与 Windows 产物互相污染 (现象是cargo报一堆平台不匹配,或者target/release里混进 ELF)。build.sh开头已加uname检测拦截,但用 PowerShell 更省事。
产物:
| 文件 | 说明 |
|---|---|
dist/webclone.exe |
命令行版,约 3.3 MB |
dist/WebClone-GUI.exe |
图形界面版,约 3.4 MB |
两个都是独立单文件:Rust 直接编成机器码,没有 .NET 那种 AOT / 自包含之分, 也不需要任何运行库,拷到别的机器上直接能跑。
手动构建(等效于脚本内部行为):
cargo build --release -p webclone-cli
cargo build --release -p webclone-guiwebclone <网址> [选项]
# 单页模板,输出到指定目录
webclone https://example.com -o D:\site
# 整站递归,深度 3,并发 16
webclone https://example.com -m full -d 3 -c 16 -o D:\site
# 走 SOCKS5 代理(支持 user:pass 认证)
webclone https://example.com --proxy socks5://user:pass@127.0.0.1:1080
# 走 HTTP 代理
webclone https://example.com --proxy http://127.0.0.1:8080
# 指定 User-Agent
webclone https://example.com --user-agent "Mozilla/5.0 (...) Edg/126.0.0.0"常用选项:-m single|full、-o 目录、-d 深度、-c 并发、--max-pages、
--timeout、--retry、--no-cross-origin(不抓 CDN)、--no-incremental、
--keep-absolute、--keep-external-links、--ignore-cert-errors、
--user-agent、-v 详细日志。
退出码与 C# 版一致:0 成功、1 有失败项 / 参数缺失、2 参数错误、3 未处理异常、
130 用户取消。
GUI 每次启动自动回显上次的选项,配置存放在 exe 同目录 的 webclone-gui.json:
- 首次运行自动生成默认配置;点"开始克隆"或关闭窗口时自动保存当前选项
- 单个字段损坏只跳过该字段用默认值;整个文件损坏自动重建
- 配置写失败(如放在无写权限目录)会写一份
gui-save-error.txt诊断,不影响克隆 - 文件字段名与 C# 版完全一致(PascalCase),两个版本可以互读同一份配置
配置文件长这样(新增的两个字段在末尾):
{
"StartUrl": "https://example.com",
"OutputDirectory": "C:\\Users\\Administrator\\Desktop\\webclone-output",
"Mode": "single",
"MaxDepth": 3,
"MaxPages": 0,
"ProxyKind": "none",
"ProxyHost": "",
"ProxyPort": 8080,
"ProxyUserName": "",
"ProxyPassword": "",
"IncludeCrossOriginAssets": true,
"ConvertToRelativePaths": true,
"EnableIncremental": true,
"NeutralizeExternalLinks": true,
"IgnoreCertificateErrors": false,
"Concurrency": 8,
"TimeoutSeconds": 30,
"RetryCount": 3,
"UseCustomUserAgent": false,
"CustomUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0"
}自动起服务器、跑六组场景断言、汇总 PASS/FAIL:
.\tests\regression.ps1 # 默认用 dist\webclone.exe
.\tests\regression.ps1 -PythonPath py # 指定 python 命令覆盖场景:单页克隆(含哈希文件名与资源落盘)、增量二跑、整站三页递归、
-np 不越界、GBK 转码,以及 --user-agent 是否真的传到了服务端。
tests\gui_probe.py 用纯 ctypes + PIL 找到窗口、读控件状态、发鼠标/按钮/键盘消息,
对界面做 59 条断言(分两阶段),并顺手截五张图。
阶段一 · 首次运行(50 条) —— 配置不存在,全套默认值:
- 默认勾选状态是否与 C# 的 XAML 一致、配置回显;
- 代理框失能、六个数字框的默认值与微调按钮宽度、自定义 UA 勾选联动;
- 关于页渲染;
- 切页重绘:切到"关于"再切回"克隆",截图与初始画面逐像素比对(阈值 1%, 修好前是 20.89%);
- 代理端口框随单选启停:直连时输入框 + 微调两个窗口都必须禁用;
- 网址框回车:用
SendInput发真实回车,验证能弹出校验提示; - 程序图标:窗口类图标(
GCLP_HICON/GCLP_HICONSM,任务栏与 Alt+Tab 用)和 窗口实例图标(WM_GETICON,标题栏用)四个句柄都非空,且GetIconInfo能证明 它们背后真的有彩色位图 —— 只看句柄非 0 会被系统的兜底句柄骗过去; - 点关闭按钮能否真的退出进程。
阶段二 · 配置恢复(9 条) —— --restore,配置里存着非默认值:
- 把沙箱配置改成
Mode=full+ProxyKind=socks5后重启进程; - 两组单选各自恰好一个被选中,且就是配置里那个(两个同时选中是 Win32 单选回填的经典坑,见下方坑 5);
- 代理地址框 / 端口输入框 / 端口微调都跟着恢复出来的 SOCKS5 一起启用 (回填失败时它们会保持禁用,出现"界面选了 SOCKS5、代理框却是灰的")。
# 必须用 `.` 在当前 shell 里执行:脚本依赖"GUI 与探针同一条命令"这个前提,
# 也避免嵌套 bash 落到 WSL 上。
PYTHON=/path/to/python.exe . tests/gui_smoke.sh输出形如 RESULT: ALL PASS / RESULT: HAS FAILURES,并留下
shot-1-clone.png / shot-2-ua-checked.png / shot-3-about.png /
shot-4-clone-back.png / shot-5-restore.png 五张截图。
最后两条断言会发 WM_CLOSE 并等进程退出,脚本另打印一行
关闭验证: 进程已自行退出 [OK] 作为独立于断言之外的第二重证据。
脚本会把
dist\WebClone-GUI.exe复制到tests\.smoke-sandbox\里再运行。原因是 GUI 的 配置固定写在 exe 同目录的webclone-gui.json:直接在dist\里跑会双向污染 —— 测试读到用户手调的端口/并发而误报 FAIL,用户调好的配置又被测试结果覆盖。沙箱化之后 两边彻底隔离,测试也总从"首次运行"的干净状态开始。 (沙箱内的旧配置用mv改名而不是rm删除 —— 本环境的 safe-delete 策略会拦截rm, 而mv改名一定成功。)
两个环境坑(脚本里已处理,手工调试时容易踩):
- GUI 和探针必须在同一条命令里跑。GUI 是 shell 的子进程,命令一结束父 shell 被回收,GUI 会被连坐杀掉,现象是探针跑到一半窗口消失、截屏得到空图。
- 跨进程不能用
TCM_GETITEMRECT。它不在系统跨进程封送的消息表里,传过去的RECT指针在目标进程里无效,会稳定返回全零矩形;探针改用"扫页签条发鼠标消息, 以关于页是否显示来自证"的办法。
# 1. 生成测试站点并启动本地服务器(支持 304 / Range)
python tests/make_test_site.py tests/site
python tests/test_server.py 8765 tests/site
# 2. 启动双模代理(可选,验证代理功能)
python tests/test_proxy.py http 8899
python tests/test_proxy.py socks5 8898
# 3. 克隆并对比
dist/webclone.exe http://127.0.0.1:8765/ -m full -o out图标与 C# 版用的是同一份文件(assets/app.ico,6 个尺寸:16 / 32 / 48 / 64 / 128 / 256,
全是 32bpp PNG 压缩层),两个产物都嵌。
Rust 只生成代码段,.ico 要出现在 PE 的资源区必须经链接期的资源编译器。走的是 MSVC
工具链自带的两件套,零第三方 crate(不需要 winres / winresource / embed-resource):
assets/app.ico --[rc.exe]--> app-icon.res --[link.exe]--> WebClone-GUI.exe (.rsrc)
build/embed_icon.rs负责全过程:定位rc.exe→ 生成临时.rc(内容就一行1 ICON "<绝对路径>")→ 编译成.res→ 用cargo:rustc-link-arg-bins=<res>交给link.exe(link.exe直接接受.res作为输入);- 两个 crate 的
build.rs都用include!引同一份逻辑,不重复实现; rc.exe一般不在PATH里(只有 VS 开发者命令提示符会加),所以按RC环境变量 →PATH→Windows Kits\10\bin\<最高版本>\<arch>→ 从LIB反推 的顺序找。找不到只打一条cargo:warning而不中断编译 —— 没图标总比编不过强;- 图标资源与 manifest 会被合并进同一个
.rsrc段,ID 不冲突:图标是RT_ICON(3)/RT_GROUP_ICON(14),manifest 是RT_MANIFEST(24)。
嵌进资源只是第一步 —— 那一步让资源管理器里的 exe 有图标。窗口的标题栏和任务栏按钮 不会自动用上它,要再补两处:
- 窗口类图标 ——
WindowMainOpts::class_icon: gui::Icon::Id(1)。winsafe 注册窗口类时 会把它写进WNDCLASSEX.hIcon/hIconSm,任务栏按钮和 Alt+Tab 走这一层; - 窗口实例图标 ——
WM_SETICON,标题栏走这一层。winsafe 不代劳,在apply_runtime_init()(WM_CREATE之后)自己发。
为什么两步都要做。 只做第 1 步会有两个后果:一是
WM_GETICON永远返回NULL—— 这个 API 只认WM_SETICON设过的图标,取不到类图标;二是标题栏那个 16×16 会发虚 —— winsafe 的register_class(源码gui/windows/raw_base.rs)把同一个 HICON 同时填给hIcon和hIconSm,而LoadIcon只会加载系统大图标尺寸那一层,小图标是缩出来的。 所以第 2 步要按GetSystemMetrics(SM_CXICON / SM_CXSMICON)分别加载正确尺寸再覆盖。
python tests/check_icon.py # 不带参数则校验 dist\ 下的两个产物tests/pe_resource.py 是个零依赖的 PE 资源区解析器;check_icon.py 用它把 exe 里的
RT_GROUP_ICON / RT_ICON 拆出来,与 assets/app.ico 逐字节比对(不是"能打开就行"):
图层数量、各图层尺寸、每个图层的字节全都要对上。窗口侧由 GUI 探针的 8 条断言覆盖
(见"GUI 冒烟验证"一节里的"程序图标"一条)。
体积代价 +25,088 字节(3,579,392 → 3,604,480),基本就是 ico 本体 23,976 字节 加上资源目录的开销。
原生 Win32 程序的外观不由代码决定,而由 exe 里那份 manifest 决定。Rust 的 MSVC
目标默认不生成 manifest,于是 exe 里没有 Microsoft.Windows.Common-Controls v6 的
依赖声明 —— 结果是所有原生控件(Button / CheckBox / Radio / ProgressBar / Tab /
ScrollBar)都退到经典渲染路径:灰色 3D 凸起按钮、方角复选框、带箭头的粗滚动条,
也就是 Win9x 那种观感。
C# WPF 版不存在这个问题,因为 WPF 的控件全是自绘的,一个像素都不来自 comctl32。
crates/webclone-gui/webclone-gui.manifest 补上了这条声明,build.rs 用 MSVC 链接器
自带的 /MANIFEST:EMBED + /MANIFESTINPUT: 把它嵌进 exe(零第三方依赖,
不需要 winres / embed-resource 那类 crate):
- 体积代价 +1.5 KB(3,576,832 → 3,578,368 字节),业务代码 0 改动; (后续修掉下面几个 Win32 坑又加了约 1 KB,再嵌图标 +25 KB,当前产物 3,604,480 字节)
- 未产生外部
.manifest文件(/MANIFEST:EMBED保证内嵌); - 效果:按钮、复选框、单选钮、页签、滚动条全部切换为 Windows 10/11 原生主题。
坑:manifest 里不能声明 DPI 感知。 winsafe 的
initial_gui_setup()会调用SetProcessDPIAware(),而该 API 在 "DPI 感知已由 manifest 指定"时会失败并返回ERROR_ACCESS_DENIED;winsafe 对它用的 是.expect(DONTFAIL),于是进程直接 panic。所以 DPI 感知必须留给 winsafe 的 运行时调用(system-DPI aware),manifest 只声明 comctl32 依赖 + supportedOS。 代价是拿不到 PerMonitorV2 的多屏独立缩放,属于 winsafe 当前版本的固有约束。
控件字体不用管:winsafe 会在每个控件创建时自动把系统 UI 字体
(SystemParametersInfo(SPI_GETNONCLIENTMETRICS).lfMenuFont,现代 Windows 上即
Segoe UI 9pt)通过 WM_SETFONT 设上去,跟随用户的系统设置,中文由字体链接自动 fallback。
C# 版做过专门的视觉设计,不是 WPF 默认皮肤:窗口底 #FAFAFA、白色圆角卡片
(CornerRadius=6 + #DDD 边框 + 12px 内边距)、#1E88E5 蓝色实心主按钮、
Consolas 12px 日志区、SemiBold 分区标题。补齐这些在 winsafe 下的成本:
| 项目 | 做法 | 量级 |
|---|---|---|
| 日志区等宽字体 | 单独 CreateFont("Consolas") + WM_SETFONT |
~5 行 |
| 分区标题字重/颜色 | 换字体 + WM_CTLCOLORSTATIC 里 SetTextColor |
~20 行 |
| 窗口/卡片底色 | 窗口类 hbrBackground + WM_CTLCOLORSTATIC 返回同色画刷 |
~30 行 |
| 圆角卡片边框 | WM_PAINT 自绘圆角矩形(要先 CreateRoundRectRgn 裁切) |
~120 行 |
| 蓝色主按钮 | owner-draw 按钮(BS_OWNERDRAW + WM_DRAWITEM),含 hover / pressed 态 |
~150 行 |
| 控件间距对齐 WPF 的 Margin 体系 | 手工挪坐标 | 视精细度 |
即:轻量美化(前 3 项)约 50 行,能明显提升但仍是系统配色;追平 WPF 的卡片式
设计约 300~450 行,主要成本在自绘按钮与圆角卡片,且要额外处理 DPI 缩放与高对比度
模式下的可读性。再往上一档才是换 UI 框架(egui / Slint / Tauri + WebView2),那要重写
整个 GUI 层(现约 1500 行),webclone-core 不受影响。
这几处都不报错、不崩溃,只是"行为不对",全部靠探针截图 + 日志定位出来。记在这里, 下次改 GUI 时能少走一遍。
现象:点"关于"再点回"克隆",关于页的大段说明文字原样留在克隆页上,和克隆页 控件叠成重影,文字完全不可读。
根因(两条 Win32 规则互相抵消):
BS_GROUPBOX是空心控件 —— 它只画边框和左上角标题,中间那块故意不填, 设计上假设"父窗口会把底色擦好";TabPage带WS_CLIPCHILDREN—— 父窗口擦背景时会把子窗口矩形整块跳过。
于是分组框中间那块像素成了"无人区":程序刚启动时它是窗口初值(看着正常),一旦被
别的页盖过再回来,系统把这块矩形交给下层窗口补画 —— 父页面被 WS_CLIPCHILDREN
跳过、分组框自己又不擦底,旧像素就留在原地了。
修法:给两个分组框挂子类化,接管 WM_ERASEBKGND 自己把客户区刷成窗口底色
(FillRect + GetSysColorBrush(COLOR_WINDOW)),并在切页后用
RedrawWindow(RDW_INVALIDATE | RDW_ERASE | RDW_ALLCHILDREN | RDW_UPDATENOW)
把新页连同子控件一起强制重绘。两者缺一不可。
排查记录:一开始只加了
RedrawWindow,像素差异纹丝不动(20.89%), 一度以为是事件没触发。实测证据是:跨进程调RedrawWindow返回 1 但残影不变 —— 说明根本不是"没收到重绘请求",而是"给它重绘它也不擦"。PrintWindow+ z 序枚举 +WindowFromPoint逐个排除后才定位到分组框。 修复后差异 0.00%。
现象:网址框里按回车毫无反应。
根因:winsafe 的 BaseWnd::run_main_loop 里有一步(process_dlg_msgs 打开时):
if process_dlg_msgs && hwnd_top_level.IsDialogMessage(&mut msg) {
continue; // 消息被消费,不再 DispatchMessage
}对话框管理器拿到 VK_RETURN 的 WM_KEYDOWN 后会去找"默认按钮",本窗口压根没有
默认按钮 —— 那条消息就被它直接丢弃:既没有 Dispatch 到窗口过程,也没触发任何按钮。
WM_KEYUP 不受影响,所以子类化过程能收到 WM_KEYUP 却收不到 WM_KEYDOWN。
修法(标准做法):在 WM_GETDLGCODE 里对"这一条具体的回车消息"回
DLGC_WANTMESSAGE —— 此时 lParam 正好指向待处理的 MSG,可以精确判断
message == WM_KEYDOWN && wParam == VK_RETURN,只放行回车,Tab / 方向键照旧交给
对话框管理器,键盘导航不受影响。
测试口径:这条只能用
SendInput发真实按键验证。PostMessage的键盘消息 直接进窗口队列、绕开IsDialogMessage,恰好把坑绕过去了(实测PostMessage到不了、SendInput能到)——用错工具会得到"功能正常"的假阳性。
winsafe 的窗口连同所有子控件都是在消息循环启动后才真正 CreateWindowEx 出来的,
WindowMain::new 返回时 HWND 还不存在。所以在 events() 里调 SetWindowSubclass
拿到的是空句柄,SetWindowSubclass 返回 FALSE、安装静默失败。
正确时机是 wm_create 回调里(apply_runtime_init)。这也正是坑 2 里"回车完全没反应"
的第一层原因 —— 当时子类化根本没装上,修好时机之后才暴露出 IsDialogMessage 那一层。
msctls_updown32 只占右侧 17px 的箭头区,真正显示数字、能被点击选中文字的是它的
伙伴 Edit。所以"直连时禁用端口框"必须两个窗口都禁用 —— 只禁 UpDown 的话,
输入框照旧能点进去选中文字。UpDown 的 EnableWindow(FALSE) 并不会连带禁用伙伴框。
现象:网络代理选 SOCKS5(或克隆模式选整站)→ 关窗 → 下次启动,两个单选同时 被勾上;而且代理地址框还是灰的。
根因(两条实现细节叠加):
- 单选的互斥是
BS_AUTORADIOBUTTON在响应用户点击时才做的。程序化发BM_SETCHECK不会取消同组兄弟 —— 而 winsafe 的RadioButton::select(b)恰好就是裸的BmSetCheck。于是"构造时带了selected: true的那个 + 配置里恢复的 那个"双双保持勾选。 - winsafe 的
RadioGroup::selected_index()实现是position(|r| r.is_selected())—— 返回第一个被选中的。两个都勾着时它认出的是 序号小的那个(直连 / 单页模板)。
两条一叠加,后果就不只是观感了:界面显示 SOCKS5、实际按直连跑,代理框还因为
selected_index() 读到"直连"而整条禁用;关窗回写时配置里的 ProxyKind 被静默改成
none —— 设置悄悄丢了。
修法:程序化设置单一一律走 select_radio_only(),把同组所有项显式设一遍
(选中的设 true、其余设 false),不要单独 select(true)。
测试口径:这个 bug 前面 42 条断言一条都没抓到 —— 它们只覆盖"首次运行"这条路径。 单选回填属于另一条独立路径(配置文件里存着非默认值 → 回填控件),必须重启进程 才能走到。现在由阶段二(
gui_probe.py --restore)覆盖:改沙箱配置 → 重启 → 断言 "每组恰好一个选中 + 就是配置里那个 + 代理框已启用"。 补测试时先用未修复的二进制跑了一遍,确认 9 条断言里有 7 条 FAIL(含克隆模式那组 同样中招)—— 断言真的抓得住,再改代码。
官方保证:Windows 10 及以上(x64)。 这不是项目的取舍,而是 Rust 工具链的硬口径
—— 自 Rust 1.78(2024-05)起,x86_64-pc-windows-msvc 的最低支持平台提升到
Windows 10,官方明确说明该要求「既适用于 Rust 工具链本身,也适用于 Rust 生成的二进制
文件」。本项目由 rustc 1.97.1 构建。Rust 另开了 x86_64-win7-windows-msvc 骨架留给
Win7,但它只是 Tier 3(不官方构建、不测试),要用得自己 -Z build-std 编 std。
值得一提的是,PE 头写的是子系统版本 6.0(Vista) —— Windows 加载器只校验这一个 字段,所以它不会以「版本太低」为由拒绝加载。真正的门槛全在导入表:
| 导入符号 | 最低系统 | 来源 |
|---|---|---|
bcryptprimitives.dll!ProcessPrng |
Win8+(Rust 侧口径为 Win10) | getrandom 0.3,Rust 1.78 起是 std 的默认 RNG 后端 |
api-ms-win-core-synch-l1-2-0.dll!WaitOnAddress!WakeByAddressSingle / !WakeByAddressAll |
Win8+ | Rust 1.78 起 std 的 mutex / condvar / rwlock 改为 futex 实现 |
kernel32.dll!GetSystemTimePreciseAsFileTime |
Win8+ | std 取高精度系统时间的路径 |
这些是静态导入(不是 GetProcAddress 动态取),所以 Win7 上进程停在加载阶段 ——
不是「功能受限」,是连窗口都出不来。注意 api-ms-win-core-synch-l1-2-0 这个文件
在 Win7 装 KB2999226 后确实存在,但里面没有那三个函数(它们是 Win8 的内核等待
原语,微软 Terminal 项目当年专门为 Win7 写过它们的 shim 才能跑)。
另有两道运行库门槛:
api-ms-win-crt-*.dll(Universal CRT):Win10 起随系统预装;Win7/8/8.1 需 KB2999226。VCRUNTIME140.dll:需装 VC++ 2015-2022 可再发行组件包。想彻底去掉这条依赖, 可在.cargo/config.toml里给目标加-C target-feature=+crt-static改静态链接 CRT。
TLS 走的是系统 Schannel(native-tls,导入表里的 secur32.dll + crypt32.dll)。
Win7 的 Schannel 默认只开 TLS 1.0/1.1,TLS 1.2 要 KB3140245 加改注册表,TLS 1.3
完全不支持(Win10 1903+ 才有)。2026 年大量站点已硬性要求 TLS 1.2+,所以即便解决了
启动问题,抓 HTTPS 仍会大面积失败。
用同一把尺子量了 C# 版(dist/webclone-cli.exe / WebClone-GUI.exe):导入表里一个
Win8+ 专有符号都没有,运行库依赖只剩 UCRT(NativeAOT 与自包含发布都静态链接了 CRT,
所以连 VCRUNTIME140 都不需要)。从导入表看,C# 版的门槛确实比 Rust 版低一档 —— 但
.NET 8+ 官方同样不支持 Win7,「能加载」不等于「官方支持」,别把导入表干净当兼容性承诺。
python tests/check_winver.py dist/WebClone-GUI.exe dist/webclone.exe读 PE 头 + 全量静态导入表 + 延迟加载表,逐符号比对「低版本不存在」清单并给出结论。 依赖升版本、工具链换代之后跑一遍,就知道门槛有没有动。
功能与选项 1:1 对齐,只有下面三处是受技术栈限制的实现差异,不影响克隆结果:
- "关于"页改为纯文本呈现。C# 版用内嵌
WebBrowser(IE 内核)渲染一份 HTML; winsafe 是纯 Win32 封装,不含 WebView 宿主,为避免额外引入 IE / WebView2 依赖, 改成了同等内容的纯文本。 - 状态文字不换色。WPF 版按结果把"就绪 / 正在克隆 / 克隆完成 / 有失败 / 已取消"
分别染成蓝 / 绿 / 红 / 橙;纯 Win32 下需要接管
WM_CTLCOLORSTATIC自建画刷, 这里选择不染,文案完全一致。 - 不做 C# 版那套自定义视觉设计。C# 版有
#FAFAFA底 + 白色圆角卡片 +#1E88E5蓝色主按钮;这里走 Windows 系统原生主题(控件外观 + 配色跟随系统), 仅在 manifest 里开启了 comctl32 v6 视觉样式。理由与后续可选的投入见上一节。
技术栈层面的实现取舍:
- UI 布局用手动定位。winsafe 自带按"父窗口缩放比例"重排的
resize_behavior, 但页签页(TabPage)自身会被TCM_ADJUSTRECT挪动,自动布局在这种 "父窗口被外部挪动"的场景下原点会算错。所以Tab的尺寸、页签页的位置、 以及页内所有控件的坐标都由apply_layout()统一手动算。 - 一切"写控件"的动作都必须发生在
WM_CREATE之后。winsafe 的窗口(连同所有 子控件)是在run_main()内部才CreateWindowEx出来的,WindowMain::new()返回时HWND还不存在;此时调set_text/set_check/EnableWindow全是 静默空操作。因此配置回显统一放在wm_create回调里(App::apply_runtime_init)。 - 首次运行时用
GuiSettings::default()回填界面。C# 版首次运行不回填,界面直接 用 XAML 里写死的初始状态(那几个IsChecked="True");winsafe 没有 XAML,若跳过 回填直接读控件取值落盘,会写出四个复选框全false的错配置。 - 回车快捷键借用
SetWindowSubclass。winsafe 的控件事件只暴露WM_COMMAND/WM_NOTIFY,没有WM_KEYDOWN钩子,所以网址输入框的"回车即开始克隆" 直接调 comctl32 的SetWindowSubclass挂了一层子类化过程。两个前提缺一不可: 安装时机必须在WM_CREATE之后(坑 3),且要在WM_GETDLGCODE里对回车回DLGC_WANTMESSAGE,否则消息会被IsDialogMessage吞掉(坑 2)。 - 微调按钮不用
UDS::ALIGNRIGHT。它会把伙伴输入框压窄一个箭头宽(96 DPI 下 16px),而且这个压窄在控件被程序化搬动之后表现不稳定。这里改为显式摆放:输入框 和微调按钮都自己给坐标与宽度。另外UDS::NOTHOUSANDS必须显式加 ——UDS::SETBUDDYINT默认按用户区域格式插千分位,8080 会显示成8,080, 而伙伴框带ES::NUMBER,这种文本是非法输入。 - 多行
Edit的换行必须是\r\n。Win32 按回车断行,裸\n会被吃掉, 整段文字挤成一行(关于页正文因此统一在出口补\r)。 - 注册了
wm_close就必须自己DestroyWindow,否则点关闭按钮毫无反应、只能 强杀进程。winsafe 的on()通道是同名消息后注册者胜 (process_last_message用msgs.iter().rev().find(..)只取最后一条),而DlgMain::new内建注册过一个wm_close→DestroyWindow,我们一注册就把它顶掉了;fn_wm_noparm_noret!宏在回调返回Ok时返回def_proc_val(dialog = TRUE),wnd_proc见user_ret有值就直接返回、不再走DefWindowProc——WM_CLOSE被 吃掉,窗口不销毁,GetMessage永远等不到WM_QUIT,消息循环卡死。 正确做法是在自己的wm_close里补上hwnd().DestroyWindow()。 (winsafe 文档对wm_close也明说了:处理此事件即覆盖WindowMain内建的DestroyWindow;DlgMain里wm_nc_destroy→PostQuitMessage(0)我们没碰, 所以销毁之后仍能正常退出消息循环。) - reqwest 关掉了 system-proxy 默认特性,并显式
no_proxy(),避免本机http_proxy/https_proxy环境变量干扰"直连"语义。 - SOCKS5 用
socks5h://(h = 域名交给代理解析),与 C# 版手写实现等价。 - HTML / CSS 扫描器按 UTF-8 字节下标工作。原 C# 版用 UTF-16 下标; 由于所有定界符都是 ASCII,两种下标在语义上等价(首字节处切分正确)。
- 需要 Rust 1.75+(实测 1.97.1)。
- 在 Git Bash / Cygwin 会话里构建若报环境相关错误,先
source ~/.workbuddy/bin/env-fix.sh(该脚本补ProgramFiles/OS等变量)。 - winsafe 依赖 Windows SDK 自带的
comctl32/shell32/ole32等系统 DLL, 不需要额外装 Visual Studio C++ 工具链(MSVC link.exe 由 rustup 的x86_64-pc-windows-msvc工具链提供)。