44 Commits
Author SHA1 Message Date
jkooe 14272196c1 fix(config): 服务端地址自动清洗全角/零宽字符,失败原因精确到字符与位置
现场:用户填的地址里混了全角冒号(U+FF1A),点「注册」连续失败 5 次,界面只有
「参数格式不合法(INVALID_ARGUMENT)|按提示格式输入」,而请求一个字节都没发出去
(settings.json 里始终没有 serverUrl)。根因是 `trim()` 去不掉零宽字符、
`new URL()` 解析失败即返回空串,用户看到的地址和正确地址完全一样,无从自查。

三处修改:

1. 自动清洗(core/config.ts)
   - 新增 `sanitizeBaseUrlInput()`:全角 ASCII(U+FF01–U+FF5E)→ 半角、
     U+3000 → 空格、删除零宽与不可见字符(U+200B–U+200F / U+2060–U+2064 /
     U+FEFF / U+00A0),最后 trim;幂等、只作用于地址、不剥离中文域名;
   - `normalizeBaseUrl()` 在原有校验**之前**接入清洗,清洗成功即静默修复;
   - 真正的控制字符(U+0000–U+001F / U+007F)继续**拒绝**,不洗白。
   - 顺带修掉一个被新用例暴露的旧缺陷:`http://`(无主机)原会被裁成 `http:` 再
     补成 `http://http:`,解析出 `http://http` 这个用户没输入过的主机名。
     现在末尾斜杠改在解析之后裁。

2. 失败原因可自查(core/config.ts + errors.ts + main.ts + renderer.js)
   - 新增 `describeBaseUrlProblem()`,照服务端 `describePasswordProblem` 的风格分级给原因:
     空 / 超长 / 控制字符(第 N 位)/ 清洗后为空 / 非法字符(第 N 位 + 字符 + U+XXXX)/
     协议非 http(s) / 内嵌凭据 / 无法解析(带正确示例);
   - `UserErrorInfo` 增加可选 `reason`,`toUserError()` 经 `extractUserReason()` 放行
     「含汉字」的人话原因、丢弃机器码(`timeout` / `fetch_failed:TypeError`),
     守住 §9.2「禁止回显内部细节」;
   - `config:setServer` 失败时把 reason 放进 AppError details 并落一条 warn 日志;
   - renderer 展示 reason,并把「地址不合法」与「注册/登录失败」分开报
     (地址这关没过时不再显示「注册失败」,避免用户误以为是账号问题)。

3. 测试(+21 例)
   - config.test.ts:全角/零宽自动清洗、幂等性、合法地址不被改坏、IDN 不误伤、
     控制字符仍拒绝、`describeBaseUrlProblem` 每个分支(断言含位置与码点)、
     与 normalizeBaseUrl 口径一致性、`http://` 回归;
   - errors.test.ts / ipc.test.ts:reason 的下发与机器码过滤,含 makeSafeHandler 端到端。

门槛:typecheck / lint / format:check 全绿;launcher 546 例(基线 525,零回归);
server 82 例未受影响(server/** 未改动)。
2026-10-27 23:22:44 +08:00
jkooe 1e9a5ff72e feat(deploy): 服务端 Docker 化 + 本机部署文档 + 修正客户端默认地址
新增(server/):
- Dockerfile:node:22-alpine、非 root(node uid 1000)、npm ci --omit=dev、
  HEALTHCHECK 用 /api/v1/health(alpine 无 curl,改用 node fetch)、exec 形式 CMD。
  node:sqlite 是 Node 内置模块,alpine/musl 上实测可用,无需换 slim。
- .dockerignore:排除 .env / data / test / smoke.mjs / coverage / docs,
  保证密钥与用户密码哈希不进镜像层。
- docker-compose.yml:3100:3000(避开本机已被占用的 3000)、env_file 读 .env、
  bind mount ./data:/app/data(用户要能直接看到/拷走 onlyinone.db)、
  restart: unless-stopped、init: true、healthcheck。

docs/deploy.md:TL;DR、首次部署、客户端填什么、局域网、升级/备份、故障表、设计取舍。

修正 DEFAULT_BASE_URL:
`https://api.onlyinone.app` 是从不存在的域名,用户不填地址就永远连接失败。
改为 `http://127.0.0.1:3100`(与 compose 宿主机端口一致),本机 Docker 开箱即用。
选「默认可用」而非「留空+强制 UI 提示」:改动面只有一行常量、不碰 UI 逻辑,
而本机试跑正是首要场景;局域网需手填 IP,已在文档写明。

测试:新增默认地址回归防护(不可为空、必须能过 normalizeBaseUrl 且不被改写、
必须是纯 origin、端口须为 3100)。launcher 525/525、server 82/82。
2026-10-27 01:01:26 +08:00
jkooe e1e35266dc docs(troubleshoot): 修正 dmg 卷名(实际带版本号 Only in One 0.2.0-arm64) 2026-10-27 00:05:51 +08:00
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
jkooe 410b5234f9 docs(pack): T-04 打包/排障/README 同步交付物与覆盖安装(+2 条排障)
## docs/packaging.md(重写交付相关章节,保留代码规范与 Linux 边界两节)

- 产物表:真实文件名 + **实测字节数**(dmg 94,534,426 B / zip 107,114,242 B);
- 打包命令:`dist:mac/win/all` + `verify:pack`,并解释 `dist:all` 为什么是两条调用;
- **`verify:pack` 八步判据表**(含"哪一步是无 Windows 机器时的核心验证");
- 覆盖安装:macOS / Windows 各一张验收清单 + **本次实测记录**(0.1.0 → 0.2.0 覆盖后
  仍是 1 个 app、数据目录 1.8MB 条目零变化、find 仍 2 条);
- 数据目录:为什么"只建一个文件夹"有保证(三层锁 + 三字段各管什么 + 为什么缓存叫
  `app-cache` 而不是 `cache`);
- 未签名首启:两种文案 + 右键打开的处置 + ad-hoc 兜底命令;
- 分发预留:`publish` 注释位 + **两条硬约束**(mac 自动更新要签名+公证才可能;
  Windows zip 不支持自动安装);
- 新增 §7 子节说明 ad-hoc 签名为什么必须由 `after-pack.js` 做(附实测 codesign 输出);
- 新增 §11 Plan B(Windows 打包挪 CI)备案,本次不实施。

## docs/troubleshooting.md(16 条 → 18 条)

- **#17 覆盖安装后还是旧版本 / 出现两个 app**:托盘常驻导致替换失败、
  LaunchServices 记住 `/Volumes/...` 那份、`productName`/`appId` 被改的后果;
  排查含 `pgrep`、`ls -d /Applications/Only*`、`plutil -p` 版本、`app.asar` mtime、
  `lsregister -dump` 计数;解决含 `lsregister -f` 重注册与 SHA256 对账。
- **#18 应用到底建了哪些文件夹 / 数据能不能搬 / 怎么彻底卸载**:一张目录表
  (唯一数据目录 + plist,其余"本来就不会建")、`find … -iname "*onlyinone*"` 期望 2 条、
  跨机搬数据(`credential.bin` 换机器解不开 = 需重新登录,属设计)、
  macOS/Windows 彻底卸载命令、只清缓存的正确范围。

## README.md

- 文档索引两行同步(18 条排障 / packaging.md 新内容);
- 新增「安装包与覆盖安装」小节:产物表 + 出包自检命令 + **覆盖安装三步** + 数据目录一句话;
- 未签名提示补两处:「已损坏」文案不等于真的损坏、ad-hoc 兜底命令(不需要 Apple 账号)、
  以及"mac 端自动更新永久不可用 / zip 无自动安装"的结论;
- 目录结构里的两个数字同步(test 31 文件 / 524 例;补 `launcher/scripts/`)。

## docs/packaging-plan.md

架构方案的原始文档一并入库(决策证据),并在开头加「实施回执」:标注实施中修正的
两处判断(ad-hoc 签名、`dist:all` 架构参数),避免后来者按错的结论行事。
2026-10-26 21:38:38 +08:00
jkooe df98543a67 feat(pack): T-03 打包自检 verify:pack(8 步)+ 修正 mac 漏签名的真缺陷
## 1. 修正一个真实的交付缺陷:`identity: null` 不等于"ad-hoc 签名"

架构方案 A1/A6 与 Part E 第 6 条的判断("electron-builder 在 arm64 上会自动 ad-hoc 签名")
在 electron-builder **24.13.3** 上不成立。实测:

```
日志:  • skipped macOS code signing  reason=identity explicitly is set to null
codesign -dv            → Identifier=Electron   flags=0x20002(adhoc,linker-signed)
codesign --verify --deep → 「code has no resources but signature indicates they must be present」
Contents/_CodeSignature/ → 不存在
```

`linker-signed` 是 Electron **预编译二进制自带**的签名,不是 bundle 签名;而
`/Applications` 里那个"用户装过、能打开"的 0.1.0 旧包是 `flags=0x2(adhoc)` +
有 `_CodeSignature` + `codesign --verify --deep` 通过 —— 即**真做过 ad-hoc 签名**。
Apple Silicon 上没 bundle 签名的 .app 会表现为「已损坏,无法打开」。

`identity: '-'` 这条路也走不通(electron-builder 会去钥匙串里找名叫 `-` 的身份)。
所以新增 `scripts/after-pack.js`(CJS,electron-builder 用 require 加载):
构建完成后跑 `codesign --force --deep --sign -`,并立刻 `codesign --verify --deep --strict` 自检,
**签名不成立就让构建失败**,绝不产出装不上的 dmg。实测耗时 0.19s,不需要任何 Apple 账号。

修后形态与旧包一致:`Identifier=com.onlyinone.launcher` + `flags=0x2(adhoc)` + 校验通过。
(`spctl` 仍 rejected —— 未公证,属预期。)

## 2. `scripts/verify-package.mjs`:把"构建成功 ≠ 包可用"变成机器判据

8 步、38 项检查,任一失败退出码 1:产物存在性 / asar 清单(必含 14 项 + 必不含
`.map` `test/` `src/` `node_modules`)/ Info.plist 四字段 / 签名形态(含
`linker-signed` 反判据 + 严格校验)/ 真实启动 / dmg 校验与挂载 / zip 结构与 PE32+ 位数 /
**双端 asar 清单逐行 diff** / SHA256SUMS.txt。

两个刻意的设计:

- **第 7 步(双端 diff)是"没有 Windows 机器"时的核心验证**:38 条清单逐行相同 +
  两端 package.json 的 name/version 相同 + asar 内无 productName,能覆盖绝大多数打包事故;
- **第 4 步(真跑)不可能在本自动化环境成立**:Electron 主进程脚本要等 GUI 初始化才执行,
  本 agent 会话拿不到窗口会话 —— 对照实验:`/Applications` 里那个"用户装过、能打开"的
  旧版在这里表现完全相同(exit 0、零输出、零落盘)。所以该步降级为 ⚠️ 并写明证据,
  硬失败只留给「非 0 退出码 / FATAL / Cannot find module」。

## 3. `dist:all` 的架构参数是真 bug

原写法 `electron-builder --mac --arm64 --win --x64` 里,`--x64` 是**全局**的 —— 实际会
额外打一份 **darwin-x64**(多下一个 97MB 引擎,未签名状态下毫无价值),本机实测就是
卡在这里(`packaging platform=darwin arch=x64` + 下载失败)。改成两次调用:
`electron-builder --mac --arm64 && electron-builder --win --x64`。

## 4. eslint:`scripts/` 两类脚本各一段

`scripts/**/*.mjs`(ESM:node 全局 + `no-console: off` + 放开 createRequire);
`scripts/**/*.js`(CJS:`require`/`module`/`__dirname` 全局 + `no-require-imports: off`)。
不加第一段,`eslint .` 会因 `process` 未定义直接把 lint 打红。

验收:dist:all 真实产出 dmg 94,534,426 B + zip 107,114,242 B;verify:pack 38 ✅ / 2 ⚠️ / 0 ❌。
2026-10-26 21:38:22 +08:00
jkooe 56f8d7d4db test(pack): paths.test.ts 补 ensureDir 两例(paths.ts 行覆盖 80% → 95.55%)
`ensureDir()` 是 `core/paths.ts` 里唯一"失败也不抛"的函数(调用方靠返回值降级),
它的两条路径此前没有用例:

1. 正常建目录 + 幂等(连调两次都返回 true);
2. 路径被同名**文件**占用 → 返回 false 且不抛异常(这条是真实故障形态:
   `logs` 路径上恰好有个同名文件时,日志器必须降级而不是 crash)。

实测:paths.ts 行覆盖 95.55% / 函数 100%,剩余未覆盖仅 69-70(`loadElectron()` 的
catch —— vitest 里 `require('electron')` 拿到的是包的入口而不是抛错,这条只能靠
真实 Electron 环境覆盖)。全量 524 例全绿,覆盖率门禁 92.19% 通过。
2026-10-26 21:38:12 +08:00
jkooe e73d2d3ab0 feat(pack): T-02 数据目录固化——root 不再依赖 app 名,cache→app-cache
「改个名字就丢数据」是不可观测的故障:用户只看到"设置没了、登录态没了",看不到报错。
所以固化不靠"记得别改",靠三道锁 + 守卫单测。

第 1 道锁(`core/paths.ts`):root 不再由 `app.getPath('userData')` 决定
  · userData 本身就是 `appData + app.getName()`,而 getName() = productName ?? name
    ——任何人往 package.json 加一行 `"productName": "Only in One"`,数据目录就会
    凭空改名成 `~/Library/Application Support/Only in One`(还带空格),dev 与打包版
    同时漂移,用户数据"消失"
  · 改为 `app.getPath('appData')`(与 app 名无关)+ 硬编码 `DATA_DIR_NAME`
  · 抽出纯函数 `resolveDataRoot()`:显式注入 > appData+常量 > `~/.onlyinone`,
    四个分支全部可单测(不碰 electron)

第 2 道锁(`main.ts`,5 行纯配置):`app.setPath('userData', appData/DATA_DIR_NAME)`
  · Chromium 自己的 Cache / Cookies / Local Storage 也钉死在同一个固定目录
  · **必须在 `requestSingleInstanceLock()` 之前**:Windows 的单实例锁文件就在
    userData 里,顺序错了锁会落在默认目录,"看起来能防重复启动、实际防不住"
  · `catch` 里必须有注释,否则 `no-empty` 规则报错;静默兜底的理由写在注释里

顺手修一个真实隐患:`cache()` 从 `root/cache` 改为 `root/app-cache`
  · Chromium 在同一 root 里建的是 `Cache/`,而 APFS 与 NTFS **都大小写不敏感**
    → `cache` 与 `Cache` 是同一个目录,我们的 `devices.json` 会落进 Chromium 的
    磁盘缓存、被它的清理逻辑波及,表现为"设备列表缓存偶尔莫名失效"(flaky 极难查)
  · 改名成本 0:现存 userData 里只有 Chromium profile,没有我们的任何文件

第 3 道锁(新建 `test/paths.test.ts`,13 例):DATA_DIR_NAME 字面值、
package.json 无 productName、package.json.name === DATA_DIR_NAME、
electron-builder.yml 的 appId/productName 字面行、cache() 与 14 个 Chromium
目录名大小写不敏感不重名。

迁移成本 0:实测现存 userData 只有 Chromium profile(无 settings.json /
credential.bin / logs),新旧路径指向同一位置,老数据原地继承。

门槛:typecheck 0 错 / lint 0 error / format:check 通过 / 522 例全绿(509 + 13)。
2026-10-26 21:15:04 +08:00
jkooe 6b1243f8a8 chore(pack): T-01 打包配置——mac dmg / win zip + 产物命名规范 + 依赖锁定
T-6 打包交付的第一步(业务代码零改动):

1. `electron-builder.yml` 全量重写
   - macOS: dmg / arm64;Windows: **zip / x64**(刻意不用 portable:它是 NSIS
     自解压器,每次启动把 ~258MB 解到 %TEMP%,退出即删——正是「不要到处建文件夹」
     要消灭的现象)
   - `artifactName: OnlyInOne-${version}-${os}-${arch}.${ext}`:产物名不带空格
     (productName 是 `Only in One`,会把空格带进文件名与 URL),app/exe 名不变
   - 删除 `nsis` 段;`publish` 以注释位预留(本次不引 electron-updater)
   - `appId` / `productName` 一字未改,并加「永不修改」注释与守卫测试
2. `package.json`
   - version `0.1.0` → `0.2.0`(8 月的旧包也叫 0.1.0,不升会撞名)
   - 补 `author`(消除 electron-builder 的 author 警告)
   - `build` 前先 `rm -rf dist`(删过源文件后 dist 里会留幽灵模块,被打进 asar)
   - 新增 `dist:mac` / `dist:win` / `dist:all` / `verify:pack`
   - devDependencies:`electron-builder@24.13.3`(**精确版本**,8-23 那次就是这版
     在本机跑通的,其 nsis/wine 缓存配套);`@electron/asar@^3.4.1` 显式声明
     (自检脚本要用;它已是 electron-builder 的传递依赖,不新增下载,但显式写出来
     比依赖 hoisting 稳)
3. `launcher/release/` 清掉 8 月 4 个旧产物与两个 `*-unpacked/`、`builder-debug.yml`
   (对应已被全量重写掉的代码,留着只会被误分发;该目录已 gitignore)

验收:`npm ls electron-builder` = 24.13.3。
2026-10-26 21:14:54 +08:00
jkooe f84e34aa9e fix(agent): status() 上报当前 deviceId 而非构造初值(QA-V10)
- 自愈换 id 后 status() 仍返回 this.deps.deviceId,状态页会显示已被服务端
  降级掉的旧 id,按它查设备行会查到错的那一条
- 改为 this.deviceId,并加注释说明为何不能用初值
- 入库 QA 的 qa-deviceid-heal-e2e.test.ts(7 例端到端:真服务 + 真 AgentLoop
  + 真 settings 文件),QA-V10 由 it.fails 转正常用例守住此回归
2026-10-26 03:21:02 +08:00
jkooe e2bf981763 chore: .workbuddy/memory 项目记忆入库(架构决策与踩坑记录属项目资产)
- 修正上一提交:.workbuddy/ 整个目录被忽略是错的
  memory/ 存的是架构裁决(R1~R11)、缺陷根因、测试反模式,团队应共享
- 只忽略可能出现的临时会话状态子目录 sessions/ 与 cache/
- 新增 memory/2026-10-26.md:本轮 T-1~T-5B 全量交付记录
2026-10-26 03:12:19 +08:00
jkooe d12e22358d docs: README 启动器行覆盖 91.89% → 91.90%(本轮 ReadDeviceId 重构后实测) 2026-10-26 03:11:36 +08:00
jkooe 104027e29f chore: .gitignore 忽略 .workbuddy/(本地工具状态,不是项目源码)
`.workbuddy/` 是本地开发工具(WorkBuddy)的会话记忆目录,每台机器各不相同,
不应该进库、也不该让 `git status` 变脏。与既有的 `.idea/` / `.vscode/` 同类。
2026-10-26 03:09:58 +08:00
jkooe b6161b41d6 docs: 新增附录 A 测试反模式清单(6 条);fix(certs): syncAndPrune 单次读取 deviceId
- certs.ts: deviceId 只在方法入口读一次并下传,避免换 id 瞬间 sync 用新值、
  prune 用旧值导致期望集合错配(与 dirRes 一次 tick 只解析一次同原则)
- troubleshooting: 附录 A 收录本项目真实踩过的 6 条测试反模式
  (upsert 只覆盖 UPDATE 分支 / 空集合循环恒真 / 假 fs 证明不了大小写不敏感 /
   白名单常量自证 / 目录夹具与正则不匹配导致空转 / 测试绿但编译不过)
2026-10-26 03:06:47 +08:00
jkooe 8903871919 feat(launcher): R10 客户端自愈——device_id 被降级为 null 时换新 id(免 PIN 闭环)
## 缺陷(QA 观察 2,用户可感知的那一半)
服务端已把「500 + 永久丢设备」消灭了,但客户端从不读响应里的 `device_id`:
`agent.ts` 的心跳只取 `result.data.serverTime`。后果链:
1. 两台机器共用 settings.json → 撞 `idx_devices_uuid`;
2. 服务端把后到的那台降级为 `device_id = null`(不 500,在线状态保住)✅;
3. 客户端毫无反应 → 下一轮继续上报**同一个被占用的 id** → 永远 `null`;
4. 本机证书永远进不了对端 Sunshine 目录 → **每次串流都要手输 PIN,无自愈路径**。

免 PIN 是本项目核心目标,所以这条必须补。

## 修法
- `core/config.ts` 新增 `regenerateDeviceId()`:**无条件**换一个新 id 并持久化。
  与 `ensureDeviceId()`(保证有 id、绝不覆盖)**职责严格分开** —— 给后者加
  `force` 也能 work,但等于在唯一一个「不该覆盖」的入口上开一个能覆盖的开关。
- `agent.ts`:
  - `AgentDeps` 新增 `regenerateDeviceId?()` 与 `readDeviceId?()`;
  - 内部 `deviceId` 由「构造时快照」改为可变字段,心跳请求体用它;
  - 心跳成功后调 `healDeviceIdIfSuppressed(result.data?.device_id)`;
  - 判据严格 `=== null`:`undefined` 表示老服务端根本不回该字段,与「被降级」无关,
    误触发会导致「每轮换一个 id、永远撞不完」;
  - 换完立即持久化,下一轮心跳(≤30s)生效;不做忙等重试,新 id 若又撞则下一轮自然再换;
  - 生成器未注入 / 返回同值 / 抛异常时**都不打挂心跳**(就地捕获 + warn)。
- `certs.ts`:`CertServiceDeps.deviceId` 允许传 `() => string`,每次调用现取。
  否则换 id 后 `skipped_self` 仍用旧 id 比对,会把**自己的**证书当 peer 写进本机
  Sunshine 目录,留下一个永远「受管」的自我证书文件(prune 清不掉)。
- `main.ts`:`currentDeviceId` 改为可变局部变量,向下传工厂函数而非快照。

## 测试(launcher 493 → 502)
`test/agent.test.ts` 新增 6 例:
1. `响应 device_id === null → 生成新 id 并持久化,且与旧 id 不同`
2. `下一轮心跳上报的是新 id(旧 id 不再出现在请求体里)`(断言第二次调用入参)
3. `响应是正常 id → 绝不触发重新生成(防「每轮换一个 id」的抖动)`
4. `响应没有 device_id 字段(老服务端兼容)→ 同样不触发重新生成`
5. `未注入 regenerateDeviceId → 只记 warn,不抛异常、心跳仍算成功`
6. `生成器返回与旧值相同的 id → 不切换(否则就是「假自愈」)`
`test/config.test.ts` 新增 3 例:真的写回设置文件、换完后 `ensureDeviceId` 返回新值、
原本无 id 时也能换。

## 顺带|观察 1 的口径统一(一行)
`insertDevicePresence` 第 ③ 层由无条件 `null` 改为
`racedAfterRetry.device_id ?? null`(保留原值),与 `existing` 分支
`deviceId ?? fallbackId` 的口径一致;避免将来多 worker 共享 DB 时把并发请求刚写入的
合法 id 抹掉一个 tick。
注释里补上 QA 指出的事实:**①③ 在单进程下不可达**(`node:sqlite` 是同步 API,
handler 内无 `await`,两次查询之间插不进别的请求)—— 这正好解释了旧代码为什么
**必然** 500(旧 catch 只有「重查同名」一条路,而它恒走不到)。保留为多 worker 预留。

## 文档
- `docs/troubleshooting.md` §16:新增「两台机器共用同一 `device_id` ⇒ 一个心跳周期内
  自动恢复」的首条处置(含两条 warn 的原文与判据),并把
  `本机 device_id 已被他人占用,已换新 id` 加入「现象」(注明是自愈动作不是故障)。
- `docs/api.md` R10 段:补「客户端必须读这个 `null` 并换 id,否则降级等于永久无 id」,
  含 `=== null` 与 `undefined` 的区别。
- README 数字同步。

## 记录澄清
QA 指出 `30fc269` 的 message 把 `QA-R10f/g/h/i` 记成「新增」,实际是 QA 写在工作区、
我提交时一并收录。归属记错一格,在此说明;断言与代码无误,不改历史。

## 一并入库
`server/test/qa-r10-concurrency.test.mjs`(QA 第 3 轮并发对抗用例 6 例)。

验证:server 82/82(覆盖率 90.18%,smoke 通过);launcher 502/502
(typecheck / eslint / prettier 全绿,覆盖率 91.89%);gitleaks protect --staged 干净。
2026-10-26 03:02:43 +08:00
jkooe eb7371591f docs: README 数字同步(server 76 例 / 90.15%;launcher 29 文件 493 例 / 91.87%) 2026-10-26 02:53:25 +08:00
jkooe 5f551b1ea1 docs: 记录 device_id 仅大小写不同的理论边界(D4 残余,L5 兜住不误删) 2026-10-26 02:51:33 +08:00
jkooe 30fc269943 fix(devices): R10 日志脱敏为 deviceId8,并补强 R10 冲突用例
- 日志不再输出完整 device_id,只留前 8 位(与 security.md 脱敏清单同源)
- 去掉 QA-R10 系列的 [BUG-OPEN] 标记(缺陷已修复)
- 新增 QA-R10f/g/h/i:反复上报、降级行为真行、写语句不污染、唯一约束是 per-user
- 新增 R10 INSERT 分支四例:降级为 null / 行确实创建 / last_seen 刷新 / 可自愈
2026-10-26 02:49:28 +08:00
jkooe 66dd291123 fix(devices): R10 补全 INSERT 分支的唯一约束兜底(QA-R2 复验缺陷)
## 缺陷(QA 独立复验发现,我上一轮只修了一半)
上一版 R10 只在 **UPDATE** 分支包了 try/catch。INSERT 分支的 catch 里只重查
「同名设备」,而 `devices` 上有**两个**唯一约束,冲突后的正确处置完全不同:

| 约束 | 含义 | 原实现 |
|---|---|---|
| `UNIQUE(user_id, device_name)` | 并发同名插入 | ✅ 重查后按 UPDATE 处理 |
| `UNIQUE INDEX idx_devices_uuid(user_id, device_id)` | 别人的 id 被重复上报(克隆 VM / 复制 settings.json **后改了设备名**) | ❌ `raced` 为 undefined → rethrow → **500** |

而 INSERT 漏兜的后果**比 UPDATE 更重**:行建不出来 ⇒ 下一次心跳仍然走 INSERT、
仍然 500 —— **永久性**,不是一次性抖动。触发条件就是「复制配置后改了设备名」。

## 修法
抽出 `insertDevicePresence(candidate)`,与 `updateDevicePresence` 同构,三层:
1. 先试带 id 插入;
2. 撞唯一约束 → **先**重查同名行(那是并发同名,交给 UPDATE 路径,它自带 id 冲突兜底);
3. 没有同名行 ⇒ 撞的是 `idx_devices_uuid` → 降级为 `device_id = null` **再插一次**,
   并记 warn。降级重试**自己也包一层 try**:它可能再撞 `(user_id, device_name)`,
   那一层必须退回 `updateDevicePresence` 而不是继续上抛 —— 少了这层,②③ 两步之间
   插入的并发同名行会把降级路径重新变成 500。
`candidate === null` 且仍撞唯一约束时原样上抛(已经要写 null 还撞,只可能是别的约束,不吞)。

## 测试(server 58 → 71 例)
我自己的 `server/test/certs.test.mjs` 补 4 例(不依赖 QA 的文件):
1. 新 device_name + 已占用 device_id → **200**、`device_id===null`、**库里确实有一行**、
   `last_seen>0`(缺行断言会明确失败,不会退化成 TypeError 掩盖真实原因);
2. 连打 3 次同样载荷 → 3 次全 200 且全部 `null`,证明不是一次性;
3. 降级后换不冲突的新 id → 自愈,且原持有者的 id 不受影响;
4. 对照:先用 `POST /certs` 建出 `device_id=NULL` 的行,再心跳撞 id → 走 UPDATE 也是 200。
QA 的 3 条 `[BUG-OPEN]` 用例(QA-R10c / QA-R10d / QA-R10)**自动转绿**,未改其断言。

## 附带收口(QA 的非阻塞建议)
launcher 侧补一条 `DEVICE_ID_PATTERN.source === '^[A-Za-z0-9-]{8,64}$'` 字面量断言,
与 `server/test/deviceIdContract.test.mjs` 钉的是同一串源码。原来只有
「config 与 validate 是同一对象」—— 那只能防住仓内分叉,防不住「两端各自往
ACCEPT/REJECT 清单里加值、清单渐渐不等」这种漂移。

## 文档
`docs/api.md` 的 R10 段写明**两条写路径都要兜**、INSERT 的后果更重、
以及降级重试的第三层;响应体 `device_id` 的取值说明同步。
README 数字同步(server 71 例 / 90.10%;launcher 29 文件 493 例 / 91.87%)。

验证:server 71/71(覆盖率 90.10% ≥ 60 门禁,smoke 通过);launcher 493/493
(typecheck / eslint / prettier 全绿,覆盖率 91.87%);gitleaks protect --staged 干净。
2026-10-26 02:47:06 +08:00
jkooe 111b442ef8 docs: D3/D4 文档同步(api.md 文件名键语义 / troubleshooting 第 16 条 / README 数字)
## docs/api.md
- heartbeat 的 `device_id` 一行:把 `invalid_name` 更正为 `invalid_device_id`
  并写清 `missing_device_id` 与 `invalid_device_id` 的区别(前者=没上报、
  后者=格式不合法,排障含义完全不同);补「与 launcher `DEVICE_ID_PATTERN` 逐字一致」。
- 「插入/更新语义」新增 R10 说明:`device_id` 撞 `idx_devices_uuid` 时**降级写回
  `null`** 而不是 500(保住在线状态,本轮丢文件名键,下轮自愈)。
- heartbeat 200 响应补一句:响应里的 `device_id` 是**本次实际写入库的值**
  (正常=请求值;撞约束=`null`;未带=库里上一次的值)。
- `POST /certs` 的 `device_id`:原文「服务端忽略」容易误读成「传了也没用」,
  改为「忽略**本请求体**里的值:证书条目的 `device_id` 取自 device 行(由 heartbeat
  写入),所以客户端要让它有值必须先在 heartbeat 里上报」。
- `GET /certs` 新增「客户端如何消费这份快照」表:①跳过自己→⑥原子写,六个 reason
  全列;并写明为什么文件名主体必须是 `device_id`(两套白名单会错位、大小写不敏感 FS
  会互相覆盖)以及 L5 不变量为何让**升级免迁移脚本**。
- §7 客户端约定新增第 3 条:落盘文件名只由 `device_id` 决定,含空/畸形/self-skip 三条派生行为。
- curl 示例的本地路径由 `moonlight-kou-mbp.pem` 改为 `moonlight-<device_id>.pem`。

## docs/troubleshooting.md
- 新增**第 16 条「对端证书不落盘 / 凭据目录里的文件『只增不减』」**(四段式):
  四个 reason(missing_device_id / invalid_device_id / expected_missing / io_error)
  的成因、四条排查命令、以及「升级后新旧两种文件名并存是正常的、不需要迁移脚本」
  的明确结论。
- 文首条目数 15 → 16,并指明第 16 条由本次 D3/D4 引入。
- 全文**没有**「设备名不要仅大小写不同」这类警告 ——  §13.6 的兜底文案是
  「若最终决定不修才写」,本轮决定修且已修,故不写入(避免留下已失效的限制说明)。

## README.md
- server 52 → 58 例、行覆盖 89.29% → 89.51%(branch 70.88% / funcs 88.73%);
- launcher 451 → 474 例、行覆盖 91.67% → 91.87%(branch 83.74% / funcs 85.5%)。

验证:gitleaks protect --staged 干净(无 .gitleaksignore、无 --no-verify);
双端测试全绿;README 里每个数字都是本轮实测值。
2026-10-26 02:26:35 +08:00
jkooe 1f956d1333 fix(certs): D3/D4 落地——证书文件名键由 device_name 改为 device_id(R6/R7/R8/R9)
## 为什么
`device_name` 是用户可改的展示字段,却被当成磁盘文件名主体,导致两个长期问题:
1. 双端必须手工对齐两套白名单(服务端 `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`
   vs launcher 同一份),D1 就是它们错位的产物;
2. 大小写不敏感 FS(macOS/Windows)上 `Peer-A` / `peer-a` 互相覆盖(D4)——
   而 `devices.device_name` 是 BINARY collation,服务器认为它们是两台设备。

## 改法
### R6|三处键同源改为 device_id
- `peerCertFileName(deviceId)` / `parsePeerCertFileName(file)→deviceId`,
  `PEER_CERT_NAME_RE = ^moonlight-([A-Za-z0-9-]{8,64})\.pem$`;
- 写入键(`syncPeerCerts`)、期望集合键(`managedFileNames`)、解析键
  (`pruneStalePeerCerts`)全部改走它;`device_name` 退化为纯展示字段,
  不再参与任何文件名推导。

### R7|device_id 为空:不写、不兜底
新增 `SyncSkipReason = 'missing_device_id'`。理由:`device_id` 为空的唯一情形是
「该设备从未心跳过」,而从未心跳的设备没有 `lan_ip`、本来就不可能被一键连接,
因此跳过写入零损失,且 ≤30s 后下一轮心跳即自愈。**退回 name 键会让 D1/D4 立刻复发**,
所以不做双键。

### R8|self-skip 只按 device_id
删除 `SyncOptions.selfDeviceName` 与名字兜底(`ensureDeviceId()` 保证本机 id 恒非空)。
保留名字兜底只会让「同名不同机」被误判成自己。

### R9|prune 新增 L5 不变量(免迁移脚本的关键)
`managed ⊆ 目录现有文件` 成立才允许删;否则整轮跳过(`expected_missing`)。
`PruneOptions.deviceId` 定为**必填**:可选会默认落进「永远不删」的静默降级,
而这类故障表现为「凭据目录只增不减」,几乎不可能被发现。
配合 L1,旧 `moonlight-<device_name>.pem` 要么落进 `not_managed_naming` 被保留、
要么解析成不存在的 id 被清,两种都不伤新文件 —— **不需要改名脚本**。

## 附带修掉的真实缺陷
`SAFE_NAME_RE` 的长度上限 64 对**文件名**来说是错的:`moonlight-`(10) + device_id(≤64)
+ `.pem`(4) = 78。若不改,长度为 64 的 device_id(R11 允许的上界)会在 `safeJoin()`
里被判超长 → 该 peer 证书永远写不出来,且每轮都让 L5 整体跳过。改为
`MAX_FILE_NAME_LENGTH = 128`(`isSafeName` 的 1–64 口径不变)。

## device_id 白名单收口
`DEVICE_ID_PATTERN` 移入 `core/validate.ts`(唯一一份),`core/config.ts` re-export,
避免「生成 id 一套规则、作文件名键另一套」——那是 D1 的同类事故。

## 测试(launcher 451 → 474)
- D4 回归:仅大小写不同的两个 device_name 现在落在两个不同文件(certs.test.ts
  + qa-certs-adversarial C7,且断言 `peerCertFileName('Peer-A')` 已为 null);
- 旧名自愈:旧命名文件落 `not_managed_naming`、保留、且不阻塞新命名的清理;
- `missing_device_id` 三例(null / undefined / 空串)+ `invalid_device_id`(10 类载荷);
- R9/L5 三例(受管文件未落盘 → 零删除、dryRun 不更激进、「写入失败那一轮不连带删别人」);
- 契约:`config.ts` 与 `validate.ts` 的 `DEVICE_ID_PATTERN` 必须是同一个对象引用;
  `PEER_CERT_NAME_RE.source` 必须由 `DEVICE_ID_PATTERN.source` 派生;
  最长 64 位 id 的 78 字符文件名必须仍能被 `safeJoin` 接受。
- QA 的 `moonlight-peer-a.pem` 夹具改为 8 位以上 id —— `peer-a` 只有 6 字符,
  在新正则下已不是受管文件,沿用会让 L1/L2 用例变成空转。

## 顺序说明
R9 与 R6 同在 `pruneStalePeerCerts()` 一个函数内、且「期望集合」与「期望必须已落盘」
是一体两面,故合并为本次提交;R11(第 1 步)、R10(第 2 步)已各自独立提交,
顺序未变。

验证:launcher 474/474(typecheck + eslint + prettier 全绿,覆盖率 91.87% ≥ 60);
server 58/58;test:coverage 门禁通过。
2026-10-26 02:22:34 +08:00
jkooe 380baa47a6 fix(devices): R10 heartbeat UPDATE 分支补唯一约束兜底(冲突降级 device_id=null)
问题:devices 上有 UNIQUE INDEX idx_devices_uuid(user_id, device_id),但
/api/v1/devices/heartbeat 的 UPDATE 分支没有 try/catch。某客户端上报了与他人
重复的 device_id(克隆 VM、复制了旧机器的 settings.json)时,UPDATE 会抛未捕获
异常 → 500,该设备的在线状态永久丢失。D3 之后 device_id 还是证书文件名的主体,
冲突会进一步升级为「两台设备抢同一个文件名」。

修法(失败不可接受,降级可接受):
- 抽出 updateDevicePresence(row, candidate),UPDATE 包 try/catch;
- 仅当错误信息含 unique 时降级写回 device_id=null + logger.warn(保住在线状态,
  代价是本轮拿不到文件名键,客户端按 missing_device_id 跳过写入);
- 其它错误继续上抛,不吞真实故障;
- existing 分支与并发 raced 分支都改走该 helper。

测试(certs.test.mjs 新增 2 例 / server 56→58):
- R10 撞约束:200 且 data.device_id===null、A 保留 idA、B 落 null、last_seen 仍刷新、
  换成不冲突的新 id 立即自愈;
- R10 缺省 device_id:回落到库里已有值,null 不会把已有 id 冲掉。

验证:npm test --prefix server 58/58 通过;test:coverage 89.51% ≥ 60 门禁。
2026-10-26 02:10:09 +08:00
jkooe 3fde62040c fix(security): R11 双端 device_id 长度下限统一为 8(服务端收紧)
`server/validate.js` 是 `[A-Za-z0-9-]{1,64}`,launcher `core/config.ts` 是 `{8,64}` ——
与 D1 的 device_name 错位**完全同类**:服务端放行 3 字符 id,launcher 拒收后该 peer
证书永远落不了盘(D3 之后还会直接决定文件名,静默失效面更大)。

修法:
- `DEVICE_ID_PATTERN` → `/^[A-Za-z0-9-]{8,64}$/`,docstring 写明与 launcher 同源,
  并说明 D3 之后该值是证书文件名主体(`moonlight-<device_id>.pem`)。
- `docs/api.md`:heartbeat 的 `device_id` 字段补「不能为空、不能短于 8 位」及其后果。
- `server.js` **无需改**:grep 确认 `isValidDeviceId()` 只用于「非法则忽略」分支,
  没有引用该规则的 `details.reason` 文案(device_name 的两处已在 D1 修掉)。

## 按架构 §13.4 补「双端行为契约」(而不是代码级共享正则)

新增 `server/test/deviceIdContract.test.mjs`(4 例)+ `launcher/test/validate.test.ts`
的 `device_id 双端契约` describe(3 例),两侧使用**逐字相同的取值清单**:

- ACCEPT:`'aaaaaaaa'` / `'A'.repeat(64)` / 真实 UUID / `'-dash-01'`
  (最后一项刻意保留:字符集允许 `-` 开头,两端一致;它只出现在文件名中段,
  不进 argv 首位,无注入面)
- REJECT:`'-dash'` `'.dot'` `'dot.'` `'a b'` `'中文'` `'a'.repeat(65)` `'ok-name_1.2'`
  (以上 7 项取自架构 §13.4 指定清单)+ 7 位 + 空串

优于「比对正则字符串」:换一种写法不会误报,真错位必然失败。服务端额外钉住
`DEVICE_ID_PATTERN.source`,防止有人偷偷加字符集。

测试:server 56 例(52 + 4)全绿;launcher validate.test.ts 26 例(23 + 3)全绿。
2026-10-26 02:07:02 +08:00
jkooe 0dd003a531 docs: §13 QA 验收后的设计裁决(D3/D4 本轮修 + R9/R10/R11 前置缺陷)
- D3/D4 裁决:证书文件名键由 device_name 改为 device_id,根治双端白名单手工对齐与大小写碰撞
- 确认证书文件为纯缓存(仅存 peer 公钥),删/改名无损,免迁移脚本
- 新增 R9 prune 第 5 层不变量、R10 heartbeat 唯一约束冲突兜底、R11 device_id 长度双端统一
- D5 采结构不变式而非快照断言(快照可被 -u 顺手刷新,等于取消评审)
- 记录 D1 教训:跨语言共享正则不推荐,改用行为断言比对两端拒绝集合
2026-10-26 02:04:01 +08:00
jkooe 6206645f31 fix(ci): D2 覆盖率口径改为 include 白名单(数字不再被运行环境稀释)
## 缺陷

`server` 的 `test:coverage` 只写了阈值,没有 include/exclude。某些开发机
(IDE / 代理 / 沙箱工具)会在 Node 的 `require` 链上注入 `*.cjs` 钩子,这些
**项目外**文件被覆盖率跟踪器一并统计:

```
# broker-ipc-client.cjs      | 24.76
# node-brokered-fs-shim.cjs  | 50.13
# node-language-shim.cjs     | 82.86
# node-safe-delete-shim.cjs  | 41.91
# all files                  | 76.56   ← 被稀释 10+ 个点
```

危害是双向的:
① 换机器/换环境数字就变,**不能作为交付证明**;
② 60% 门禁在拿被稀释的分母判定 —— `server.js` 真掉 20 个点可能被这些 shim 抵消
   而不报警。

## 修法(用 include 白名单,而不是逐个 exclude 环境特有文件名)

```json
"test:coverage": "node --test --experimental-test-coverage \
  --test-coverage-include='**/server/*.js' \
  --test-coverage-exclude='**/node_modules/**' \
  --test-coverage-exclude='test/**' \
  --test-coverage-lines=60 test/*.test.mjs"
```

`include` 表达的是正确语义:「只统计本项目源码」。逐个 exclude 环境特有的
`*.cjs` 名字换台机器就会再冒出来,不解决问题。

## 三条验收(逐条实测)

1. **报告内无项目外文件**:覆盖率报告的文件列只有 10 个项目源码
   (config / crypto / db / errors / logger / migrate / ratelimit / server / tokens /
   validate .js),`grep -E "Applications|/Users/|node_modules|\.cjs"` 对报告段落
   零命中。
2. **数字可复现**:连跑 3 次,`all files` 均为 `89.29 | 70.85 | 88.65`。
3. **门禁真实会 fail**:阈值临时调到 99 复跑 → `exit=1` 并打印
   `Error: 89.29% line coverage does not meet threshold of 99%.`
   (改回 60 后恢复正常)

## 连带

- `.github/workflows/ci.yml`:把口径写进覆盖率那一步的注释(为什么用 include、
  为什么不能靠 exclude 兜底),本地与 CI 从此同一口径。
- `README.md`:覆盖率与测试数全部换成实测值 ——
  server **89.29%**(原 75.31% 是被污染的数字)、launcher **91.67%**(原 91.06%);
  server 测试 **52**(原 43)、launcher 测试 **451**(原 390);
  并新增「覆盖率口径为何用 include 而不是 exclude」小节,把这次事故写清楚。
2026-10-26 01:59:46 +08:00
jkooe 5937743bca test: 入库 QA 5 个对抗测试(68 例)+ 翻转 QA-E6 + 补白名单自检
## 入库 QA 的 5 个对抗测试(原为未跟踪状态,属交付物)

| 文件 | 例数 | 视角 |
|---|---|---|
| `server/test/qa-security.test.mjs` | 9 | 服务端安全(副作用断言:DB 里到底有没有数据、响应体是否逐字节相同、限流是否真触发) |
| `launcher/test/qa-contract-e2e.test.ts` | 13 | **真实起服务 + 真实客户端**的端到端契约(两侧单测都绿但互相错位这类问题只有它能抓) |
| `launcher/test/qa-certs-adversarial.test.ts` | 17 | 证书链路对抗(指纹伪造 / 路径穿越 / 命名畸形) |
| `launcher/test/qa-runner-injection.test.ts` | 10 | 命令注入(参数数组 vs 拼串) |
| `launcher/test/qa-boundary.test.ts` | 20 | 边界与降级 |

保留 `qa-` 前缀:一眼能看出归属,也便于将来与被测代码解耦。

## QA-E6 按 D1 修复后的新行为翻转(原本它断言的是缺陷本身)

旧断言(记录缺陷现状):「服务端 200 接受 `-dash` → launcher 写不出文件」。
新断言(守护修复后的不变量):
1. `-dash` / `.hidden` / `--flag` / `..` / `.` 在 heartbeat **与** certs 上一律
   `400 VALIDATION_FAILED`,且库里不会留下任何首字符为 `.` / `-` 的设备;
2. 合法设备名 `dash-ok_1.0`(覆盖 `.` `_` `-` 三类字符)两端都接受,且
   `syncPeerCerts` 真的把 `moonlight-dash-ok_1.0.pem` 写进凭据目录 ——
   这是 D1 的核心不变量:**服务端接受的设备名,客户端必须写得出来**。

## 补 `ALLOWED_RESOLUTIONS` 的自检(QA 2.4「自证循环」)

`validate.test.ts` 原来用 `ALLOWED_RESOLUTIONS` 同时喂实现与断言:若白名单被误加
一条 `'1920x1080;rm -rf /'`,`isResolution(它)` 仍返回 true,用例照绿。新增两条:
1. **内容快照**:`expect(ALLOWED_RESOLUTIONS).toEqual([...12 条硬编码字面量])`
   —— 任何增删改都必须显式改快照,改动者会被迫看到「这是一份安全白名单」;
2. **结构不变式**:每一项必须匹配 `/^\d{3,5}x\d{3,5}$/`,不允许分号/空格/换行等
   任何额外字符(即使有人为了通过快照而同步改字面量,注入串也过不了这一条)。

## 验证

launcher:**28 文件 / 451 例全绿**(390 基线 + QA 68 例 − 重叠 0 + 新增 2 例:
E6 拆成 2 条、快照 1 条);typecheck 0 错;lint 0 error;format:check 通过
(QA 的文件本来就是 prettier-clean)。
server:**52/52 全绿**(43 基线 + QA 9 例)。
2026-10-26 01:59:35 +08:00
jkooe 9458ef9ce0 fix(security): D1 双端 device_name 白名单对齐(服务端收紧首字符)
QA 实测的错位:
  server/validate.js         DEVICE_NAME_PATTERN = /^[A-Za-z0-9._-]{1,64}$/        (首字符允许 . 和 -)
  launcher/src/core/validate.ts  SAFE_NAME_BODY = '[A-Za-z0-9][A-Za-z0-9._-]{0,63}' (首字符禁止)

后果(真实可复现):`heartbeat {device_name:"-dash"}` 服务端 200 接受、证书也能入库,
但 launcher 的 `syncPeerCerts()` 判定 `invalid_name` 后**永不落盘** ⇒ 该设备每次串流
都要手输 PIN,日志里只有一条 warn,现场几乎排查不出来。

修法(**只收紧服务端,不放宽客户端**):
- `server/validate.js`:`DEVICE_NAME_PATTERN` → `/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/`
  —— 与 launcher 的 `SAFE_NAME_RE` 首字符口径完全一致。首字符禁止 `-` 还顺带堵住
  `--flag` 形式的参数注入,禁止 `.` 则防 `.` / `..` 与隐藏文件。
  反过来放宽 launcher 会给参数注入开口子,明确禁止。
- `server/server.js`:heartbeat 与 certs 两处 400 的 `details.reason` 同步改成
  「需匹配 [A-Za-z0-9][A-Za-z0-9._-]{0,63}(首字符须为字母或数字)」——
  否则错误信息在描述一条已经不存在的规则(文案与实现必须同源)。

防复发(注释层面):
- `server/validate.js#isValidDeviceName` docstring 写明 D1 的动机;
- `launcher/src/core/validate.ts#isSafeName` docstring 明确标注「本函数首字符口径更宽,
  **不要**拿它校验 device_name 或磁盘文件名」,并指向两个规范实现
  (服务端 `isValidDeviceName` / 本文件 `isSafeFileName`)。

文档:`docs/api.md` 的 heartbeat 字段规则、400 `details.reason` 示例同步更新,并新增
「与客户端的一致性(D1 的教训)」说明「收紧方向只能是服务端对齐客户端」。

端到端实测(真实起服务,临时端口):
  heartbeat "-dash"      -> 400 VALIDATION_FAILED
  heartbeat ".hidden"    -> 400 VALIDATION_FAILED
  heartbeat "--flag"     -> 400 VALIDATION_FAILED
  heartbeat ".." / "."   -> 400 VALIDATION_FAILED
  heartbeat "dash-ok_1.0" / "a" -> 200
  certs     "-dash"      -> 400 VALIDATION_FAILED

server 测试不受影响:52/52 通过(无既有用例依赖「`-` 开头被接受」)。
2026-10-26 01:59:21 +08:00
jkooe e6541ab751 docs(T-4): API 契约 / 安全 / 排障 / README 同步(不改源码)
四份交付物,全部以代码为唯一依据逐条核对,未改动任何源码、测试或配置。

新增 `docs/api.md`(566 行)
- 端点清单与代码逐条对齐:`server.js` 共 **10 条路由注册 = 9 个逻辑端点 +
  `/api/health` 兼容别名**(复核命令已写进文档)
- 每个端点:方法/路径/鉴权/请求体字段(类型·必填·校验规则)/成功与失败响应/
  全部错误码/curl 示例
- 写清 6 个易错点:`/api` → `/api/v1` 的 **308**(含 `/api/health` 豁免与
  「中间件在 express.json 之前」)、access 2h / refresh 30d + jti 吊销、
  refresh **一次性轮换**、heartbeat 的 `device_id` 等 4 个字段「非法则忽略并保留
  上次值」的老数据兜底、重复注册 **409 USER_EXISTS**、安全响应头 + 64KB 请求体上限
- 错误码总表 **15 条**(与 `errors.js#ERROR_TABLE` 逐条对齐,附复核命令)+
  端点 × 错误码矩阵;并说明 `FORBIDDEN` / `INVALID_ARGUMENT` 当前无业务触发点
  (保留是为了客户端映射表 100% 覆盖)

新增 `docs/security.md`(283 行)
- 信任模型:云是盲协调器(数据面不上云、只存公钥),但**盲 ≠ 不校验** ——
  服务端强制自算 SHA-256(忽略客户端 fingerprint)+ 客户端下载后**必须本地重算比对**
- 私钥边界四层(发现/上传前/入库前/日志),双端同一规则
- `JWT_SECRET` 轮换 6 步(含影响面:所有 token 立刻失效)+ 准入条件
- 日志脱敏清单:四类输入(敏感键名 / 私钥块 / JWT 字面量 / 超长截断)+
  指纹只留前 8 位 + 递归深度与非预期类型兜底
- **三条真实踩坑**:① gitleaks 8.30.1 **会先 base64 解码再匹配**(所以 base64 存
  假密钥无效,切块会被 generic-api-key 打回)→ 正确姿势是「PEM 标记运行期拼装 +
  载荷 base64 单独成行 + import 期 SELF_CHECK」;② 测试造敏感载荷用
  `fakeSecrets.mjs`(服务端)/ `pemBlock()`+`fakeJwt()`(启动器);
  ③ `~/.gitignore_global` 的 `*.pem` 会让 fixture 静默进不了库 → 用 `.mjs` 导出
- **`TRUST_PROXY` 部署警告**:反向代理后不设 `TRUST_PROXY=1` 会让全团队共享一个
  IP 计数器(一人失败 5 次锁死所有人)
- 已知安全边界 8 项(双向证书、服务端证书区分、TLS 终止、refresh 绑设备、签名…)

新增 `docs/troubleshooting.md`(636 行,**15 条**四段式)
- 开头用醒目框写「**迁移备份只复制主 db 文件**」的未来 WAL 风险(第 10 条正文给出
  `VACUUM INTO` / `PRAGMA wal_checkpoint(TRUNCATE)` 两种改法)
- 覆盖:JWT_SECRET 被拒(含报错原文 + 生成命令)、`node:sqlite`
  `ExperimentalWarning`(正常)、`node --test <目录>` 报 Cannot find module、
  clone 后 fixture ENOENT、gitleaks 阻断、设备一直离线(90s 阈值 / 30s 心跳)、
  换地址后打到旧地址(设置项优先级 > 环境变量,308 只改路径不改 host)、
  限流自锁(含三条 SQL 解锁)、迁移失败回滚与手工恢复(备份只留 3 份)、
  WAL 备份风险、升级后同机两行设备(含合并 SQL)、USB `UNSUPPORTED_ON_PLATFORM`
  (附 8 位 × 3 端能力矩阵)、usbip 提权、SMB 打不开、Linux 边界

修改 `README.md`(92 → 310 行,R-P2-10)
- 阶段映射表全部换成与代码一致的状态(✅/⏳),**删掉所有过时 TODO**
- 补:Node **>=22.5(推荐 ≥24)**、`.env` 现在靠 `--env-file-if-exists=.env`
  真正加载(并说明旧 README 的「放好即可」为何不准确)
- 补:双端完整命令表(`smoke` / `lint` / `format:check` / `typecheck` /
  `test:coverage`)+ 覆盖率门禁口径与实测值(server **75.31%** / launcher **91.06%**)
- 补:平台能力矩阵、Linux 支持边界、P2 13 条状态表、文档索引
- 补:未签名应用首次打开提示(macOS 右键→打开 / Windows SmartScreen 仍要运行 +
  核对 SHA-256),避免用户误以为中毒
- 补:**如何新增平台分支**(能力表 + 实现文件 + 单向依赖 + 中文 reason +
  禁业务代码写 `process.platform` + `PlatformAdapter` 只增不改)

修改 `docs/packaging.md`(121 → 149 行)
- 按架构 §11.7 第 13 步新增「Linux 支持边界与未来纳入需补什么」9 条清单
  (README / security / troubleshooting 三处引用它,避免指向不存在的章节)

⚠️ 文档自身也遵守密钥扫描规则:写本文时实测撞了一次 gitleaks ——
`docs/security.md` 的 curl 示例与 `docs/troubleshooting.md` 引用的扫描输出里
原本含 PEM 字面量,已改成「用变量替换 / 占位描述」,**没有**加 `.gitleaksignore`、
**没有** `--no-verify`。

验证:文档内所有数字均为实测 —— 端点 10/9、错误码 15、troubleshooting 15 条、
smoke 13 步、server 43 例 / 75.31%、launcher 390 例 / 91.06%。
**未改动任何源码**(QA 的 `server/test/qa-security.test.mjs` 与
`launcher/test/qa-contract-e2e.test.ts` 未纳入本次提交)。
2026-10-26 01:40:02 +08:00
jkooe beeb181faa feat(T-5b): 登出 UI + 401 自动跳转 + 角色与依赖灯
第 8 步 R-P2-12 / 第 9 步 R-P2-13。

登出(R-P2-12)
- `main.ts` 抽出**唯一**登出用例 `doLogout(reason)`:用户手点与 401 自动触发共用,
  `performLogout` 本身幂等,两条入口重叠安全
- **补上架构师指出的 `auth:logged-out` 广播**:`{reason:'user'|'revoked'|'expired'}`。
  没有它时,被**后台心跳**踢下线(用户没点任何按钮)界面会永远停在主视图
- 登出后一并清空 `agent` / `certService` / `deviceCache` / 依赖缓存 / 令牌。
  只置 `agent = null` 会让 `devices:list` 的降级分支读到**上一个账号**的快照
- `api.ts` 统一出口识别会话失效:`TOKEN_REVOKED` → `revoked`;
  `TOKEN_EXPIRED` **且没有 refreshToken** → `expired`(有 refresh 时交给刷新流程)。
  带**闩锁**:一次会话失效只通知一次(心跳/设备/证书可能同时拿到 401),
  `clearTokens()` 重置闩锁以便下次登录后再被吊销仍能触发
- 无信封的 401(老网关直接透传)也能判定;回调抛异常不影响请求返回值

角色与依赖灯(R-P2-13,新增 `src/core/readiness.ts`)
- `deriveRole(caps)` 纯函数:双向 / 主控 / 被控 / 仅共享(experimental 也算可用)
- `buildReadiness(caps, status, inputs)` 纯函数:4 盏灯(sunshine / moonlight / cert /
  heartbeat),每盏带 `state` + `label` + `detail`
- **判定顺序先能力位、后依赖安装**:能力 `unsupported` 时直接给能力的中文原因,
  绝不显示「未安装」—— 否则 Linux 用户会去反复安装 Sunshine,而真实原因是我们
  本轮没做 Linux(§11.4.6 明令禁止的误导)
- 四盏灯互不污染:Sunshine 未装只让 sunshine 变灰,心跳该绿还是绿
- `readiness:get` IPC:依赖探测带 **30s 缓存**(`checkDependency` 会起子进程)
- UI 仅展示(角色文字 + 4 个灯),不提供切换(切换属 V2),renderer 里零推导逻辑

UI(原生 HTML/JS)
- 主界面右上角「退出登录」按钮 → `auth:logout`
- 订阅 `auth:logged-out` → 切回登录页 + 清空密码框 + 按 reason 给不同提示
- 角色与依赖卡片:状态点颜色由主进程下发的 `state` 决定

测试:新增 `test/readiness.test.ts`(12 例:角色四组合、降级不互相污染、
unsupported 不说"未安装"、依赖三态、证书 5 态、心跳 3 态)+ `test/api.test.ts`
追加 8 例会话失效(含闩锁与 clearTokens 重置)。全量 **390 例**通过,
行覆盖 **91.06%**(门禁 60%);server 43/43 无回归。
2026-10-26 01:30:14 +08:00
jkooe 8c2f231ed5 feat(T-5b): 设置页连接参数 + 三个死 IPC 接线
第 7 步 R-P2-11 / 架构 §11.4.4。

连接参数(新增 `src/core/connectSettings.ts`)
- `normalizeConnectSettings(raw)` 纯函数:**逐字段**白名单校验,非法字段回落默认
  并把问题列表回给 UI。不整份拒绝的理由:5 个控件里打错 1 个就把另外 4 个也丢掉,
  体验极差;但静默接受非法值又会在点「连接」时才失败
- 默认值与 `connect.ts` 的 `DEFAULT_CONNECT` 逐字段相同(非回归基准,有断言)
- 校验复用 `core/validate.ts` 的现有校验器(`isResolution` / `isFps` / `isBitrate` /
  `isCodec`),**不新增第二套正则**;因此实际范围是 24–240 fps / 1000–500000 kbps、
  分辨率白名单 12 项(比 §11.4.4 的 `\d{3,4}x\d{3,4}` 更严,见回传偏离说明)
- `toConnectOptions()` 把设置项转成 `connectToDevice` 的选项;`device:connect`
  已改为读设置(`{ ip, ...toConnectOptions(settings) }`)
- PRD 指定的 argv 断言已落地:写入 `2560x1440/120/80000/av1/fullscreen:false` →
  `buildMoonlightArgs` 逐项等于预期数组且**不含** `--fullscreen`

三个曾「留而死」的 IPC 现在接上(T-5A 只留字段、无调用点)
- `certs:pruneNow` → `certService.syncAndPrune()`,返回 `{ dir, synced, pruned }`;
  `certService` 提到模块级(与 AgentLoop 共用同一实例,不重复建)
- `credDir:resolve` → `resolveSunshineCredDir(settings.credDirOverride)`
- `credDir:setOverride` → 空串=清空回自动探测;非空先 `resolveSunshineCredDir(raw)`
  校验,**不存在则抛 `CRED_DIR_NOT_FOUND` 而不是静默回落**,通过才落盘
- 三个通道都有真实 renderer 调用点(无死通道):`preload.ts` 暴露
  `resolveCredDir` / `setCredDirOverride` / `pruneCertsNow`

UI(原生 HTML/JS,不引框架;`src/index.html` 按 §11.3.5 刻意不参与格式化)
- 连接参数 5 个控件(分辨率 input / 帧率 select / 码率 input / 编码器 select / 全屏)
- 凭据目录区:当前目录 + 来源(手动指定 / XDG 默认 / 平台默认 / 自动探测)+
  可写性 + 「重新探测」+ 手动指定输入 + 「立即清理陈旧证书」
- 凭据目录三种失败给**不同**文案(未安装 / 你指定的路径不存在 / 不可写),
  这正是 `CredDirResolution` 做成 discriminated union 的目的
- 清理结果文案区分「预演完成(未删除)」与「清理完成」,并列出被删文件名

测试:新增 `test/connectSettings.test.ts`(9 例)。全量 370 例通过,
行覆盖 90.9%(门禁 60%)。
2026-10-26 01:25:35 +08:00
jkooe b70556600a feat(T-5b): 托盘常驻与开机自启(默认关闭)
第 6 步 R-P2-04 / 架构 §11.4.1 + Q-7 + Q-13。

托盘(新增 `src/tray.ts`)
- **图标内联**:16×16 RGBA PNG(105 字节)直接以 data URL 嵌在源码里。
  理由写进注释:仓库里没有二进制图标,`nativeImage.createFromPath('build/icon.png')`
  在开发机/CI 上必然失败,而"图标缺失就崩溃/托盘空白无提示"是不可接受的降级
- `createImage` 返回 null 或抛异常 → 用 `createEmpty()` 兜底并 warn,**托盘仍然创建**
  (仍有 tooltip 与菜单,用户至少能找到它);`createTray` 抛异常 → 返回空控制器,不崩
- `buildTrayMenu()` 纯函数:状态项(已上线/未上线)→ 显示/隐藏主窗口 → 退出,
  顺序固定(退出放最下防误点)
- `shouldHideOnClose(trayEnabled, isQuitting)` 纯函数固化 **Q-7**:
  默认「关窗即退出」,只有开了常驻才收进托盘;`isQuitting` 为真时永不拦截
- 菜单构建与 electron 调用分离:`createTrayController(deps, handlers)` 注入
  `createImage` / `createEmptyImage` / `createTray` / `buildMenu`,
  **不引入 electron mock** 即可全部单测

开机自启(新增 `src/core/autostart.ts`)
- macOS / Windows:`app.setLoginItemSettings({ openAtLogin, openAtHidden: true })`
- Linux(**Q-13**):写 `~/.config/autostart/onlyinone.desktop`,代码就绪、**未真机验收**
- `buildDesktopEntry()` 纯函数 + `quoteExecPath()`:按 Desktop Entry 规范给
  `Exec=` 加双引号并转义 `"` `\` 反引号 `$`;**相对路径直接抛 AppError**
  (autostart 用相对路径会静默失败,用户以为"开了没生效")
- `setAutoStart` 失败不抛(返回 `ok:false` + 原因),自启属体验项不能阻断设置页
- 启动时 `reconcileAutoStart()` 回读系统实际状态并**回写设置项**:
  用户在系统设置里手工关掉后,UI 不能继续显示"已开启"

设置页(本步只加两个开关 + 清理预演)
- `settings:get` / `settings:set` 新 IPC:只认 boolean,落盘与副作用同一个 handler
  (避免"设置存了但托盘没建"的半生效状态)
- `preload.ts` 暴露 `getPrefs` / `setPrefs`,renderer 增加「设置」卡片(3 个复选框 + 保存)
- `settings.ts` 新增 `trayEnabled?` / `autoStart?`(默认 false)

测试:`test/tray.test.ts`(9 例:Q-7 四组合、菜单顺序与文案、点击不串台、
图标缺失/解码失败/托盘创建失败三条兜底、update/destroy)+ `test/autostart.test.ts`
(16 例:Exec 转义、.desktop 内容、三端分支、失败不抛、回读校准)。全量 361 例通过。

⚠️ 托盘与自启的**真实行为**(图标外观、菜单点击、系统登录项、Linux autostart)
只能人工验收,步骤见回传。
2026-10-26 01:21:59 +08:00
jkooe 335461e89d feat(T-5b): 主进程健壮性(单实例 / 窗口状态 / 崩溃兜底)
第 5 步 R-P2-06 / 架构 §11.4.3。

单实例
- `app.requestSingleInstanceLock()` 放在模块顶层(必须在 `whenReady` 之前);
  第二实例 `app.quit()`(用 quit 不用 exit,让 before-quit 清理有机会跑)
- `second-instance` → `restore()` + `show()` + `focus()`,用户点第二次图标
  看到的是已有窗口,而不是"什么都没发生"

窗口状态持久化
- 新增 `core/windowState.ts`,四个纯函数:`restoreWindowBounds()` /
  `isVisibleOnAnyDisplay()` / `toSavableBounds()` / 常量(默认 960x680、最小 720x520)
- 判定用 **workArea**(不含菜单栏/任务栏)而非 bounds;"可见"要求窗口至少
  160x80 落在某块屏内 —— 只判左上角会让"只露 1px"也算可见,用户实际抓不到标题栏
- 拔掉外接屏后坐标仍在屏外 → 回到主屏居中(这是 Electron 应用最常见的
  "启动器打不开了"幽灵 bug)
- 写盘节流:`resize`/`move` 防抖 500ms,`close` 与 `before-quit` 同步各写一次
  (避免防抖窗口内退出丢状态);写失败只 warn,不影响启动/退出
- `settings.ts` 新增 `window?: WindowBounds` 与导出类型

崩溃兜底(Q-14 定案)
- `uncaughtException` → 落日志 → `dialog.showErrorBox` → `app.exit(1)`:
  半初始化进程继续运行比崩溃更难排查
- `unhandledRejection` → **只落日志不退出**:本项目大量 Promise 是尽力而为的
  (心跳 / 证书写入 / 托盘图标),全部升级为致命错误反而制造线上不可用
- `installCrashGuards()` 在 `whenReady` 之前调用,初始化阶段的异常同样被兜住

测试:新增 `test/windowState.test.ts`(13 例)——无持久化值居中、屏幕外回落、
多屏原样、小于最小值夹紧、大于屏幕夹紧、坏数据/半个坐标、headless 无显示器、
最小可见面积、`toSavableBounds` 只留 4 个数字。全量 336 例通过。

⚠️ 本步的 `main.ts` 改动(单实例 / 窗口 / 弹窗)**只能人工验收**,步骤见回传。
2026-10-26 01:16:58 +08:00
jkooe 81316e3395 chore: 新增 .git-blame-ignore-revs(跳过 prettier 全量重排的 blame) 2026-10-26 01:11:08 +08:00
jkooe e08f9d1de9 style(T-5a): prettier 全量格式化(无行为变更)
按 `launcher/.prettierrc`(printWidth 120 / trailingComma all / singleQuote true)对
`src/**/*.{ts,js}`、`test/**/*.ts`、`*.{mjs,json,yml}` 做一次性重排。

为什么单独一个 commit(架构 §11.3.7):
- 重排会把真实逻辑改动完全淹没,混进功能 commit 后 review 等于不可读;
- 单独成 commit 才能把 SHA 写进 `.git-blame-ignore-revs`,让 `git blame`
  与 GitHub 都跳过这一次(否则每一行的 blame 都会指向"格式化");
- 重排前已跑过全绿测试(323 例)+ typecheck + lint,重排后再跑一遍:
  **format:check / typecheck / lint / vitest 四道关卡全绿**,可确认零行为变更。

刻意**不**格式化的唯一源文件是 `src/index.html`(`.prettierignore` 排除,
原因写在 `docs/packaging.md`:内联手写 CSS + 单行 `:root` 变量表,Prettier 会把
它重排成数百行)。不做"分文件逐个重排"、也不做"只对新代码生效"——中间态与
永久红门禁都比不格式化更糟。
2026-10-26 01:10:57 +08:00
jkooe 8ced7243e3 test(T-5a): 补 api/config/settings 覆盖率(顺带修出 normalizeBaseUrl 的真实缺陷)
覆盖率前后对比(行覆盖)
| 文件 | 之前 | 之后 |
|---|---|---|
| `src/api.ts` | 0% | **100%** |
| `src/core/config.ts` | 0% | **100%** |
| `src/settings.ts` | 63.33% | **96.61%** |
| 全局 | 82.12% | **90.35%** |

新增 `test/api.test.ts`(23 例)——直测注入的 `fetchImpl`:
- 8s 超时:fake timer 推进到 7999ms 不 abort、8000ms 后 `signal.aborted === true`
  且返回 `NETWORK_UNREACHABLE` + `details.reason='timeout'`;
  请求提前返回时 fake timer 计数归零(不留下会误 abort 后续请求的定时器)
- 统一信封:`{ok:true,data}` / `{ok:false,error}` / 无信封 2xx 包一层 / 非 JSON
  (网关 HTML)回落成状态码映射
- `TOKEN_EXPIRED` / `TOKEN_REVOKED` 原样透传(401 不被压成 `UNAUTHORIZED`);
  409 无信封 → `USER_EXISTS`
- 令牌:login 保存双令牌、refresh 一次性轮换、无 refresh 时直接 UNAUTHORIZED、
  logout 无论成败都清空;`uploadCert` 请求体**不含 fingerprint**
- 请求构造:baseUrl 末尾多余斜杠被去掉、未登录不带 Authorization

新增 `test/config.test.ts`(14 例):`normalizeBaseUrl` 全分支、`resolveBaseUrl`
优先级(设置项 > 环境变量 > 默认)、`setBaseUrl` 非法输入不写盘、`isDeviceId`、
`ensureDeviceId` 对坏值重生成、`getConfigSnapshot` 返回副本。

🐛 **修掉一个真实缺陷(新用例第一次跑就暴露的)**
`normalizeBaseUrl('ftp://nope')` 原来会走"补协议"分支变成 `http://ftp//nope` ——
把协议名当成了主机名,静默把一个用户没输入的地址写进设置项。修复方式:
只有"带 `//` 且非 http(s)"的形态才判为外部协议并拒绝;`localhost:8787` 这种
"冒号是端口"的输入仍然正常补 `http://`。两条边界都补了对应用例。

(`test/settings.test.ts` 的 10 例已在 settings DI 那次 commit 落下,不重复计入。)
2026-10-26 01:09:55 +08:00
jkooe d13de09545 chore(T-5a): 引入 ESLint + Prettier 与 typecheck 统一
R-P2-07(以架构 §11.3.4 为准,§8.1 的"不启用类型感知"是过时表述)
- 新增 `launcher/eslint.config.mjs`(ESLint 9 flat config,`.mjs` 后缀:
  package.json 无 `"type": "module"`,`.js` 会被当 CJS)
- 类型感知**只开 3 条**规则(no-floating-promises / no-misused-promises /
  await-thenable);`no-unsafe-*` 那一族不启用。其余用非类型感知的 recommended
- 把三条架构约定固化成 `no-restricted-syntax`:
  · 非 config/platform 禁读 `process.env`(§9.4)
  · 非 platform 禁判 `process.platform`(§9.5)
  · 禁裸 `new Error`,一律 AppError(§9.2)
  白名单块**不是**整体 off:`src/core/config.ts` 与 `src/platform/**` 仍禁 `new Error`;
  `src/core/runner.ts` 只放行 env 选择器(它要把父进程环境透传子进程,不是读配置)
- 新增 `.prettierrc`(`printWidth: 120`、`trailingComma: "all"`)与 `.prettierignore`
  (刻意排除 `src/index.html`,原因写进 `docs/packaging.md`)
- 新增 `tsconfig.typecheck.json` 覆盖 `src/**/*` + `test/**/*`;
  **必须显式 `"rootDir": "."`**,否则继承来的 `rootDir: "src"` 会让 test 报 TS6059
- 脚本:新增 `typecheck` / `lint` / `lint:fix` / `format` / `format:check`,
  删除过时的 `typecheck:core`
- 依赖:`eslint@^9` / `typescript-eslint@^8` / `eslint-config-prettier@^9` /
  `prettier@^3`(**未装** `eslint-plugin-import` / `-n` / `@stylistic` / `globals`)

lint 耗时实测(决定是否拆 lint:ci):首次 9.07s、二次 7.28s,均 < 10s → **不拆**。
记录在 `docs/packaging.md`「代码规范与静态检查」。

顺带修掉的问题(都是新规则第一次跑出来的真问题,不是降级规则)
- `src/api.ts`:删除已无引用的 `bareError()`(上一个 commit 删 listCerts 后的遗留)
- `src/main.ts`:`mainWindow.loadFile()` 与 `app.whenReady().then()` 补 `void`
  (no-floating-promises 命中;这两个 Promise 失败与否不影响主流程)
- `src/core/paths.ts`:eslint-disable 的规则名换成 v8 的 `no-require-imports`
- 4 处控制字符正则(config/runner/validate/shared/share)按"故意为之"处理:
  `src/**/*.ts` 关闭 `no-control-regex` 并写明理由(§9.1 先剔控制字符再匹配)
- 测试文件历史类型错误 6 处:`as unknown as` 显式跨类型 + 注释,未放宽 tsconfig

CI:类型检查改用 `npm run typecheck`(覆盖 test),并新增 `npm run lint` 与
`npm run format:check` 两个门禁步骤。

注意:本 commit 后 `format:check` 仍会红 —— 全量重排是**下一个独立 commit**
(便于 `.git-blame-ignore-revs` 精确指向它,也是架构 §11.3.7 的硬要求)。
2026-10-26 01:07:38 +08:00
jkooe a356ba8260 refactor(T-5a): settings 去嗅探改 DI,electron 加载点收敛到 core/paths.ts
R-P2-05 / 架构 §12.4 R5:验收口径是「`require('electron')` 全仓仅出现在
`core/paths.ts`」,因此这一步比"只改 settings.ts"多做了两件事:

settings.ts
- 新增 `createSettingsIo({ paths, fsImpl })`:只接收 `PathResolver`(`userData()`)
  与一个 3 方法的 fs 实现,不再嗅探 electron、不再读环境变量
- 保持既有函数式 API(`loadSettings` / `saveSettings` / `getServerUrl` /
  `setServerUrl`)不变,新增 `patchSettings` / `defaultSettingsIo` /
  `resetDefaultSettingsIo`;默认 io 延迟构建,避免 import 期就去问 Electron
- 删掉 `setServerUrl` 里顺手写环境变量的一行:`resolveBaseUrl()` 本来就优先读
  设置项,那行既冗余又让本文件成了第二个"读环境"的地方

core/paths.ts
- 新增 `loadElectron()` + `ElectronLike`:全仓**唯一**的 electron 加载点
  (延迟 require + try/catch),`electronUserDataDir()` 改为走它

platform
- `credentialStore.ts#loadSafeStorageLazily()` 与 `shared.ts#resolveScriptsDir()`
  改调 `loadElectron()`,同时把 safeStorage 的成员校验补全(缺 encryptString /
  decryptString 时也降级)

core/config.ts
- 新增 `createConfigStore(io)` 便于注入;默认仍用 `defaultSettingsIo()`

测试:新增 `test/settings.test.ts`(10 例)——真实临时目录往返、坏 JSON 兜底、
patch 合并、内存 fs、deviceId 只生成一次、以及两条 grep 守卫
(settings.ts 无 require/无 process.env;全仓仅 core/paths.ts 含 electron 加载)。
全量 286 例通过。
2026-10-26 01:02:59 +08:00
jkooe 0e73f2bd99 feat(T-5a): 凭据目录平台探测与陈旧证书清理
R-P2-02(凭据目录探测)
- 新增 `platform/credDir.ts`:`credDirCandidates()` 是纯函数(三端 × 每种布局
  100% 可单测,不碰 fs、不读 process.platform);`resolveSunshineCredDir()`
  返回 discriminated union 而非 null(区分「没装」「你填的路径不存在」
  「不可写」三种必须不同处置的情形)
- 4 级优先级;override 不存在直接报 `override_not_found`,**不静默回落**;
  `writable: false` 仍然 `ok: true`(macOS TCC 授权后可恢复)
- 三端候选清单按架构 §11.2.4,逐条标注是否需真机验证
- `coarseSunshineCredDir` / `homeDir` 迁入 credDir.ts(写入与探测共用一份)

R-P2-01(陈旧证书清理)
- 白名单收敛到 `core/validate.ts`:`SAFE_NAME_RE` / `PEER_CERT_NAME_RE` /
  `peerCertFileName()` / `parsePeerCertFileName()` / `safeJoin()`,
  **写入白名单与删除白名单由同一段字符集派生**
- 新增 `pruneStalePeerCerts()`:四层误删防护(严格命名 / 必须解析出设备名 /
  只处理普通文件(lstat 不跟随符号链接)/ 空集合整体不删 + 60s 宽限期)
- 新增 `syncAndPrune()`:同步与清理**共用同一次 `GET /api/v1/certs` 快照**,
  杜绝「刚写入就被删、下一轮又写入」的抖动
- `CertService` 新增 `syncAndPrune()`;`createCertService` 增加可选
  `resolveCredDir` / `getPruneDryRun`(一次 tick 只解析一次目录)
- dry-run 双轨:debug 影子日志(始终开启)+ 设置项 `pruneDryRun`

清理
- 删除 `connect.ts` 的 `sunshineCredDir()` 与 @deprecated `syncPeerCerts()` 垫片
- 删除 `api.ts` 的旧 `listCerts(token)`、删除 `test/core.test.ts`(用例已迁至
  `credDir.test.ts` / `certs.test.ts` / `discover.test.ts`)
- `agent.ts`:解析失败只写 `selfCertState`(sunshine_not_found /
  override_not_found),**心跳与设备列表照常**

测试:credDir 20 例 + certs 清理 11 例 + agent 降级 4 例;全量 276 例通过,
行覆盖 82.12%(门禁 60%)。
2026-10-26 00:59:52 +08:00
jkooe 5023364883 refactor(T-3): makeSafeHandler 对齐架构师签收口径((handler, logger) 两参形态)
§12.4 R4 给的签名是 `makeSafeHandler(handler, logger)`,而我第一版写成了
`(handler, {channel, logger})`(为了把通道名带进日志)。现在两种形态都支持:

- `makeSafeHandler(handler, logger)`              ← 签收口径
- `makeSafeHandler(handler, {channel, logger})`   ← 需要通道名时

实现上用 `normalizeHandlerOptions()` 按「有可调用 error() 且有 module 名」
区分 logger 与 options,避免调用方改签名。

另新增一例单测锁定两参形态(成功 + AppError 精确码 + 日志落错误码),
并把 main.ts 的 wrapIpc 压成真正的单行胶水调用。
2026-10-26 00:48:06 +08:00
jkooe 0bb2a574c3 docs: 架构评审 v2(含 P2 全量补全设计与 T-2 落地复核记录)
- 架构评审结论、架构视角问题清单(G-xx / A-xx)
- 目标架构模块图与 4 条关键时序图
- v1 API 契约、跨平台 PlatformAdapter 抽象、65 项文件清单
- T-1 ~ T-5 有序任务分解与依赖图
- 依赖包清单(运行时 0 新增 / 开发期 5 个)
- §11 P2 全量补全设计、§12 派工清单与 T-2 复核决定 R1~R5
2026-10-26 00:48:05 +08:00
jkooe 3958eb3383 fix(T-3): makeSafeHandler 兼容 logger 与 options 两种调用形态
- 新增 normalizeHandlerOptions,同时支持 (handler, logger) 与 (handler, {channel, logger})
- 补 26 行测试覆盖两种形态与 logger 自身抛错场景
2026-10-26 00:48:04 +08:00
jkooe 531991954c feat(T-3): 多端适配与工程质量 —— PlatformAdapter 三端实现/命令注入面清零/发现并发限流/CI 门禁
1. platform:三端完整实现。windows.ts / macos.ts / linux.ts 各自导出能力表与
   usb/share/discovery 实现,共用设施抽到 shared.ts(单向依赖,无循环)。
   Linux 是**显式降级实现类**(usb=null + 每条能力带中文 reason),不是 else 兜底。
2. usb.ts:退化为薄分派层,清掉 5 处 exec 拼串,改走 runner(参数数组 +
   shell:false);新增 listAttached();parseUsbPort / parseUsbipdList 纯函数,
   usb===null 时抛 UNSUPPORTED_ON_PLATFORM(不再把 ENOENT 抛给 UI)。
3. share.ts:薄分派 + 本机共享开关。Windows 接 scripts/setup-smb.ps1
   (ValidatePattern 白名单 + 绝对路径校验 + -EncryptData $true),
   macOS/Linux 明确 UNSUPPORTED_ON_PLATFORM + 引导文案,绝不静默成功。
4. discover.ts:候选 IP 剔除 .0/.255/网关/本机并按 /24 全局去重(单网卡 252);
   mapWithConcurrency 真实并发上限 64;mDNS 分派改由 capabilities().mdns 驱动,
   不再判断平台;解析器 parseDnsSd / parseAvahiBrowse 纯函数化。
5. 依赖预检:probeDependency 支持 ok/missing/outdated + hintUrl,最低主版本来自
   DEPENDENCY_MIN_MAJOR;core/logger.ts 补文件轮转与脱敏测试(4 类敏感输入)。
6. CI:.github/workflows/ci.yml —— typecheck → 双端测试 → 覆盖率门禁 →
   smoke → 残留进程检查;launcher 用 vitest.config.ts thresholds(全局 60%,
   核心 connect/discover/usb/agent/share/platform/* ≥70%),server 用 Node 22
   内置 --test-coverage-lines=60,低于阈值直接 fail。
7. UI:能力驱动显隐(USB/共享面板按 capabilities() 隐藏并显示原因),
   renderer 内无任何平台判断;IPC 错误边界抽成 core/ipc.ts 的 makeSafeHandler()
   以便 vitest 直测(§12.4 R4),main.ts 的 wrapIpc 退化为 3 行调用。

测试:launcher 245 例全绿(原 143 例非回归 + 102 新增),server 43 例全绿;
tsc --noEmit 0 错误;launcher 行覆盖 82.56%,server 75.31%(门禁 60%);
gitleaks pre-commit 通过。
2026-10-26 00:45:17 +08:00
jkooe c5dfa4a948 feat(T-2): 功能闭环 —— 命令注入防护/证书双向链路/非阻塞连接/周期心跳
1. runner(新):外部进程唯一出口。execFile/spawn/elevate 全部 shell:false + 参数
   数组 + 可执行文件名白名单 + 控制字符校验 + timeout 双保险;macOS 提权走
   osascript,命令先 shell 单引号包裹、再 AppleScript 字符串转义(appleScriptQuote)。
2. api:全部端点迁 /api/v1,8s AbortController 超时,统一 ApiResult 信封解析;
   deviceId 首次生成并持久化(core/config.ts ensureDeviceId),路径解析可注入
   (core/paths.ts,禁止 process.cwd)。
3. platform:types.ts(§4.6 全部接口)+ index.ts 骨架(detectPlatform /
   capabilitiesFor / coarseSunshineCredDir / moonlightCertPaths / checkDependency)
   + credentialStore.ts(safeStorage 加密落盘,不可用自动降级内存且不抛)。
4. certs(新):本机公钥证书发现并上传(补齐 v1 只下不上的闭环缺口)+ peer 证书
   客户端独立重算指纹 + 文件名白名单 + path.resolve 内敛断言 + tmp→rename 原子写;
   EACCES 映射 CRED_DIR_NO_PERMISSION。不含 prune(T-5)。
5. connect:改为 spawn + detached + 1.5s 存活判定,≤3s 返回;参数全部白名单。
6. devices(新):在线三态(online/recent/stale,含 clockOffset 时钟校正)+ 本地
   缓存 HMAC 完整性校验,仅 CredentialStore.kind==='os' 时落盘。
7. agent:AgentLoop 30s±10% 周期心跳 + 连续 2 次失败降级(定时器不停,可自愈)
   + tick 内异常就地 catch 不冒泡(Q-14)。
8. main/renderer/preload:wrapIpc 统一错误边界(renderer 永不收原始 message)、
   renderer 全面去 innerHTML(textContent + createElement)、三段式错误 + Toast、
   在线三态点;IPC 入参二次校验(ip/busid/shareName)。
9. session(新):performLogout 内部链路(停心跳→服务端登出→清凭据→清缓存),
   任一步失败只 warn 不中断;不含 UI 入口与 401 自动跳转(T-5 第 8 步)。

测试:launcher 143 例全绿(原 26 例非回归 + 117 新增),server 43 例仍全绿;
tsc --noEmit 0 错误;gitleaks pre-commit 通过。
2026-10-26 00:23:59 +08:00
jkooe 7ddd344d7f chore: 初始化仓库并落地 T-1 批次1(安全地基与仓库卫生)
- 新增 .gitignore,排除 node_modules / dist / release / server/data 数据库与备份 / .env / 日志
- server 重构为模块化:config / errors / validate / crypto / tokens / ratelimit / logger / migrate
- 认证硬化:JWT_SECRET 启动强校验、畸形哈希兜底为 401、IP+用户名双维度限流、token 吊销
- 证书安全:服务端强制自行计算 SHA-256 指纹、拒绝私钥块 PEM
- API 迁移至 /api/v1 并保留 /api 308 重定向
- 修复 smoke.mjs 因入口判定导致必然连接失败的缺陷
- 测试夹具改为运行期拼装 + base64,规避 gitleaks 误报与全局 *.pem 忽略规则
- 测试:server 43 例 + launcher 26 例全绿,server 行覆盖 74.77%
2026-10-26 00:10:01 +08:00