深入· 01

架构与不变量

模块职责、数据流,以及那些「不知道就会改错」的约束 —— 每一条都对应一次真实踩过的坑。

适用于 v0.2.0/约 7 分钟/深入

技术栈与形态

Windows 桌面应用:Rust 后端(Tauri 2)+ 原生 TypeScript 前端(无 UI 框架,TailwindCSS 4 只用于重置与少量工具类)。界面结构、状态与交互全部手写,两套视觉设计共用同一份 DOM。

选 Tauri 而不是 Electron 的理由很直接:需要调用三个外部命令行工具、需要读写本地文件、需要窗口材质, 而渲染层只是「配置界面 + 日志面板」—— 没有必要为此带一个完整的浏览器运行时。

模块地图

前端 (src/)
├── main.ts              界面编排与状态管理(五步向导、自绘标题栏、皮肤切换器、右键菜单装配)
├── types.ts             共享类型 + 步骤元数据 + 选项 schema(唯一的事实源)
├── api.ts               Tauri 命令与事件封装
├── desktop.ts           桌面化行为层(快捷键注册表 + 拦掉浏览器行为 + 首帧显示)
├── skin.ts              皮肤 / 主题 / 材质三个开关的单一状态源
├── util.ts              共用小工具(HTML 转义 / 取文件名 / 相对路径)
├── style.css            样式入口(按层导入)
├── styles/              tokens.css(兜底令牌)→ skins/*.css → components.css
└── components/
    ├── file-tree.ts     文件树(三个独立点击区 + 搜索 / 类型 / 只看已选)
    ├── file-picker.ts   文件选择器 = 文件树 + 一层「找人」的工具
    ├── log-panel.ts     运行日志面板(行数上限 + live region)
    ├── modal.ts         对话框外壳 + 确认 / 输入 / 新建工程
    ├── settings-dialog.ts  设置面板
    ├── context-menu.ts  自绘右键菜单
    └── toast.ts         提示条

后端 (src-tauri/src/)
├── lib.rs               组装:插件、窗口、命令注册表、启动兜底
├── commands.rs          Tauri 命令层(参数校验 + 选项↔模板对齐)
├── model.rs             领域模型:Project / EncryptOptions / PackageOptions / 事件载荷
├── store.rs             工程持久化、应用状态、临时目录清理
├── defaults.rs          由选项生成模板 + Inno 边界常量
├── template.rs          ${key} 占位符渲染
├── pipeline.rs          三阶段异步进程编排(备份、流式日志、产出识别)
├── tools.rs             工具链定位 + 本机探测
├── download.rs          外部工具自动下载(来源声明 / 校验 / 解压 / 静默安装)
├── hash.rs              产出校验清单(SHA-256 / SHA-1 / MD5,流式)
├── runtime.rs           .NET Framework 运行时探测(注册表)
└── settings.rs          全局设置(工具路径)

数据流

用户在向导里改开关
      │
      ├─ 选项写进 current.encryptOptions / current.packageOptions
      │
      ├─ 若 generated == true → invoke render_encrypt_config / render_package_script
      │        (Rust 侧 defaults.rs 生成模板文本)→ 写回工程
      │
      ├─ 600ms 防抖 → invoke save_project → projects/<id>.json
      │
      └─ 点「执行」→ invoke run_stage
               │
               ├─ Rust:先 save_project(落盘权威在这里),再进 pipeline
               ├─ 按阶段取工具链路径 → 备份主程序(首次)→ 写临时配置 / 脚本
               ├─ 启动子进程,stdout / stderr 逐行 emit "pipeline:log"
               ├─ emit "pipeline:stage" 汇报阶段状态
               └─ 打包阶段:执行前后对比输出目录,找出实际产出 → 可选生成校验清单

前端不持有模板语义:它只收集开关,模板永远由 Rust 生成。这条约束让「界面上的选项」与 「实际写进配置的东西」不可能漂移。

不变量

以下每一条都对应一次真实踩过的坑,改动相关代码前请先读。

选项与模板

加一个配置项 = 只改 schema。 加密与打包的选项都声明在 src/types.ts 的 ENCRYPT_GROUPS / PACKAGE_GROUPS 里,带 kind / def / when / writes;界面按它渲染, 回归探针也按它断言。不要为某个选项手写渲染代码。

模板只由 Rust 生成。 浏览器预览没有 Rust,所以 preview/render.ts 是 defaults.rs 的镜像; 两边一旦漂移,预览里看到的模板就不是用户实际会拿到的模板。check:render-parity 做逐字比对。

Inno 的边界值集中在 defaults.rs 顶部。 LZMA_DICT_MIN_KB / LZMA_DICT_MAX_KB / LZMA_THREADS_MAX / SLICE_MIN_BYTES / SLICE_MAX_BYTES / SLICES_MIN / SLICES_MAX 都抄自官方文档,preview/render.ts 有同名常量。

异步与状态

异步写回全局状态需要代次守卫。 ensureTree / refreshIgnores 写回的目标是模块级状态。 切换工程时若旧请求晚于新请求返回,就会把上一个工程的目录树挂到当前工程上, picker.onChange 会把 A 的路径写进 B 的 mergeDlls。两者各有一个单调递增的 epoch, 回来时对不上就丢弃 —— 两个计数器必须分开,勾选文件会触发排除项刷新, 共用一个计数器会让在途的扫描自我作废。每条早退路径还必须把 scanning = false 落地。

长任务前必须快照 current。 runStage / runAll 在入口捕获 proj,之后每次写回都先校验 current.id === proj.id。运行期间左侧工程列表会被禁用,但快捷键与程序化路径仍可能触发切换。

防抖保存必须在切换工程前结算。 scheduleSave 是 600ms 防抖,回调里用的是捕获的 proj; selectProject / refreshProjects 会先 flushPendingSave(),删除当前工程时用 discardPendingSave()。

同一个事实出现在界面上两处,必须一起刷新。 syncStepSubs() 是所有改动路径的统一出口, 它同时刷新步骤条摘要与执行页摘要。详见五步向导。

样式与皮肤

层叠陷阱。 components.css 的规则全部包在 @layer components 里,而 skins/*.css 是无层的。 CSS 层叠规则里,无层的作者样式在任何优先级下都胜过有层的作者样式 —— 所以皮肤里的一条普通规则 能压过组件层的 :hover。推论:皮肤要覆盖某个组件的 hover / active 态,必须写在皮肤文件里。

tokens.css 的兜底值不是多余的。 自定义属性未定义时整条声明会失效(不是回退到可见的初始值), 表现为元素莫名没有边框。

永远不要为某套皮肤 fork DOM。 皮肤与主题挂在 <html> 的根属性上,对话框与提示条也会同时切换, 不存在「部分元素还是旧皮肤」的中间态。

平台与工具链

注册 Tauri 命令是两步:在 commands.rs 定义并且加进 lib.rs 的 invoke_handler! 列表。

工具链三级回退:用户设置 > 内置 resources/tools > 用户下载目录。第三级是必需的 —— 应用装在 Program Files 下时内置目录只读。

内置工具必须与声明的下载版本一致。 解析顺序是「内置 > 下载」,放一份旧版本会静默压住正确的版本。

全局快捷键表不 stopPropagation,否则浮层关不掉。

窗口 visible: false 的两半都不能删。 前端在首帧之后 show(),Rust 有 1.5 秒兜底; index.html 里的内联引导脚本依赖 Tauri 在构建时注入 CSP hash/nonce(CSP 是 script-src 'self')。

数据

工程 JSON 不写 serde 默认值。 本程序完整写出所有字段,缺字段说明文件被改坏了,应当报错而不是 悄悄补默认值。所以 Project / EncryptOptions / PackageOptions 上没有任何 #[serde(default)]; Settings::load 在解析失败时回退到默认值,但会往 stderr 留一条痕。

取值一律白名单收敛。 工程 JSON 可以手改,所有枚举值写进模板前都过一遍白名单, 不认识的退回默认值(defaults.rs::pick)。

桌面化设计的取舍

浏览器引擎给的默认值本身就是「网页味」的主要来源。desktop.ts 把这些默认值逐条换成桌面程序的习惯:

拦掉的浏览器行为 说明
Ctrl+R / F5 / F7 / F12 刷新与开发者工具
Ctrl+P / Ctrl+F / Ctrl+U 打印 / 查找 / 查看源码
Ctrl+Shift+I/J/C 开发者工具与元素检查
Ctrl + / - / 0 与 Ctrl+滚轮 浏览器缩放 —— 固定密度的桌面工具没有「缩放」这个概念,所以是拦掉而不是改键
拖出界面内容 / 拖入文件导航 阻止 dragstart 与 dragover / drop
右键原生菜单 由自绘菜单替代
无焦点时按 Backspace 阻止「后退」
界面文字被拖选 body 默认 user-select: none,只放开输入框、脚本编辑区、运行日志

度量层向 Fluent / WinUI 3 靠:

项 取值 依据
标题栏高度 36–38px Fluent 32px;这里要留出皮肤切换器
窗口按钮 46px 宽 × 通高 × 直角 Fluent caption 按钮;关闭键 hover #C42B1C
状态栏 24px 本地工具把状态放窗口底部,而不是塞进标题栏
正文字号 / 行高 12px / 1.4 桌面工具密度,不是网页的 13–14px / 1.5
圆角 4px 控件 / 3px 小件 Fluent Windows 的 4/8/8 体系收一档
动效 83 / 167 / 250ms Fluent motion
焦点环 2px 双描边 Fluent 的焦点是双描边,不是彩色光晕
光标 界面元素一律箭头 手型光标是网页习惯

浮层一律「阴影 + 1px 描边」成对出现,避免只有阴影时在浅底上糊成一片。字体全部本地打包, 因为 CSP 是 default-src 'self',运行时拉不到 Google Fonts;中文回落到系统字体。

搜索Esc

↑ ↓ 选择 · Enter 打开 · 中英文都能搜