用户在真机上撞到「未打开"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 条(现象/原因/排查/解决 + 判据表 + 三条「不要做」)
29 KiB
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。两个实现细节值得记住:
contents一旦自己写,就必须把 app 那一项也写进去(不写path的那一项 = 打包出来的.app本身)。只写软链会把 app 从卷里弄丢。- 源文件放
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.asar28 KB)覆盖成0.2.0(app.asar332 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\。
三层锁(为什么不靠"记得别改")
「改个名字就丢数据」是不可观测的故障:用户看不到报错,只看到设置没了、登录态没了。 所以固化写成三道锁 + 守卫单测:
core/paths.ts:数据根目录 =app.getPath('appData')+ 常量DATA_DIR_NAME, 不再读app.getPath('userData')(那个值本身就等于appData + app.getName(), 而getName()=package.json.productName ?? name)。纯函数resolveDataRoot()的四个分支全部可单测。main.ts:app.setPath('userData', appData/DATA_DIR_NAME),把 Chromium 那堆 (Cache / Cookies / Local Storage …)也钉在同一个固定目录。必须在requestSingleInstanceLock()之前 —— Windows 的单实例锁文件就在 userData 里。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 正确的放行步骤(当前唯一正规路径)
- 打开 dmg,把
Only in One拖进Applications。 - 访达里双击它 → 弹出拦截提示 → 点「完成」关掉。 这一步不能省:系统要先"看见"一次被拦,才会在设置里给出放行入口。
- 苹果菜单 → 系统设置 → 隐私与安全性 → 滚到底部「安全性」一节, 会看到「"Only in One" 已被阻止,以保护 Mac」→ 点 「仍要打开」。
- 输开机密码 / 触控 ID → 再点一次「仍要打开」。
- 之后双击正常,不会再拦。
两个坑:
- 「仍要打开」按钮只在不早于被拦后约 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 --verifyexit 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 全通过),故不启用。