# 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---.`(`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%\`,退出即删**。 这恰好是「应用到处建文件夹」要消灭的现象,所以不用它。 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/` 下) ```bash 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 下载,直连不稳时设国内镜像: ```bash 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 报错。所以每次出包都跑: ```bash 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 ""` | 无输出,**exit 0** | 每个文件与签名记录逐字节一致 → **没被篡改** | | `codesign -dv --verbose=4 ""` | `Identifier=com.onlyinone.launcher`、`flags=0x2(adhoc)`、`Signature=adhoc`、`TeamIdentifier=not set` | 本机临时签名,没有开发者身份可追溯 | | `spctl -a -t exec -v ""` | **`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 ""` | ❌ **对本包无效(空操作)** | 本包全树**没有** `com.apple.quarantine`(只有 `com.apple.provenance`),没有隔离标记可去。实测 `xattr -r -l "" \| grep -c quarantine` = **0**。跑这条命令不会改变任何事,只会让人误以为"试过了但没用" | | `sudo spctl --master-disable`("打开任何来源") | ❌ **不要用** | macOS 15 起不再接受这种非交互的全局策略改动(要走 MDM 配置描述符);且它是对**整台机器**关掉这道防线,为一个内部工具不值 | | 系统设置 → 隐私与安全性 → **仍要打开** | ✅ **当前唯一正规路径**(见 §6.3) | | | 终端 `open -a ""` | ✅ 应急可用 | 见 §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 - `,并立刻 `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`。 ```bash 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(...)` 签名漂移、伪造数据的类型 错误恰恰最容易悄悄腐烂,所以新增: ```json { "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 全通过),故不启用。