Files
onlyinone/docs/packaging.md
T
jkooe 927486f2dd feat(pack): dmg 卷内自带《首次打开必读.txt》+ 修正已失效的 Gatekeeper 放行指引
用户在真机上撞到「未打开"Only in One.app",因其包含恶意软件」,
但实测 `open -a` 能正常启动(四进程齐全、settings.json 正常写入)。
定性结论:ad-hoc 签名 + 未公证 → `spctl -a -t exec` 必然 rejected,
`codesign --verify --deep --strict` 通过 ⇒ 包没坏、只是没公证。问题只在交付体验。

产品改进:让包自己会说
- electron-builder.yml 加 dmg.contents:卷内恰好 3 个条目(app + Applications 软链 + 中文说明)
- 说明源文件放 launcher/resources/ 而非 build/(~/.gitignore_global 忽略 build/,
  放那儿会「本地能打、别人 clone 下来打不出」)
- 刻意不放 .command 脚本:脚本自身也带 quarantine,双击同样被拦,反而多一层困惑
- verify:pack 第 5 步新增两项硬校验:说明文件非空、卷内恰好 3 个可见条目

文档纠错(三处已失效/会误导的指导)
- 「访达 右键 → 打开 → 再点打开」:macOS 15(Sequoia)起 Apple 已移除该绕过路径,
  右键菜单里没有那个「打开」项 → 改为「系统设置 → 隐私与安全性 → 仍要打开」+ `open -a` 应急
- 「xattr -dr com.apple.quarantine」:本包全树无 quarantine(只有 com.apple.provenance),
  该命令是空操作 → 明确标注无效,并给出 `codesign`/`spctl` 自证判据
- 老文档把「已损坏,无法打开」列为"最常见形态":ad-hoc 签名自洽时系统不会这么说,
  该文案只出现在签名真坏时 → 改为独立分支处理
- 新增「为什么每次重新打包又会拦一次」:放行记录绑 cdhash,ad-hoc 每次构建都变

- troubleshooting.md 新增第 19 条(现象/原因/排查/解决 + 判据表 + 三条「不要做」)
2026-10-27 00:05:03 +08:00

29 KiB
Raw Blame History

Only in One — 打包与分发说明

面向交付:怎么打、打出来的是什么、怎么覆盖安装、装的到底是什么文件夹。 业务代码(launcher/src/**)除 T-6 批准的 core/paths.ts + main.ts 各几行外未改动。

1. 产物

两个交付物(launcher/release/,已 gitignore,不入库):

平台 文件 大小(实测) 形态
macOS(Apple Silicon) OnlyInOne-0.2.0-mac-arm64.dmg 94,534,289 B ≈ 90.2 MiB 拖拽安装(卷内恰好 3 个可见条目:Only in One.app + Applications 软链 + 首次打开必读.txt)
Windows x64 OnlyInOne-0.2.0-win-x64.zip 107,114,242 B ≈ 102.2 MiB 解压即用(zip 内有 Only in One.exe + resources/app.asar)

配套文件:

文件 用途
release/SHA256SUMS.txt 上面两个包的 SHA-256(分发时请随包给出,收到包的人先核对)
launcher/resources/首次打开必读.txt dmg 卷内那份中文说明的源文件(改了它要重新出 dmg;见下方说明)
release/mac-arm64/Only in One.app dmg 的源(中间产物,可删)
release/win-unpacked/ zip 的源(中间产物,可删)
release/*.blockmap electron-builder 自动产物,将来做增量下载时用得上,可留

dmg 卷里为什么要塞一份 txt

这个包没有 Apple 公证,首启必被 Gatekeeper 拦(见 §6)。以前"怎么放行"只存在于 口头和文档里,结果是:拿到包的人看到「因其包含恶意软件」以为中毒,不敢开; 而网上能搜到的方法里,最流行的那个(右键 → 打开)在 macOS 15 起已经失效。

所以把说明放进 dmg 本身,让包自己会说:

  • 落在卷内、中文名 首次打开必读.txt,打开 dmg 一眼就能看见;
  • 内容是"未公证 ≠ 有毒 + 两种打开方法 + 怎么校验没被篡改 + 数据存哪 + 为什么每次升级要再放行一次";
  • 刻意不放 .command / .sh 脚本:脚本自身也带 com.apple.quarantine, 双击同样被 Gatekeeper 拦(而且提示更吓人),反而多一层困惑。纯 txt 不会被拦。

实现见 electron-builder.yml 的 dmg.contents。两个实现细节值得记住:

  1. contents 一旦自己写,就必须把 app 那一项也写进去(不写 path 的那一项 = 打包出来的 .app 本身)。只写软链会把 app 从卷里弄丢。
  2. 源文件放 launcher/resources/,不能放 launcher/build/:本机 ~/.gitignore_global 忽略了 build/,放那儿会"本地能打、别人 clone 下来打不出"。 dmg.contents[].path 的解析顺序是 buildResources(build/) → 项目根, 两处都找不到会直接让构建失败(不是静默跳过),resources/ 正好命中第二条。

命名规范:OnlyInOne-<version>-<os>-<arch>.<ext>(os ∈ {mac, win},arch ∈ {arm64, x64})。 产物名不含空格(productName 是 Only in One,带空格会把 URL/curl 全带进转义的坑), 但 .app / .exe 的名字仍然是 Only in One。

Windows 为什么是 zip 而不是 portable / NSIS

portable 底层就是 NSIS 自解压器:每次启动把 ~258 MB 解压到 %TEMP%\<unpackDirName>,退出即删。 这恰好是「应用到处建文件夹」要消灭的现象,所以不用它。 zip 只在你指定的那一个目录落地,覆盖安装 = 再解压一次到同一目录,语义最清晰。 (顺带纠正一个常见误会:本机能打 NSIS —— electron-builder 自带 macOS 原生 makensis, ~/Library/Caches/electron-builder/nsis/nsis-3.0.4.1/mac/makensis 是 x86_64 Mach-O,Rosetta 可跑。 选 zip 是产品理由,不是环境妥协。)

2. 打包命令(在 launcher/ 下)

npm install                 # 装依赖(electron + electron-builder@24.13.3 + …)

npm run build               # 只编译:先清 dist,再 tsc(会把 src/index.html、renderer.js 拷进 dist)
npm run dist:mac            # macOS dmg(arm64)
npm run dist:win            # Windows zip(x64)
npm run dist:all            # 两个都打(= dist:mac 然后 dist:win)
npm run verify:pack         # 打包后自检(8 步,见 §3);失败退出码非 0

dist:all 刻意写成两条 electron-builder 调用,而不是一条 --mac --arm64 --win --x64: 命令行上的 --x64 是全局的,一条命令会让 mac 也额外打一份 x64(多下一个 97 MB 的 darwin-x64 引擎,未签名状态下毫无意义)。改成两次调用后,产物严格等于 yml 里声明的架构。

网络 / 镜像

electron 引擎与打包工具默认从 GitHub 下载,直连不稳时设国内镜像:

export ELECTRON_MIRROR="https://npmmirror.com/mirrors/electron/"
export ELECTRON_BUILDER_BINARIES_MIRROR="https://npmmirror.com/mirrors/electron-builder-binaries/"

3. 打包后自检:npm run verify:pack

构建成功 ≠ 包可用:漏文件、漏依赖、版本漂移、asar 里混进 .map / test/ / src/、 Windows 打成了 32 位 —— 这些都不会让 electron-builder 报错。所以每次出包都跑:

cd launcher && npm run dist:all && npm run verify:pack

八步逐一打印 ✅/❌,任一项失败退出码 1:

# 检查 判据
0 产物存在 dmg / zip / .app / win-unpacked 四个都在
1 mac asar 清单 必含 dist/main.js dist/preload.js dist/index.html dist/renderer.js dist/core/* dist/platform/* package.json;不含 *.map / test/ / src/ / node_modules/
2 Info.plist CFBundleIdentifier=com.onlyinone.launcher、CFBundleShortVersionString=0.2.0、CFBundleName/CFBundleExecutable=Only in One
3 签名形态 Identifier=com.onlyinone.launcher、flags=0x2(adhoc)(不是 linker-signed)、有 _CodeSignature/CodeResources、codesign --verify --deep --strict 通过。spctl 必然 rejected(未公证),只提示不失败
4 真实启动 可执行文件是 Mach-O arm64;进程不异常退出、stderr 无 FATAL / Cannot find module。若环境起不了 GUI(CI / 无窗口会话),降级为静态判据并给出 ⚠️ —— 判据是"连已知可用的旧版也这样"
5 dmg 结构 hdiutil verify 通过;可挂载;卷内恰好 3 个可见条目:Only in One.app + Applications 软链 + 首次打开必读.txt(非空);检查后自动推出
6 Windows zip unzip -l 含 Only in One.exe / resources/app.asar / locales/ / *.dll / LICENSES.chromium.html;unzip -t CRC 全通过;file 判定 PE32+ x86-64
7 双端一致 从 zip 抽出 resources/app.asar,与 mac 侧逐行 diff;两端 package.json 的 name/version 相同;asar 内 package.json 无 productName
8 校验和 生成 release/SHA256SUMS.txt,恰好 2 行

第 7 步是「没有 Windows 机器」时最有价值的验证:结构完整性 + 与 mac 侧清单一致 能覆盖绝大多数打包事故(漏文件、漏依赖、版本不一致)。真机运行仍需在那台小主机上做一次(见 §4.2)。

4. 覆盖安装

结论先放这儿:覆盖安装不会丢数据。程序本体和数据目录是两个互不相干的位置, 且数据目录名是代码里的常量(见 §5),不随包名/版本变化。

4.1 macOS(dmg 拖拽覆盖)

# 步骤 期望结果
1 托盘菜单 →「退出」(pgrep -f "Only in One" 无输出) 进程归零。关窗口 ≠ 退出,本工具有托盘常驻
2 记基线:du -sh ~/Library/Application\ Support/onlyinone-launcher 有数可对比
3 双击 OnlyInOne-0.2.0-mac-arm64.dmg 挂载出卷,内有 Only in One.app + Applications 快捷方式
4 拖 app 到 Applications →「替换」 无「项目正在使用」报错
5 ls -d /Applications/Only* 恰好 1 条
6 系统设置 → 隐私与安全性 → 底部「仍要打开」→ 再点一次(未公证首启,见 §6.3。注意:macOS 15 起没有「右键 → 打开」这条路径了) 应用正常起来
7 数据仍在:…/onlyinone-launcher/ 里的 settings.json / 登录态没变 ✅ 不丢数据
8 find ~/Library -maxdepth 2 -iname "*onlyinone*" 恰好 2 条(数据目录 + plist),没有新增分叉
9 版本确认:plutil -p "/Applications/Only in One.app/Contents/Info.plist" | grep ShortVersion 0.2.0

覆盖安装会不会残留旧文件?不会:访达的「替换」是删除旧 bundle 再写入新 bundle(不是合并), 旧 Contents/Frameworks、旧 helper、旧 app.asar 全部清掉。

实测记录(T-6,2026-10-26,本机 arm64 / macOS 27.2) 用 8 月装的 0.1.0(app.asar 28 KB)覆盖成 0.2.0(app.asar 332 KB): /Applications 里仍然只有 1 个 Only in One.app;CFBundleIdentifier 仍是 com.onlyinone.launcher;数据目录仍是 onlyinone-launcher(1.8 MB,条目一个没变); find ~/Library … 仍恰好 2 条;Preferences 仍只有 com.onlyinone.launcher.plist。 签名形态与旧包一致:Identifier=com.onlyinone.launcher + flags=0x2(adhoc), codesign --verify --deep --strict 通过。

4.2 Windows(zip 解压覆盖)

# 步骤 期望结果
1 托盘 → 退出(或 Get-Process "Only in One" -EA SilentlyContinue | Stop-Process -Force) 进程归零,否则解压会报「文件正被使用」
2 记基线:%APPDATA%\onlyinone-launcher\ 有基线
3 把 OnlyInOne-0.2.0-win-x64.zip 解压到原目录,全部覆盖 无占用报错
4 启动 Only in One.exe → SmartScreen → 更多信息 → 仍要运行 起来
5 已登录状态、设备列表仍在 ✅ 不丢数据
6 dir %APPDATA%\onlyinone-launcher 只有这 1 个数据目录
  • 数据在 %APPDATA%\onlyinone-launcher,不在解压目录:把程序从 D:\Apps\OnlyInOne 换到 E:\Tools\OiO 也不影响数据。
  • 解压出来的 exe 带 Mark-of-the-Web:右键 exe → 属性 → 勾「解除锁定」(或 Unblock-File)可一次性解决 SmartScreen。
  • zip 形态不写注册表卸载项,也不建 %LOCALAPPDATA% 目录。

5. 数据目录:为什么"只建一个文件夹"是有保证的

macOS 上稳定只有 1 个数据目录 + 1 个 plist:

路径 由什么决定 内容
~/Library/Application Support/onlyinone-launcher/ launcher/src/core/paths.ts 的 DATA_DIR_NAME 常量 settings.json、credential.bin、logs/、app-cache/ + Chromium profile(Cache/ Code Cache/ GPUCache/ Cookies Local Storage/ …)
~/Library/Preferences/com.onlyinone.launcher.plist appId(CFBundleIdentifier) Electron 首启写的 4 条 macOS 外观默认值,约 190 B

~/Library/Caches/onlyinone-launcher/、~/Library/Logs/…、~/Library/Saved Application State/… 都不存在(Chromium 缓存在数据目录内部;日志写 数据目录/logs/launcher.log)。 Windows 侧同理,唯一数据目录是 %APPDATA%\onlyinone-launcher\。

三层锁(为什么不靠"记得别改")

「改个名字就丢数据」是不可观测的故障:用户看不到报错,只看到设置没了、登录态没了。 所以固化写成三道锁 + 守卫单测:

  1. core/paths.ts:数据根目录 = app.getPath('appData') + 常量 DATA_DIR_NAME, 不再读 app.getPath('userData')(那个值本身就等于 appData + app.getName(), 而 getName() = package.json.productName ?? name)。纯函数 resolveDataRoot() 的四个分支全部可单测。
  2. main.ts:app.setPath('userData', appData/DATA_DIR_NAME),把 Chromium 那堆 (Cache / Cookies / Local Storage …)也钉在同一个固定目录。必须在 requestSingleInstanceLock() 之前 —— Windows 的单实例锁文件就在 userData 里。
  3. test/paths.test.ts(13 例):断言 DATA_DIR_NAME === 'onlyinone-launcher'、 package.json 不含 productName、package.json.name === DATA_DIR_NAME、 electron-builder.yml 里 appId / productName 是两个固定字面量。

三个字段各管什么(别搞混)

字段 位置 决定 影响数据路径?
name launcher/package.json app.getName() 的兜底值 ⚠️ 名义上决定 Electron 默认 userData,已被第 1 道锁绕过;但仍保持 onlyinone-launcher
productName electron-builder.yml .app / .exe 名、CFBundleName、CFBundleExecutable ❌ 不影响(asar 里的 package.json 没有 productName 字段,有守卫测试)
appId electron-builder.yml CFBundleIdentifier → Preferences plist、LaunchServices 身份、TCC 授权归属 ❌ 不影响数据,✅ 决定 plist

appId: com.onlyinone.launcher 与 productName: Only in One 永不修改: 改 appId = 系统视为另一个 App(plist 新建、TCC 重新授权、登录项失效); 改 productName = /Applications 里出现两个 app,用户可能双击到旧的那个。

为什么我们的缓存目录叫 app-cache 而不是 cache

Chromium 在同一个 root 里建的是 Cache/,而 APFS 与 NTFS 都大小写不敏感 → cache 与 Cache 是同一个目录,我们的 devices.json 会落进 Chromium 的磁盘缓存、 被它的清理逻辑波及,表现为「设备列表缓存偶尔莫名失效」(这种 flaky 极难排查)。 OS 层面无解,只能改名,APP_CACHE_DIR_NAME = 'app-cache'。这条也有单测守卫。

迁移成本

T-6 之前与之后指向同一个位置(~/Library/Application Support/onlyinone-launcher), 老数据原地继承,无需任何迁移脚本。

6. 未签名首启(未公证,不是中毒)

当前包没有 Developer ID 签名、没有公证(团队内部流转,无 Apple 账号)。它是 ad-hoc 签名的(codesign --sign -,见 §7),所以 arm64 能跑起来;但首启仍会被 Gatekeeper 拦。这是 ad-hoc 路线的固有代价,不是包坏了 —— 8 月那版能正常双击的 0.1.0 签名形态与现在一模一样(flags=0x2(adhoc) / TeamIdentifier=not set), spctl 同样 rejected。

6.1 用户实际看到的提示,以及它到底在说什么

未打开"Only in One.app",因其包含恶意软件。此操作未对Mac造成危害。

(更早的系统上措辞是:「"Only in One" 无法打开,因为 Apple 无法检查其是否包含恶意软件。」)

这句话不是在说"检出病毒"。 它是 macOS 对所有未经 Apple 公证的 App 的固定文案, 确切含义只有一句:Apple 手里没有这个 App 的公证记录,没法替它背书。 一个功能极简的内部工具也会收到一模一样的提示。用户第一次看到这句话, 十有八九以为中招了 —— 所以 dmg 卷内自带了一份《首次打开必读.txt》(见 §1)把这点讲清。

6.2 判据:三条命令自证「包是好的,只是没公证」

在终端里跑(/Applications 换成实际路径):

命令 本包实测 含义
codesign --verify --deep --strict "<app>" 无输出,exit 0 每个文件与签名记录逐字节一致 → 没被篡改
codesign -dv --verbose=4 "<app>" Identifier=com.onlyinone.launcher、flags=0x2(adhoc)、Signature=adhoc、TeamIdentifier=not set 本机临时签名,没有开发者身份可追溯
spctl -a -t exec -v "<app>" rejected,exit 3 没有公证票据 → 必然 rejected

一句话口诀:codesign 通过 + spctl rejected = 包是好的、只是没公证; codesign 不通过 = 包真的有毛病,别用,重新拿一份。

⚠️ 别把 spctl 当杀毒扫描。它不看文件内容,只查 Apple 的公证票据。 rejected 的字面意思是"执行策略不接受",不是"发现恶意代码"。

6.3 正确的放行步骤(当前唯一正规路径)

  1. 打开 dmg,把 Only in One 拖进 Applications。
  2. 访达里双击它 → 弹出拦截提示 → 点「完成」关掉。 这一步不能省:系统要先"看见"一次被拦,才会在设置里给出放行入口。
  3. 苹果菜单 → 系统设置 → 隐私与安全性 → 滚到底部「安全性」一节, 会看到「"Only in One" 已被阻止,以保护 Mac」→ 点 「仍要打开」。
  4. 输开机密码 / 触控 ID → 再点一次「仍要打开」。
  5. 之后双击正常,不会再拦。

两个坑:

  • 「仍要打开」按钮只在不早于被拦后约 1 小时内出现。翻遍设置也找不到 = 隔太久了, 回第 2 步再双击一次,然后立刻去系统设置。
  • 应急路径:终端 open -a "/Applications/Only in One.app"。它走的是不经访达的 启动路径,实测能直接起来(团队已实测:四进程齐全、settings.json 正常写入、 --user-data-dir 仍指向 ~/Library/Application Support/onlyinone-launcher)。 但它不改变双击的行为 —— 想让双击也正常,仍要走上面第 1~5 步一次。

6.4 已失效的办法(别再照老教程做)

办法 状态 说明
访达 右键(或 Control+点击)→ 打开 → 再点「打开」 ❌ macOS 15(Sequoia)起已失效 Apple 在 Sequoia 移除了这条绕过路径,原话:"In macOS Sequoia, users will no longer be able to Control-click to override Gatekeeper… They'll need to visit System Settings > Privacy & Security"。现在右键菜单里根本没有那个「打开」项 —— 照着老教程找会找不到,误以为 Mac 出了问题
xattr -dr com.apple.quarantine "<app>" ❌ 对本包无效(空操作) 本包全树没有 com.apple.quarantine(只有 com.apple.provenance),没有隔离标记可去。实测 xattr -r -l "<app>" | grep -c quarantine = 0。跑这条命令不会改变任何事,只会让人误以为"试过了但没用"
sudo spctl --master-disable("打开任何来源") ❌ 不要用 macOS 15 起不再接受这种非交互的全局策略改动(要走 MDM 配置描述符);且它是对整台机器关掉这道防线,为一个内部工具不值
系统设置 → 隐私与安全性 → 仍要打开 ✅ 当前唯一正规路径(见 §6.3)
终端 open -a "<app>" ✅ 应急可用 见 §6.3

顺带纠正一个常见误判:老文档把「"Only in One" 已损坏,无法打开。你应该将它移到废纸篓」 列为"最常见形态"。本包不会出这句 —— ad-hoc 签名是自洽的(codesign --verify exit 0), 系统不会说它"损坏"。真出现这句 = 签名真的坏了(codesign --verify 会失败), 处置是重新拿包,或本地补签一次: codesign --force --deep --sign - "/Applications/Only in One.app"(不需要 Apple 账号)。

6.5 为什么每次重新打包后会又拦一次

Gatekeeper 把放行记录绑在 App 的代码指纹(cdhash) 上 —— 不是绑在路径、 也不是绑在文件名上。ad-hoc 签名没有稳定的 Team Identifier,每次构建 cdhash 都不同, 系统于是认为"又是一个没见过的新 App",重新拦一次。

所以:

  • 每次出包 / 每次覆盖安装后首启,都要按 §6.3 重新放行一次,这是一次性的两下点击。
  • 「上次明明放行过,这次又被拦」是预期,不是新包退化、不是杀毒变严、更不是中毒。
  • 想彻底消除这个提示只有两条路:① 买 Apple 开发者账号($99/年)做 Developer ID 签名 并公证;② 关掉 Gatekeeper(不推荐)。本轮明确选"不做",接受这个代价(PRD-v2 Q11 裁决 B)。

6.6 Windows 侧

SmartScreen「Windows 已保护你的电脑」→ 更多信息 → 仍要运行; 解压出来的 exe 带 Mark-of-the-Web,右键 → 属性 → 勾「解除锁定」(或 Unblock-File) 可一次性解决。zip 形态没有签名,同理是"未签名"而非"有毒"。

7. 将来的分发服务(现在不做,只留位)

electron-builder.yml 末尾留了 publish 注释块。服务就绪时解开 4 行 + 填 URL 即可。

两条硬约束(否则将来会被误判成"配置没写对"):

  • macOS 即使装了 electron-updater 也跑不了自动更新:它走 Squirrel.Mac,要求 代码签名 + 公证。未签名/未公证的包会直接失败(Code signature at URL … did not pass validation)。 没有 $99/年的 Apple 账号 = mac 端自动更新永久不可用。
  • Windows zip 目标也没有自动安装:electron-updater 只支持 NSIS / portable 的静默安装。 zip 只能「下载 → 用户手动解压覆盖」。

所以将来的分发形态 = 应用内「检查新版本」(一个 GET 拿静态 latest.json)→ 打开下载页 → 用户手动覆盖。这个形态不需要 electron-updater,只需要 §1 的产物命名 + SHA256SUMS.txt。 本次不引入 electron-updater。

关于 ad-hoc 签名(为什么必须有 scripts/after-pack.js)

mac.identity: null 只是"不找 Developer ID 证书",electron-builder 24.13.3 因此完全跳过签名, 产出的 .app 没有 bundle 签名:

codesign -dv            → Identifier=Electron、flags=0x20002(adhoc,linker-signed)   ← Electron 引擎自带
codesign --verify --deep → 失败:code has no resources but signature indicates they must be present
Contents/_CodeSignature/ → 不存在

这种包在 Apple Silicon 上会表现为「应用已损坏,无法打开」。identity: '-' 也不行 (electron-builder 会把它当成"找一个名叫 - 的钥匙串身份")。 所以加了 afterPack: scripts/after-pack.js —— 构建完成后跑一次 codesign --force --deep --sign - <app>,并立刻 codesign --verify 自检; 签名不成立时让构建失败,而不是产出一个装不上的 dmg。不需要任何 Apple 账号。

有 Developer ID 证书时:填 mac.identity,并把 afterPack 钩子去掉(那条路径由 electron-builder 自己签)。

8. 依赖版本(锁定)

包 版本 说明
electron 31.7.7 运行时
electron-builder 24.13.3(精确) 8-23 那次构建就是这版在本机跑通的,其 nsis/wine 缓存与它配套;升 26/27 需要配置 schema 迁移(win.sign、fileOptions 等键重命名),超出本批范围
@electron/asar ^3.4.1 只给 verify:pack 读 asar 清单用(它本来就是 electron-builder 的传递依赖)
typescript 5.9.3
vitest 2.1.9

electron-updater:不装(理由见 §7)。

9. 代码规范与静态检查(R-P2-07)

launcher 侧统一了三件事:typecheck(覆盖 src + test)、lint、format。

cd launcher
npm run typecheck     # tsc --noEmit -p tsconfig.typecheck.json
npm run lint          # eslint .(flat config,含 3 条类型感知规则)
npm run lint:fix
npm run format        # prettier --write
npm run format:check  # CI 用

为什么 typecheck 用独立的 tsconfig.typecheck.json

主 tsconfig.json 的 include 只有 src/**/*.ts(build 用途,会 outDir 输出到 dist/),不覆盖测试文件。但测试里的 vi.mocked(...) 签名漂移、伪造数据的类型 错误恰恰最容易悄悄腐烂,所以新增:

{ "extends": "./tsconfig.json",
  "compilerOptions": { "noEmit": true, "rootDir": ".", "types": ["node", "vitest/globals"] },
  "include": ["src/**/*.ts", "test/**/*.ts"] }

⚠️ "rootDir": "." 是必须的:继承来的 rootDir: "src" 会让 test/** 报 TS6059: File is not under 'rootDir'。noEmit: true 下改 rootDir 无副作用。

顺手修掉的历史问题:加上测试文件类型检查后,首轮暴露了 6 处测试文件的真实类型错误 (Object.assign(new Error(...), {code: 1}) 传给 ErrnoException 回调、 codec: 'vp9' 越过联合类型、heartbeat.mock.calls[0][0] 在零参 mock 上取元组元素)。 全部按"显式跨类型(as unknown as)+ 注释说明为什么"的方式修掉,没有放宽任何 tsconfig 的严格度。

lint 耗时实测(决定是否需要拆 lint:ci)

场景 实测
首次(冷缓存、含类型感知规则的项目加载) 9.07s
第二次(同进程缓存未命中但文件系统已热) 7.28s

架构 §11.3.4 的退路是「首次 > 10s 就把 3 条类型感知规则移到独立的 lint:ci」。 实测 9.07s < 10s,因此不拆:lint 仍是一次跑完,CI 与本地口径一致。

类型感知只开 3 条规则

tseslint.configs.recommendedTypeChecked 里的 no-unsafe-* 那一族要求全量 unknown 建模,对 30 个文件的团队工具是纯负担;本项目真正容易犯的错是 「Promise 忘了 await」(心跳、证书写入、日志刷盘都是"不 await 也看起来正常、 但失败被静默吞掉")。因此只保留:

  • @typescript-eslint/no-floating-promises
  • @typescript-eslint/no-misused-promises
  • @typescript-eslint/await-thenable

其余用 tseslint.configs.recommended(无需类型信息)。与 §8.1 的"不启用类型感知" 表述冲突,以 §11.3.4 为准。

scripts/ 下的工具脚本

  • scripts/verify-package.mjs(ESM):打包自检,需要 node 全局 + console;
  • scripts/after-pack.js(CJS,electron-builder 用 require 加载它):mac ad-hoc 签名。

eslint.config.mjs 里为这两类各加了一段 files 配置(node 全局、no-console: off、 CJS 里 require 放行)。不加这段,eslint . 会因 process 未定义直接打红。

为什么 src/index.html 不参与格式化

.prettierignore 里刻意排除了唯一的源文件 src/index.html(不是漏配):

  • 它是内联手写 CSS + 单行 :root 变量表 + 与 renderer.js 的 getElementById 强耦合的模板;
  • Prettier 会把那段 CSS 重排成数百行,既降低"一眼看完配色"的可读性,又让 "改一个按钮"变成几百行的 diff,review 时真实改动被淹没。

其它源文件(src/**/*.ts、src/renderer.js、test/**/*.ts、*.mjs/json/yml) 全部纳入格式化。首次全量重排是独立一个 commit,其 SHA 记录在 .git-blame-ignore-revs(GitHub 与 git blame --ignore-revs-file 都会自动跳过它)。

10. Linux 支持边界与「未来纳入需补什么」

本轮边界:Linux 不纳入真机验证与打包(甲方裁决)。代码分支齐备、单测覆盖, 但 8 个能力位全部声明为 unsupported,platform/linux.ts 是显式降级实现类 (不是 else 兜底),usb === null。

  • ✅ 可用:登录、心跳、设备列表、设置页、凭据目录探测(候选含 ~/.config/sunshine、 Flatpak ~/.var/app/dev.lizardbyte.app.Sunshine/...、Snap ~/snap/sunshine/current/...)
  • ❌ 不可用:串流(主/被控)、USB 导出与挂载、SMB 共享、mDNS 发现
  • ⚠️ 凭据存储降级为内存(无系统加密存储),重启需重新登录

豁免的是「验证」,不是「设计」。未来纳入 Linux 时按下面 9 条逐项补, 每条都有现成落点,不需要重新设计架构:

# 要补什么 落点
1 scripts/linux-setup.sh:Sunshine 安装引导(Flatpak / apt)→ modprobe vhci-hcd(USB/IP 内核模块 + /etc/modules-load.d)→ Samba 引导(smb.conf 片段,替代 Windows 的 setup-smb.ps1) 新增脚本
2 把 usbImport / usbExport / streamHost / streamClient 的 state 从 'unsupported' 改为 'supported' 并补实现 —— 调用方零改动(能力声明式设计的直接收益) launcher/src/platform/linux.ts
3 把 5 条 ⚠️需真机验证 的 Linux 候选路径回填为实测结论 launcher/src/platform/credDir.ts
4 Linux 开机自启真机验证(GNOME / KDE 的 autostart 目录差异) launcher/src/core/autostart.ts(写 ~/.config/autostart/onlyinone.desktop,代码已就绪)
5 新增 scripts/linux-usb-attach.sh(对应现有 scripts/macos-usb-attach.sh) 新增脚本
6 CI 增加 ubuntu-latest 的 build + test job(不做打包) .github/workflows/ci.yml
7 docs/troubleshooting.md 增补 Linux 章节(内核模块、Flatpak 权限、Samba 防火墙) docs/troubleshooting.md
8 打包说明增加 AppImage / deb 本文 §1 / §2
9 README 阶段映射表 Linux 行从「未纳入」改为对应阶段 README.md

一句话:本轮不改架构,只是「不在 Linux 上验收」。上面 9 条每一条的抽象位置都已预留。

11. Plan B:Windows 打包挪到 CI(本次不实施,仅备案)

若某台机器上 Windows 打包出问题(wine / 下载源 / 缓存),把 Windows 打包挪到 GitHub Actions 的 windows-latest:在 .github/workflows/ci.yml 增加一个 pack-win job (跑 npm run dist:win + npm run verify:pack),mac 侧只跑 dist:mac。 不引入任何密钥。本次本机实测能正常产出 zip(PE32+ x86-64 / CRC 全通过),故不启用。