V2/init #1

Merged
mtfyun merged 110 commits from v2/init into main 2026-11-08 20:28:44 +08:00
Owner
No description provided.
mtfyun added 108 commits 2026-11-08 20:04:57 +08:00
从主干(1.0.9)长出,**只新增、不改动主干**。本提交带过来的是
已交付的水墨屏看板 1.1.0 那批改动(下一步会在此基础上做横屏与统一字号):

  · ui/eink_board.dart     单台设备大字看板(6 大格、一屏放下)
  · ui/eink_mode.dart      全局零动画开关 + 静止等待指示
  · ui/theme.dart          PmonEink 三档明度令牌 + statusGlyph 形状化状态
  · host/app_config.dart   einkMode / einkRefreshSec(默认关,不影响彩色版)
  · 平台通道               setKeepScreenOn(副屏常亮)
  · 刷新节流               数据每秒到,只在间隔到点重建

之后本分支会继续做:横屏为默认、竖屏可切、隐藏次要信息、统一字体与样式。
主干(main)不含以上任何代码。
<user> 2026-09-16 的四条要求,逐条落地并各有可执行断言:

① 横屏为默认、可切竖屏
   · 新增配置 einkLandscape(默认 true);看板里的「转屏」按钮切换并落盘
   · 屏幕方向由宿主锁(SystemChrome),看板只发指令、保持"纯展示"以利测试
   · 列数按**可用空间宽高比**决定(宽→3 列 / 高→2 列),不写死方向 ——
     Flutter 只拿得到一个矩形,分屏/折叠/多窗口下方向与矩形并不一致

② 聚焦核心数据、隐藏次要信息与冗余控件
   · 在线时顶部只有「实心圆 + 设备名」:形状已表达在线,
     "在线"二字与"数据新鲜"都是冗余;一旦离线/疑似/未知才把状态与原因铺出来
   · 底部两行说明合并为一行("最后刷新 xx" + "每 N 秒自动刷新" → "xx · 每 N 秒")

③ 对比度与刷新
   · 延续零动画(静止等待图标、NoSplash、无无限动画),有源码结构断言守着
   · 支撑"永不溢出":横屏/竖屏/极窄/极矮/极宽多套尺寸全部覆盖

④ 统一字体与样式(避免字号大小不一)
   · PmonEinkText 收敛为 5 档(label 与 action 同号,靠字重而非字号区分);
     界面里**不得写就地字号**,有源码断言(`fontSize: 数字` 即红)
   · 数值改为「大数值 + 小单位」:此前 `1.42 Mb/s` 整串按 40px 渲染宽 360px,
     而 3 列时每格仅 207px → 统一比例被拖到 0.575,**一屏数值全被它压小**
   · 全屏数值共用**同一个**缩放比例(按最长那条算);
     并改用**实测字形二次收敛**替代纯线性估算(渲染期文本宽度非线性,
     实测在极窄格 + 极长值时溢出 3.7~9.3px);最外层留一层 FittedBox 兜底,
     保证"绝不裁掉内容"(正常尺寸下它不生效,同号不受影响)
   · 数值加**无损紧凑化**(`310.00` → `310`):只删数学上无信息的零尾,绝不四舍五入

实测(F50 代跑):
  · 横屏 3 列 × 2 行、大数值 + 小单位、数值明显大于标签 —— 见 dist/水墨屏走查/03
  · 竖屏**未能在 F50 干净验证**(它的 wm size/density 覆盖 + 强制旋转使 Flutter 视口
    变成近正方形,于是按设计走了 3 列)。竖屏布局由 widget 测试覆盖
    (536×724 → 2 列),待真机(Neo2)上机复核。

测试:客户端 321/321;其中 eink_board_test 32 项。
变异测试:把"统一缩放"退回"逐格 FittedBox"会被"渲染高度一致"那条抓住。

主干 main 不受影响 —— 本分支相对 main 只新增、未修改任何主干代码。
配合项目定名「墨观 InkWatch」:

- client/README.md 的发布步骤改为输出 inkwatch-arm64-*.apk,
  并标注「包名与 FFI 符号仍是 pmon」(两件事,见 docs/rename-inkwatch.md)
- pubspec 版本 1.1.1+11 → 1.1.2+12

真机核验(F50):

- 界面标题「墨观 采集端」;APK 内 AndroidManifest 含「墨观」、不含 PMon
- 因未改 applicationId,覆盖安装成功 —— 本地配置与会话令牌原样保留。
  若改了包名,这一步会变成「装成另一个 App」,要重装并重新认领

生产已同步:~/pmon → ~/inkwatch。容器名与卷名(pmon_pg_data)均未变,
4 个容器 Up 18 hours healthy;从新路径 compose 照常认得 ——
这正是「只改显示名、不动 compose project name」的价值所在。
脱敏改坏了一处断言:device-identity-test 的「大小写归一」用例
  输入 deviceTag('android-ABC123')(大写,未被替换)
  期望 'abc123'(小写,已被替换)
→ 输入与期望指向了不同的标识,必然失败。改为输入 ABC123 / 期望 abc123,
  两者同指一个标识,语义(大写入、小写出)不变。

⚠️ 教训:**字符串级脱敏必然改坏成对出现的断言**(输入与期望、正例与反例)。
   脱敏后必须跑全套测试,不能只看 diff 顺眼就推。

同时把上一轮只匹配小写而漏掉的**机型编号大写变体**(25042PN24C / 24069RA21C)
一并脱敏为 MODEL-A1 / MODEL-B2 —— 型号本身是公开信息,但与设备 ID 后缀
组合起来足以指认具体设备,故一并替换。
上一轮 git add -A 时把 filter-branch 的临时目录一并提交了 ——
它里面存着**改写前的提交信息与临时工作树**,等于把脱敏前的原文又带回了仓库。

本次:删除该目录、写入 .gitignore(防止再犯),随后用它重写全部历史。
上一轮 git add -A 时把 filter-branch 的临时目录一并提交了 ——
它里面存着**改写前的提交信息与临时工作树**,等于把脱敏前的原文又带回了仓库。

本次:删除该目录、写入 .gitignore(防止再犯),随后用它重写全部历史。
首次推送前的人工审计一次扫出六类个人数据,而且**反复漏**:
  · 第一轮漏了机型编号的**大写变体**(规则只写了小写)
  · 第二轮漏了**裸用户名**(规则只写了长形式 xxxdeMacBook-Pro-2.local)
  · 第三轮漏了真实账号邮箱 —— 因为臣用 head -10 截断了扫描输出,
    前面十条全是测试假邮箱,真邮箱被挤到看不见
三次都是"扫全之后回看"才发现的。人工审计靠不住,固化成脚本。

判据分两档(关键设计):
  · FAIL:高置信(公网 IP / .cn 域名 / /Users/<名字> / SSH 私钥路径 /
    私钥块 / 手机号 / 疑似真实密钥赋值)
  · WARN:需人判断(邮箱 —— 测试夹具与真实邮箱长得一样)。
    只列不判红,避免假阳性变成噪音、进而被整体忽略。
白名单刻意**窄**:Flutter 官方中国镜像域名、明显合成的口令值、单字母域名的假邮箱。

扫描范围:工作树(被跟踪文件)+ **全部提交** + 全部提交信息 ——
只扫工作树是不够的,`git log -p` 照样能把脱敏前的原文翻出来。

本脚本**不内置任何真实值**(内置就等于把要防的东西写进仓库);
项目特定敏感串写到本机的 ~/.config/inkwatch/privacy-denylist.txt(不入库)。

用法:node tests/privacy-scan.js(全量) / --worktree-only(快,只扫工作树)
改的是什么:client/android/app/build.gradle.kts 此前是 Flutter 模板原样 ——
release 一直用 signingConfigs.getByName("debug"),即公开的 AOSP 测试密钥。
三条后果且都不报错:
  ① 任何人都能签一个同签名的包冒充升级
  ② 无法上架(Play 拒绝 debug 签名)
  ③ 换正式密钥后,已装设备无法覆盖升级(不可逆)

怎么改:
- 新增 signingConfigs.release,凭据从 client/android/key.properties 读
  (该文件已在 .gitignore 中,确认 check-ignore 命中)
- buildTypes.release 指向它
- task-graph 守卫:release 构建缺 key.properties 时**明确失败**并打印修复指引
  刻意不学模板「回落 debug 签名」—— 那是一条「看着构建成功、其实签的是公开密钥」
  的静默失败路径。本仓库多次栽在静默失败上(迁移从不执行、首次部署路径无人测),
  故这里宁可让它红。

验证:
- 变异:移走 key.properties → release 构建 2 秒内失败并给出指引
- 放回 → 构建成功;apksigner 确认 DN=CN=InkWatch,指纹与 keystore 一致
  SHA-256 0d58f8b5749b6a60c8042f6f4333a0c847192bf37d44e56cdd4756f3970ad679

密钥:~/keystores/inkwatch-release.jks(仓库外,PKCS12/RSA2048,有效期至 2054),
凭据记录在同目录一个 600 权限的文件里。
⚠️ 这是本仓库唯一不可再生的东西 —— 丢了就永远无法更新已发布的应用。

新增 docs/release-and-signing.md:密钥信息、key.properties 契约、出包命令、
两个包名的分工、换签名的后果与恢复步骤。
为什么必须独立:两条分支的 versionCode 各自递增(通用版 1.0.9+9 / 水墨屏版 +13)。
共用一个 applicationId 会互相覆盖,且 versionCode 小的那个会被系统当成**降级**
而拒绝安装 —— 两个想并行的版本根本装不到同一台手机上。

改为独立包名后:两版可共存、各自升级,且 data 目录独立(配置互不干扰)。

这是本分支与主干**唯一有意的差异** —— 已在 gradle 里写明「合并主干时不要合回去」。
namespace(Kotlin 包名)保持 com.pmon.pmon_client 不动:它只影响 R 类与源码包声明,
与「装成哪个应用」无关,动它要大改 Kotlin 源码且无收益。

版本 1.1.2+12 → 1.1.3+13。
为什么单独写:看板版面(列数、字号、格高)全部取决于设备的**逻辑尺寸 dp**,
而 6″/300PPI 且系统 density 由厂商设定 —— 1072×1448 在 density 2.0 下是 536×724dp,
若厂商设成 240 就是 715×965dp,版面完全不是一回事。不查就不知道。

脚本做三件事:
  1. 按机型名(Neo/iReader)**精确挑设备**,避免误操作到 F50 上
  2. 打印机型/Android 版本/内存/ABI + 物理与覆写尺寸、density,
     并换算出逻辑 dp 与"该走几列"的预期
  3. 找到设备却无法连接时,直接打印开启 USB 调试的排查步骤

用法:bash client/scripts/eink-device-probe.sh [APK路径]

首跑实测:Neo2 在 adb 不可见(见提交信息附的 USB 描述符证据),
脚本按设计输出了排查步骤。
背景:Neo2 的 USB 调试开不了(已用 locationID 关联确认它只暴露掌阅自己的
255/255/0 传输接口,全系统唯一的 ADB 接口属于 F50)。而看板版面
(列数、字号、格高)**完全取决于逻辑尺寸 dp** —— 拿不到 wm size/density 就是瞎调。

于是把自检做进应用:把关键数据**直接画在屏幕上**,一张截图即可取数。

自检页内容:
  · 展示层自有(MediaQuery):逻辑尺寸 dp、devicePixelRatio、物理像素、方向、
    由尺寸推出的看板列数
  · 宿主提供(viewer_page):刷新间隔、已收帧数、本会话断点、最后刷新时刻、常驻内存

设计要点:
  · **静态文本表**,无动画无滚动 —— 与看板同一套墨水屏约束
  · 打开时**不显示数值网格**(一屏只放一件事)
  · 底部按钮在「自检 / 返回」间切换,仅在宿主提供回调时出现
  · 用 Expanded 等分行高,多尺寸不溢出

测试:eink_board_test 32 → 39 项(新增 7 项),客户端 321 → 328/328。
本文件此前有四处范围缺陷,每一处都是"范围悄悄变小、而输出仍然是绿的":

① 受 cwd 约束。脚本内 `git ls-files` 与 `git grep <rev> -- .` 都相对当前目录,
   而 run-all.sh:27 正是 `cd "$(dirname "$0")"` → 实际只覆盖 tests/**
   (实测 26 个文件 vs 从仓库根跑的 193 个,少 86%)。
   client/ server/ shared/ docs/ deploy/ 全在盲区,而真实环境指纹恰好都在那里。
   → 开跑先 `git rev-parse --show-toplevel`,所有 git 调用带 cwd=ROOT。

② 非 ASCII 路径。`git ls-files` 默认把路径加引号转义输出
   ("docs/xxx-\346\236\266.html"),照原样 readFileSync 必然打不开 → 被 catch 跳过。
   本仓库两个中文名文件长期不在扫描范围内。
   → 所有 git 调用带 `-c core.quotepath=false`。

③ 未跟踪文件。只扫 `ls-files`(纯受跟踪)会漏掉"刚写好、还没 add"的新文件,
   而那正是下一次提交的原料。实测踩到:一个新写的 docs/*.md 带着设备代号与
   ROM 构建指纹躲过了脱敏。
   → 加 `--others --exclude-standard`(忽略项仍排除,不引入噪音)。

④ 浅克隆。`clone --depth=N` 隐含 `--single-branch`,只带默认分支。
   → 扫描前确认分支齐全。

同时**写明判据边界**(这一条比上面四条更重要,因为它防的是"用错力"):
判据是"这条信息能不能把内容跟我本人 / 我的机器关联起来",不是"网上能不能查到"。

| 类别 | 处置 |
|---|---|
| 产品参数(机型名、型号代号、分辨率、芯片名) | **不必脱** |
| 设备实例标识(device_id 的 ANDROID_ID 后缀、设备串号) | 建议脱 |
| 云 / 容器实例(lhins-…、1Panel-…) | 该脱 |
| 指向本人的(服务器 IP、域名、SSH 私钥路径、Linux 用户名、macOS 主机名、账号邮箱) | **必须脱** |

两条硬约束,来自本轮一次返工:
  · 不得把产品参数当敏感数据脱 —— 脱了以后 README 里「设备归并」案例
    (归并前 5 台 / 归并后 4 台)就没法跟真机对照了,净负收益;
  · 不得让判据恒红 —— 通用开源项目名(searxng / meilisearch)、通用词
    (reader_default)、已经是脱敏占位符的值(/home/devuser、~/pmon)
    都不许进判据或 denylist。恒红会让整条护栏被无视,比漏报更糟。

按此边界调整规则集:
  · 新增「Linux 家目录(含用户名)」,devuser 作为既有占位符放行
  · 删除「Apple 机器标识符」「证书 DN 中的地名」「真机型号/芯片标识」——
    这三条属于产品参数,留着只会恒红
  · 邮箱保持 WARN:夹具与真实邮箱形状完全一样,规则分不开;
    真正要拦的那个邮箱属于"形状上无特征"一类,请写进 denylist(命中即 FAIL)

另修一处工具自身的坑:`--format=%(refname:short)` 里的左括号在 `sh -c` 下是
子 shell 起点 → 语法错误 → 被 catch 吞成空串(范围提示会显示"分支 0 个")。
带 () % 的 git 参数改用 execFileSync,不拼进 shell 字符串。

验证:从仓库根与从 tests/ 运行结果完全一致;范围提示如实报出文件数与分支数。
上一轮脱敏**做过头了**:把产品参数当敏感数据脱,把 README 里「设备归并」案例
(归并前 5 台 / 归并后 4 台)跟真机的对照关系毁掉了 —— 净负收益。

按最终判据("能不能把内容跟我本人 / 我的机器关联起来")重做,替换表从 31 条
收敛到 4 条,命中从 43 文件 / 400 处降到 25 文件 / 46 处:

仍要脱(指向账号资源 / 指向本人 / 设备实例标识):
  · 云实例 ID、容器编排实例名
  · 设备 ID 的稳定后缀真值(在 main 上补齐)
  · 个人 handle

撤回(此前误脱,现恢复原样):
  · 产品参数:机型名、型号代号、屏幕分辨率、芯片名、Android 版本
  · 证书 DN 里的地名 —— 它随 APK 公开分发,且文档要用它核对 apksigner 输出
  · 私有 LAN IP、公共 npm 镜像源
  · 通用开源项目名(searxng / meilisearch / market-sentiment / reader_default)
  · 已经是脱敏占位符的值(/home/devuser、~/pmon)
  · keystore 目录路径 —— `~/` 下不含用户名,且命令要能照抄执行

「护栏拦不住」的三类(形状上无可匹配特征)改由本机 denylist 装原始值:
Linux 用户名、macOS 主机名、账号邮箱。denylist 装原始值、不装结果值 ——
装结果值会把已经改对的内容也判红。

⚠️ 只改工作树。历史里的原文仍在,由 git filter-repo 连历史一起重写。

验证:tests/run-all.sh 全部套件(见下一条执行记录)。
上一轮把自称替换成了 `<user>`,有两个副作用:

① **尖括号触发 14 个 doc-comment lint**(`unintended_html_in_doc_comment`)——
   Dart 分析器把 `///` 注释里的 `<...>` 当 HTML。脱敏前 analyze 是
   "No issues found",脱敏后变 14 条 info,属于**回归**。
   涉及:app_config / boot_report / config_migration / main / ui/eink_board /
   ui/skeleton + 6 个测试文件。

② **语义被降级**。原句是**决策记录**,改完丢了主体:
   - `(2026-09-16 产品负责人定的默认)` → `(<user>定的默认)`
     —— 从"负责人拍板"降级成"某个用户",信息量没了
   - `产品负责人那台,2026-09-15 实测` → `<user>那台` —— 指代不清
   - `产品负责人截图里的现象是` → `<user>截图里的现象是` —— 同上

改法:`<user>` → **「产品负责人」**(全仓 41 处 / 23 文件)
   · 无尖括号 → 14 个 lint 归零
   · 语义比 `<user>` 清楚:明确是「拍板的人」,而不是「某个用户」
   · 公开仓库里用中性角色名比昵称得体

验证:客户端 328/328、`dart analyze lib test` = No issues found。
diff 已核为**纯粹**(仅这一类替换,未混入其它改动)。

注:昵称本身不指向真实身份(网名 `某个网名` 才是可关联的,那条保留在 denylist 里),
故这里的取舍是「去掉个人色彩」而非「切断身份关联」。
设备报告(2026-09-16「电纸书助手」导出,实测非资料推测)显示此前的描述有两处不准,
且缺了一条**会影响设计判断**的关键事实:

1. **系统版本写错**:注释与文档此前写 `Android 11`,实为 **Android 10 (API 29)**。
   顺手核过兼容性:Kotlin 里所有 `SDK_INT` 判定(>= O / >= TIRAMISU /
   >= UPSIDE_DOWN_CAKE)都有守卫,29 上走安全分支;
   `foregroundServiceType="dataSync"` 正是 API 29 引入的,在 29 上有效;
   `POST_NOTIFICATIONS`(33) 被忽略;只用应用私有 filesDir,不涉分区存储。
2. **补上 CPU 与传感器**:MT8175B 四核 2GHz(aarch64,与 APK 的 arm64-v8a 匹配);
   ★ **传感器 0 个** —— 这条最要紧,见下。

★ 由「零传感器」推出的设计结论(已写进 docs/eink-device-neo2.md):
   没有加速度计/陀螺仪 → 系统拿不到姿态 → **自动旋转在这台机器上等于不存在**。
   所以横竖屏**只能由软件锁定**,看板里的「转屏」按钮**不是便利功能,
   而是唯一的方向入口**,**不能省** —— 省掉它,用户在竖屏机器上永远看不到横屏版面。
   现有实现(宿主用 SystemChrome 锁方向、看板只发指令)正好不依赖传感器,
   经此确认是对的,不是过度设计。

另记一个仍待实测的数:逻辑尺寸 dp。报告里的「300」若指 densityDpi → 约 572×772dp;
若只是由 1072×1448@6″ 算出的 PPI(density 取常见 2.0)→ 约 536×724dp。
**两种假设下版面决策一致**(列数由宽高比决定:竖屏 2 列 / 横屏 3 列),
故不阻塞调版;确切值用看板「自检」页一屏截图即可定案。
产品负责人指出"参数早就发过了,不该再等截图"。臣复核后确认:**参数确实够用**,
而且不必上机 —— 把两种 density 假设的逻辑尺寸喂给看板自己的代码
(widget 测试挂载),就能量出真机版面:

| 假设 | 方向 | 数值字号 | 列数 |
|---|---|---|---|
| A 572×772(若 300=densityDpi) | 竖 / 横 | 40.0 / 37.2 dp | 2 / 3 |
| B 536×724(若 300=PPI、density 2.0) | 竖 / 横 | 38.5 / 33.2 dp | 2 / 3 |

两种假设下**列数一致**、**数值字号 33~40dp**(标签的两倍以上)→ 版面合格。

⚠️ 关键澄清(避免把测算读成真机值):**widget 测试的字形是正方形的**
(每字宽 ≈ 字号),远宽于真机字体,故上表是**下限** ——
真机只会更大(长值基本能开到满档 40dp)。

结论:`densityDpi` 的精确值**不影响任何设计决策**,只用于事后核对。
设备卡里「如何确认」的措辞已从「待确认」改为「事后核对」,
并说明自检页的真正用途是内存占用与实际刷新计数。

(臣上一轮把"验证"说成了"前提",是要纠正的表述。)
真机反馈:在采集端登录后,切到查看端仍显示未登录,于是又登一次。

排查结论:**不是登录两次,而是登录态没同步**。令牌早就共享
(两端都写同一个 `AppConfig.accountToken`,且两端都订阅了 `ConfigStore.updates`),
整条链路本来是通的:

  采集端登录 → onAuthChanged → ConfigStore.save
    → _publish 广播 → ViewerPage._onExternalConfig
    → sessionAffectingChanges(含 accountToken)为真
    → _restartWith → _startFromConfig → `_token = cfg.accountToken` ✅

断在**最后一环**:`AccountCard` 在 `initState` 里
`_token = widget.initialToken` 读了**一次**就不管了,且没有 `didUpdateWidget`。
而主壳是 IndexedStack,两页的卡片**都长期存活** —— 父级更新了 `initialToken`
也不会重跑 `initState`(同位置、无 key → State 被复用),
卡片内部仍是旧的空 token,照样渲染登录表单。用户看到的自然就是
"那边登了、这边还得再登一次"。

修法:给 `AccountCard` 补 `didUpdateWidget`,只在 `initialToken` 变化时同步;
登出(token 变空)时一并清掉 `_info`,否则会留着上个账号的档位与设备数。

边界(刻意保守):
  · 邮箱**只补空、不覆盖** —— 只在输入框为空或仍等于旧值时才同步,
    免得把用户正在敲的内容冲掉;
  · token 未变时直接返回,不做无谓重建(避免自写盘自激)。

对照:`PairingCard` 全程直接用 `widget.accountToken`,所以没有这个问题 ——
同一应用里两种写法,错的那个恰恰是"看起来更规范"的那种(把入参缓存进 State)。

验证:
  · 新增 4 条测试(`test/account_test.dart` ⑥ 组),客户端 328 → **332/332**
  · **变异测试**:移除 `didUpdateWidget` → 「别处登录/登出后卡片要跟着变」两条**报红**;
    恢复即绿 —— 证明测的就是这条修复本身
  · `dart analyze lib test` 干净
真机反馈「转屏功能无法使用」。排查:**不是代码没接上,是系统不理** ——
`SystemChrome.setPreferredOrientations` 发出去后窗口尺寸纹丝不动,
界面上当然"没反应"。这与设备事实卡里的「零传感器」自洽:
没有加速度计,SmartOS 层面就不做方向变化。
(接线本身是对的:按钮 → setState + 请求 + 落盘,manifest 也没有写死
 `screenOrientation`、`configChanges` 也含 orientation|screenSize。)

⚠️ 由此得一条通用认识:**发了请求不等于生效**。能验证的只有窗口尺寸本身。

改法:
1. `_verifyOrientation()` —— 请求后回读窗口尺寸(900ms 宽裕),
   宽高比没变成想要的那样就切到应用内旋转。
   **不猜设备、不看厂商,只看事实。**
2. `_einkBoardShell()` —— 必要时套 `RotatedBox(quarterTurns: 1)`。
   它的关键性质是**交换子节点的约束**(窗口 1072×1448 → 子节点拿到 1448×1072),
   于是横屏布局正好按 1448×1072 排布、再旋转 90° 铺满物理屏 ——
   **不缩放、字号无损**;命中测试也跟着旋转,按钮照常可点。
   代价:系统 UI 不参与旋转,用户需把设备物理横置。
   同时清掉系统 padding(旋转后它描述的是未旋转的窗口,语义已不对)。
3. 自检页新增「窗口(物理)」「布局(看板用)」「转屏方式」三行 ——
   前两者**不相等**即证明在应用内旋转,一眼可验证。

对**支持系统转屏的设备(如 F50)零影响**:请求后尺寸确实变了 →
`_softRotate` 保持 false,行为与之前完全一致。

验证:
  · eink_board_test 39 → 40 项,客户端 332 → **333/333**,analyze 干净
  · 新用例「竖屏窗口里也排出 3 列」:断言物理窗口是 536×724(竖)
    而看板列数是 3(横)—— **一竖一横并存**即证明约束真被交换
  · ⚠️ 写这条测试时踩坑:不能沿用按屏幕 y 坐标分行的 `columnsOf()` ——
    RotatedBox 之后 `getTopLeft` 返回的是**旋转后**的屏幕坐标,同一行会变成同一列。
    **度量方法本身失效,别把"量不出来"当成"功能没实现"。**

设备事实卡(docs/eink-device-neo2.md)补第六节,记录该结论与验证方法。
真机反馈「开看板后底下的导航键没隐藏」。查下来是**两个成因,都要治**:

① **系统导航栏**:代码里**从未**调过 `setEnabledSystemUIMode` —— 一直显示。
   修法:进看板 → `SystemUiMode.immersiveSticky`;退出 → `manual` + 全部 overlays。
   · 用 `immersiveSticky` 而非 `immersive`:前者在用户误触屏幕边缘后**会自动重新隐藏**,
     副屏是常驻的,不该被一次滑动永久唤出系统栏。
   · 恢复用 `manual` + `SystemUiOverlay.values` 而非 `edgeToEdge`:后者要求 API 29+,
     前者全版本可用,语义就是"把系统栏都还回来"。

② **应用自己的底部导航**(采集/查看两页签):主壳是 `IndexedStack` +
   `bottomNavigationBar`,而后者是 Scaffold 的**独立区域** —— 看板渲染在 body 里,
   它一无所知。修法:新增 `PmonEinkBoardMode`(`ValueNotifier<bool>`),
   主壳用 `ValueListenableBuilder` 订阅,看板前台时不渲染导航。
   · 用 ValueNotifier 而非像 `PmonEinkMode` 那样的静态 bool:这里必须**触发主壳重建**。

**判定只有一处**:`_syncBoardChrome()` 用同一个 `_eink && !_einkPaused` 同时驱动
系统栏显隐与主壳导航 —— 本项目已因"同一判据写两处"反复踩坑(指标清单、单位换算、
身份判据…),这里不再重演。

顺带修掉一个隐患:转屏校验原先用 `Future.delayed`(**不可取消**),页面销毁后
回调仍会醒来。已改成可取消的 `Timer` 并在 `dispose` 里取消。
(这是写测试时被 "Pending timers" 揪出来的 —— 测试的价值不只在断言。
 `dispose` 里同时还原 `PmonEinkBoardMode` 与系统栏,免得离开这一页留下副作用。)

验证:
  · 新增 2 条用例(`viewer_page_test` ⑨ 组)→ 客户端 344 → **346/346**
  · **变异测试**:注释掉"通知主壳"那行 → 「进看板 → active 置真」**报红**;恢复即绿
  · `dart analyze lib test` 干净

⚠️ 本提交是 **feature/eink 专属**,不同步主干:`ui/eink_mode.dart` 主干上不存在,
`main.dart` 的订阅逻辑也只在有水墨屏模式时才有意义。
(上一轮臣就是整文件复制到主干,把分支专属内容带了过去 → 主干编译不过。
 这次明确不跨分支。)

设备事实卡补第七节,记录两个成因、取舍与遗留。
2026-09-17 为了连上 Neo2 绕了四轮弯路,根因**不在设备侧**:
Neo2 被 Parallels 直通进了虚拟机(`prl_vm_app` 持有它的 user client),
之后释放了又被 Microsoft Edge(Chromium WebUSB)认领。
而脚本当时只会说"未发现设备,请检查 USB 调试" —— 于是排查一直被引向
设备侧(开关、拔插、描述符),迟迟找不到主机侧的真凶。

改法:把"设备在总线上"与"根本没插上"**分成两条路**:
  · 在总线上 → 打印它暴露的接口类别 + **谁持有它**(user client 占用者)
  · 不在总线上 → 才是线/开关的问题
并明确写出 ADB 接口的判据(`255 / 66 / 1`)。

命令用的是**按设备递归取子树**(`ioreg -r -n "<名>"`),
不是全量 grep 拼字段 —— 后者会让不同节点的字段串在一起,
臣据此一度误判"Neo2 有 ADB 接口"。

⚠️ 同时记下一个**已确认的硬事实**:iReader Neo 2(SmartOS / Android 10)
在主机侧**无任何占用者**、设备侧 USB 调试开关反复确认已开的情况下,
仍只暴露一个名为 `MTP` 的 `255/255/0` 私有接口 ——
**该机型不产出 ADB 通道**,调试通道不可用。以后不要再在这上面花时间,
改用设备内「自检」页取数。

(脚本细节:`$NAME」` —— bash 会把紧跟其后的多字节字符字节并进变量名,
故一律写成 `${NAME}`。)
# Conflicts:
#	tests/privacy-scan.js
护栏的邮箱一条是 WARN(只列不判红),但规则太紧:本地部分限 1-3 字符,
于是测试夹具 `typed@x.com` / `other@y.com` 每轮都被列出来,
18 条噪声把真正的信号淹了。

判据其实是**域名**:`x.com` 这种单字母域名不可能是真实的企业或个人邮箱,
本地部分多长都不改变这一点。故放宽本地部分到 1-12 字符,域名仍限单字母。

(这与"护栏总报红就没人看"是同一类问题:噪声会让人对信号麻木。)
方案落地第一步。它是两件事的**共同底座**(故先做它,不分成两遍):
  · 指标走内网(LAN 优先)
  · 大文件端到端传输(不经服务器 ⇒ 不受服务器磁盘与流量限制)

⚠️ **这一步最关键的不是功能,是安全**:
绝不能 `HttpServer.bind(InternetAddress.anyIPv4)` —— 那会同时绑到公网网卡上,
等于在公网开了一个端口。故:
  · 只绑 `pickLanAddress` **选中的那一个**内网地址;
  · 只暴露**只读、无数据**的 `/lan/ping`,未知路径一律 404;
  · 将来接数据/文件时用一次性令牌 —— **不拿"同网段"当授权**
    (那是隐蔽监控的形态,见方案 2.1 的合规红线)。

地址选择收敛在 `pickLanAddress` 一条纯函数里,因为**选错网卡的后果最难定位**
(表现是"服务看着开了、对面却连不上")。规则:排除回环 / 169.254 链路本地 /
IPv6 / 虚拟网卡(utun、docker、br-、veth、vmnet、tap…),**私有网段优先**。
没有可用内网地址时**返回 null 且不启动** —— 不退而求其次绑公网。

可测性上做了一处刻意取舍:入参收 `(网卡名, 地址列表)` 简单形状,
而不是直接吃 `NetworkInterface` —— **后者没有公开构造函数**,
吃了它就没法在测试里构造"VPN + Wi-Fi 同时存在"这类最容易选错的场景。

验证:
  · 新增 12 条测试(9 条穷举地址筛选 + 3 条服务本身),客户端 346 → **358/358**
  · 其中一条**真起服务 + 真发 HTTP 请求**(不是"调了 API 就算过"),
    因为"绑了但对面连不上"正是本模块最容易出的问题;并断言未知路径 404
  · **Mac 真机验证**:网卡 en0 192.168.0.85 / en7 192.168.0.12 → 选中前者;
    服务起在 192.168.0.85:49504;/lan/ping → 200 {"ok":true,"v":1};未知路径 → 404
  · `dart analyze lib test` 干净

接线:主壳 initState 启动、dispose 停止;启动失败**不影响主流程**
(局域网只是"更快",不是"必需"),只在看板自检页如实显示状态 ——
**"没开"和"开了但对面连不上"必须能分辨**。

下一步:查看端拿到地址后先试内网、失败回落云端;再做文件传输。
# Conflicts:
#	client/lib/host/lan_service.dart
#	client/lib/main.dart
#	client/test/lan_service_test.dart
把第二步的探测结果露出来 —— 否则"走内网还是走公网"只是代码里的一个变量,
用户看不见,也就没法判断到底有没有生效(本项目一贯要求可验证)。

**三种状态必须分得开**:
  · 对端未上报(可能不在同一局域网)
  · 已上报但探不通(会列出试过哪些端点)
  · 可达(显示具体是哪个端点)
混成一句"不可用",用户就没法判断是"我这边的服务没起来"还是"对端根本没报"。

⚠️ 诚实边界:探测结果**不是实时推送的**,在下一次节流刷新时反映到界面(最多 10 秒)。
这是刻意的取舍 —— 为一个诊断行加一条推送通道要多一处要维护的接口,
而看板本来就有节流刷新,够用。

客户端 361/361、analyze 干净。
按项目纪律「**凡多层持同一份清单,必须有真实形态数据端到端穿透**」补上 ——
这条链路横跨四处:客户端上报 → 网关校验 → 网关存会话 → 随快照下发 → 客户端接收。
单测只能覆盖"网关校验"那一处,而本项目最贵的一类 bug 恰恰是**跨层静默丢数据**
(历史上白名单过严把 capabilities 全剥掉,谁都没发现)。

跑法(本脚本自带环境,不碰生产):
  起临时 redis + 本地网关(无 PG 走 Redis 授权回退)→ 手动写一条 auth 授权 → 跑

★ 实测结果(**证明安全规则在链路里真的生效**,不只是"函数会拦"):
  上报 5 条(2 条合法 + 公网地址 / 主机名 / 云元数据地址各 1 条)
  → 快照里只有 ["192.168.1.5:49504","10.0.0.7:1234"]
  → 三类非法值**在真实链路里被拦掉**
  再次全部非法上报 → 旧端点**被清掉**(快照不再带该字段),
  避免查看端拿着上一轮的过期地址去连

过程中顺带确认了两件既有机制(都不是本次改动引入的):
  · 订阅有**授权校验**,匿名订阅会被 NOT_PAIRED 拒 —— 头一次跑就撞上了,
    这反而说明授权守得住;本地无 PG 时走 `auth:<deviceId>` 的 Redis 回退;
  · 无 PG 时网关是 stateless,不落库。

另:生产网关已同步部署并核对 —— `sha256sum` 与本地**一致**,
即生产跑的就是这份被 E2E 验证过的代码。
方案文档里的 ③,也是产品负责人的**原始诉求**:文件不落服务端、
端到端传输、支持超出服务器磁盘容量的大文件。
实现落在第一步做出的那个"只绑内网的小服务"上 —— 这正是把底座先做出来的回报。

## 端点

| 端点 | 作用 |
|---|---|
| `offerFile(path)` | 登记一个待传出的文件,返回带**一次性令牌**的句柄 |
| `GET /file/<token>` | **流式**返回,支持 `Range` 断点续传 |
| `revoke(token)` | 传完即撤销 |

文件从发送端磁盘**直接流到**接收端磁盘,服务端一分钟都不参与 ——
所以既不受服务器 45G 可用磁盘的限制,也不烧流量包。

## 安全(这一块比功能重要)

- **令牌即授权,不拿"同网段"当授权** —— 那等于把物理接近当成授权依据,
  正是隐蔽监控的形态(方案 2.1 的合规红线)。令牌由上层显式发放,与配对码同一思路。
- **令牌不存在/过期一律 404,刻意不用 403**:403 会泄漏"这个令牌曾经存在",
  等于给爆破一个反馈信号。
- **路径不来自请求**(由登记时的本地路径决定)→ 结构上就没有路径穿越。
- **`stop()` 清空登记**:停止服务 = 不再共享任何文件。否则下次 start 会带着
  上一轮的令牌复活 —— 令牌不该比它所属的那次服务活得更久。
  (这条是被测试逼出来的:用例间单例状态残留,一查才发现语义本身就不对。)

## 校验:把"传完了"和"内容对"分开

`LanDownloadResult.verified` 是**返回值的一部分**,不是一行日志。
大文件最常出的问题是"字节数对得上、内容不对"(断点拼接错位、对端文件被换过)。
对端没给哈希时**不算验过** —— "没坏"与"验过了"是两件事,不能拿后者冒充前者。

哈希走**流式**(`sha256.bind(file.openRead())`),大文件不整个读进内存
—— 那正是本模块要解决的问题本身。

## 验证

- 新增 7 条用例(端到端 sha256 一致、令牌错 404、撤销后 404、
  Range 206 + 分片内容正确、越界 416、中文文件名 URI 编码、登记不存在的文件报错)
  → 客户端 361 → **368/368**
- **真机实测**(本机真起服务、真传文件、真校验):
    源文件 64.0 MB
    登记(含流式 sha256)502 ms
    传输 64.0 MB / 601 ms ≈ **106 MB/s**
    端到端校验 ✓ sha256 一致,字节数一致
  → 1GB 也就十来秒,**"超出服务器磁盘的大文件"这条诉求成立**

## 依赖

新增 `crypto`(Dart 官方、纯 Dart、极小):大文件传输没有端到端校验是不合格的,
这个依赖值得。未引入任何原生插件,安装包体积基本不受影响。
与主干同一份实现。同步前已 diff 确认两文件在编辑前一致,故可安全取;
并顺手把 pubspec 里 crypto 的注释与主干统一 —— 两次插入的文本有引号差异,
虽无害,但同一份清单两处定义正是本项目反复踩的坑,注释也不该例外。
(pubspec 现在只剩 version 一行差异,那是两支刻意不同的版本号。)

验证:客户端 368 → 374/374,analyze 干净。
把上一步的领取码接上界面。放在**采集端页与查看端页各一张** ——
"我的设备间传文件"是双向的:任一台都可能既发又收。

## ★ 一条贯穿全卡片的纪律:「先确认再收」

接收是**两段式**的:粘贴码 → **看清"将收到什么(来自哪台机、什么名字、多大)"**
→ 再点「收下」。**不给"粘了码就自动开下"的路径** —— 文件会落到接收方磁盘上,
用户有权在写盘之前知道那是什么。
(与本项目对"破坏性操作要先列清单再确认"的态度一致。)

配套的一条细节:**输入内容一变,上一次的确认立刻作废** ——
否则会出现"看着 A 的信息、收的却是 B"这种最不该有的错。

## 保存到哪

优先**下载目录**(Android 上是 app-specific 外部目录,**系统文件管理器看得到**),
拿不到才退回应用私有目录。存在用户翻不到的地方等于"传了个寂寞"。

文件名来自对端,**只取 basename**(把分隔符换掉)—— 不让对端用路径做别的事。

## ⚠️ 引入依赖时踩的坑(值得记)

加 `file_picker` 后 **Android 构建直接失败**:
```
Dependency ':flutter_plugin_android_lifecycle' requires ... compile against version 36 or later
:file_picker is currently compiled against android-34.
```
即 **`file_picker` 8.x 自身用 android-34 编译**,而其传递依赖要求 36+。
→ 升到 **13.x** 解决(顺带 API 变了:`FilePicker.pickFiles()` 是**静态**方法、
返回 `List<PlatformFile>`、用户取消返回**空列表**而非 null ——
"取消"不是错误,不该被渲染成红色提示)。

⚠️ **教训:加原生依赖必须真跑一次 Android 构建**,
`pub get` 成功、`analyze` 干净都**不代表能构建**。

## 验证

- 新增 5 条界面纪律用例(没确认就没有「收下」、垃圾码给人话错误且不放行、
  有效码先显示"将收什么"、**改输入即作废旧确认**、发出区初始无码)
  → 客户端 374 → **379/379**,`analyze` 干净
- **Android release 构建通过**:`com.inkwatch.eink` v17 / 19.7MB
  (比上版大 0.6MB,来自 file_picker + path_provider,合理)
- 出包 `dist/inkwatch-eink-1.1.7.apk`(正式签名 CN=InkWatch)
先写 file_picker ^8 再改成 ^13 时留下的说明,内容已被上方那段(解释为何不能低于 13)
完全覆盖。留着会让两分支的 pubspec 多一处无谓差异 ——
而同一份清单两处定义正是本项目反复踩的坑,注释同理。

现在 pubspec 与主干**只剩 version 一行差异**(两支刻意不同的版本号)。
产品负责人要"设备间传文件"免去手工粘贴领取码。这需要一条信令通道。
本提交只做**服务端侧**(协议事件 + 网关转发 + 端到端验证),
客户端接线留作下一步 —— 一次改太多、又验不透,是本项目最贵的一类错误的温床。

## 协议:新增 3 个上行 + 2 个下行事件

| 事件 | 方向 | 作用 |
|---|---|---|
| `agent:offer` | 上行 | 采集端发布"有个文件待领" → 广播给该设备的订阅者 |
| `viewer:offer` | 上行 | 查看端发布 → 定向送到目标设备的采集端 |
| `viewer:unsubscribe` | 上行 | **退订** —— 局域网直连时改走内网,必须能退掉云端订阅 |
| `server:offer` | 下行 | "有人要给你一个文件,凭这些信息去取" |
| `server:unsubscribed` | 下行 | 退订回执 |

## ★ 三条设计取舍

**① 服务端刻意不解析"领取码"的格式。**
载荷里把 `lan_endpoints`(数组)与 `token` **分开传**:端点走
`normalizeEndpoints` 校验(只收私有网段 —— 与 hello 里**同一条防线**,不因
"这次是 offer"而放宽),而那个码是客户端之间的约定,服务端多懂一层
就多一处要跟着改的地方。

**② `viewer:offer` 必须先过授权。**
`isAuthorized(target, accountId)` 复用同一套判据。少了这一步,
"发文件"就成了**绕过授权往别人设备推东西**的口子。

**③ 退订只摘 Redis 订阅,不动 socket 房间。**
房间是**账号级**(`viewer:<accountId>`,一个账号可能多端在线),
而订阅是**设备级**。把房间退掉会连带断掉该账号看别的设备的连接 ——
那是另一回事。转发路径本就按 `getSubscribers(deviceId)` 查,摘订阅即生效。

## 验证:端到端穿透(`tests/tools/lan-signal-e2e.js`)

真起 socket 走完整链路,**8/8 通过**:
  · `agent:offer` → 订阅者收到,端点/文件名/大小原样到达
  · **公网端点在 offer 这条路上同样被拦**(没绕过同一条防线)
  · `viewer:offer` → 目标设备的采集端收到
  · `viewer:unsubscribe` → 收到回执,且**此后不再收到该设备的帧**
    (这一条是"省流量"能不能成立的前提 —— 退不干净就是空话)

另:跑 `lan-signal-e2e` 时发现订阅本身有授权校验(匿名订阅被 `NOT_PAIRED` 拒),
这在设计之内,脚本里如实写明。
服务端侧上一步已就绪。这一步把两端接上:
选完文件**自动通知对端**,对端那边直接出现待领提示,不用再转发那条码。

## 两条路并存,而不是二选一

免粘贴通了就走它;**不通就自动退回"把码发给对方"**。
刻意不是二选一 —— 那会让"不在同一局域网"直接变成功能不可用。
界面把这两种结果**分开说**:能通知对端时不给用户增加任何动作;
没通知成时明确要求"请把这条码发给对方"。

## 共用一份解析(单一真相源)

`parseIncomingOffer` 放在 `lan_service.dart`,**两端共用** ——
采集端与查看端收到的是同一个事件形状,各写一份解析迟早会漂移
(本项目为此吃过多次亏)。

## ★ 一处必须补的坑:`didUpdateWidget`

宿主(采集端页/查看端页)的会话对象是**异步创建**的:卡片先建、`_agent`/
`_session` 后到。若只在 `initState` 订阅,这个通道会被**永久漏掉** ——
症状是"免粘贴时灵时不灵",而且极难定位。
**这与本项目刚修过的 `AccountCard` 是同一类问题**(入参缓存必须配 `didUpdateWidget`),
所以这次是**主动**加上并写测试守住的,而不是等它变成故障。

## 避免重复实现

手工粘贴与免粘贴**共用同一个下载实现**(`_download`)—— 两条入口、一套逻辑。
分开写迟早出现"一条路径修了、另一条没修"。选端点也复用
`pickReachableLanEndpoint`(对端可能报多个,先挑真的可达的那个)。

## 验证

- 卡片测试 5 → **9 条**,新增:收到待领文件显示"谁给的/什么/多大"、
  「忽略」后不再纠缠、**流入参后到也必须订得上**(守 didUpdateWidget)、
  没有免粘贴通道时粘贴那条路仍可用(降级而非不可用)
- 客户端 379 → **383/383**,`analyze` 干净
- Android release 构建通过:`com.inkwatch.eink` v18 / 19.8MB
- 出包 `dist/inkwatch-eink-1.1.8.apk`

⚠️ 服务端**无需再部署**(信令在上一步已上线)。
与主干同一份实现(产品级)。同步前逐个核对基线:分支已有上几轮的改动,
本轮差异是纯新增;viewer_page 的看板自检页因两分支差异大,用锚点精确插入。

要点见主干上那条提交,关键三条:
  · **两套数据源必须互斥** —— 两条都在跑意味着云端流量照烧,
    而那正是本功能要省的东西;
  · **三道闸防重复**:_lanMode 时忽略云端帧 / 轮询按 seq 去重 /
    两条路共用同一个 _ingestMetricsPayload;
  · **不等退订回执就切** —— 在途帧由第一道闸丢掉,等回执只慢一拍。

验证:客户端 383 → **387/387**,analyze 干净,Android release 构建通过。
⚠️ 切换时序仍无自动化覆盖,真机形态验证待两台设备同局域网实测。
产品负责人拍板了"三层优先级:局域网 → 公网直连 → 中继(要有限制)"。
本提交做**中继核心**,它是唯一能保证"不在同一局域网也传得成"的一层。

## 为什么"不落盘"而不是"传完就删"

原始要求是"文件不保存在服务端"。落盘再删**不满足**它:中间那段时间磁盘上
真有这份数据,而服务器只有 45G 可用、一次大文件就可能填满。
所以这里是**两条 HTTP 连接对接后直接 pipe** —— 字节从发送端进来、
立刻从接收端出去,进程内存里只有管道缓冲。
(测试里真的起了两个 HTTP 连接跑一遍,并断言**服务端目录里没有多出文件**。)

⚠️ 代价如实说:**两端必须同时在线**。这与"不落盘"是同一枚硬币的两面。

## 四道限制("中继传输要有限制")

| 闸 | 值 | 越界行为 |
|---|---|---|
| 单次大小 | 2 GiB | 拒绝并说明上限 |
| 每账号并发 | 1 | 拒绝,提示等前一条 |
| 限速 | 8 MiB/s | 节流 |
| 每账号每日 | 20 GiB | 拒绝并给出已用/上限 |

另外:**假 size 绕不过**单次上限(传输中累计,超了直接断),
配额**按实际字节记**(不是声明的 size)。
任何一道越界都**明确告知用户**,不是静默失败。

## 安全

- **上传/下载各自一条一次性令牌**,拿到一个不能当另一个用(有用例守着);
- 令牌比较用 `timingSafeEqual`,不按字符提前返回;
- 数据面失败一律 **404,不区分"不存在"与"令牌错"** —— 避免给爆破反馈。

## ⚠️ 测试抓到的真 bug(值得记)

`waitDownloader` 最初只接 `(id, token)`、**没接住下载端的 `res`**,
于是配对时拿不到 pipe 的目标。**只测"开/拒"永远不会暴露它** ——
是"真起 HTTP 跑一遍"这条用例抓出来的。

另一条教训:臣第一版断言"系统临时目录前后条目数相等",
**那是假失败** —— tmp 是全局共享的、别的进程随时在写。
**量不出来时先怀疑量法**(本项目已多次踩)。

## 验证

- 服务端 `relay-test` **15/15**:四道闸、令牌双向隔离、过期、
  **真传一遍且字节一致**、**服务端不落盘**、按实际字节记配额、假 size 被截断

## ⚠️ 未完成(如实说明)

1. **接入层(nginx)未落定**:中继走 HTTP,外网访问必须过 nginx,
   且**必须关掉 `proxy_request_buffering`**(默认会把请求体缓冲到临时文件,
   那样"不落盘"就是假的)。臣改了 `/opt/1panel/www/conf.d/pmon-ip.conf`,
   **但排查发现它似乎不在 openresty 的 include 链路里** ——
   生效配置的路径未确认,故**不敢说这一环做对了**。
2. **客户端三层降级未做**(局域网 → 公网端点 → 中继的候选排序与界面明示)。
生产接入层已改并 reload 验证(外网 /relay/open → 200)。
仓库这份同步补上,保证**能重建出同样的接入层** —— 只改服务器不改仓库,
下次重新部署就丢了,而这类只在线上存在的配置最难排查。

★ 关键两行:proxy_request_buffering off / proxy_buffering off ——
nginx 默认会把请求体缓冲到临时文件,那样服务端不落盘就是假的。
这不是性能调优,是这个功能成立的前提。

(生产那份在 /opt/1panel/www/conf.d/pmon-ip.conf,改完需 reload 才生效 ——
 臣当时改了没 reload,从公网打仍是 404,白排查了一阵。
 reload 用 kill -HUP master 即可,因为 openresty 可执行不在 PATH 里。)
上一提交没找到锚点(仓库这份用 upstream 名 pmon_gateway、注释是中文,
与生产那份的字面 socket.io 不同),臣**没有硬塞**,这次按实际结构补上。

★ 关键两行:proxy_request_buffering off / proxy_buffering off ——
nginx 默认把请求体缓冲到临时文件,那样服务端不落盘就是假的。

生产侧已改并验证:外网 POST /relay/open → 200 且返回两条一次性令牌。
产品负责人拍板"优先选快的、局域网优先、公网次之、中继兜底、中继要有限制"。
本提交补齐**客户端**这一环,三层第一次真正接通。

## 服务端:新增 wan_endpoints 与放宽 offer 校验

- **新增 `wan_endpoints`**(公网直连):与 `lan_endpoints` **分开两个字段**,
  这样"私有优先"的排序才有地方落地,服务端也能一眼看出要去连的是内网还是公网。
  ⚠️ 但两者**共享同一条禁令**:`169.254`(云元数据,SSRF 头号目标)/ `127` / `0.x`
  **无论出现在哪个字段里都不收**。"允许公网"不等于"允许任何地址"。
- **放宽 `_buildOffer`**:早先硬性要求"必须有内网端点",那会让"不在同一局域网"
  **直接变成功能不可用**。现在改成**三条路至少有一条**。
  中继那段服务端**只校验形状** —— 取数权在 32 字节随机令牌上,
  伪造 id 没用、把 id 转给别人也没用,所以不必回查会话。

## 客户端

- **中继通道**(`openLanRelay` / `uploadViaRelay` / `downloadViaRelay`)
- **下载校验只有一处**(`_receiveToFile`):局域网与中继**共用同一份收尾比对**。
  各写一份迟早漂移,而这是"文件到底对不对"的唯一判据。
- **中继凭据按 `frameProvider` 同一模式注入**:查看端本来没有设备令牌,
  而采集端总有 —— 且两者同处一个 App、采集端总在跑,所以两端都取得到。
- **上传要等对方**:中继是"两条连接对接后直接 pipe",发送端早到了没地方去。
  故**轮询重试**(先等响应头证明管道已建立,再发字节),
  这样不必为"对方开始收了"再加一个信令事件。

## ★ 界面上必须明示走的是哪条路

- 走中继:已走**中继**…会消耗流量包 + 限速 X MiB/s
- 走直连:已走**局域网直连**(最快、且不消耗服务器流量)
- 接收侧完成后同样标注「本次经中继中转(不落盘)—— 消耗了服务器流量包」
  / 「本次走直连 —— 未消耗服务器流量」
- 中继**上传进度条只在真走中继时出现** —— 直连是秒传的,
  给它加进度条只会让人以为卡住了。

## 验证

- 信令端到端 **14/14**,四条最关键:
  * **只有中继、没有内网端点 → 仍能发出**(三层的前提)
  * 公网端点走 `wan_endpoints` → 通过
  * **云元数据地址在 `wan` 里同样被拦**
  * **三条路都没有 → 被拦**(不发一条"哪个都走不了"的 offer)
- 客户端 **387/387**,analyze 干净,Android release 构建通过(19.9MB)
- 线上:`/relay/open` 经公网 → 200 并返回两条一次性令牌
- 出包 dist/inkwatch-eink-1.2.0.apk

⚠️ 诚实边界:真机形态(两台设备分别处于同网段 / 不同网段)未实测。
新增解析用例,重点守两条最容易漏的:
  * **只有中继(没有内网端点)也必须能解析** —— 三层里的最后一层,
    解析不了就等于没有,而不在同一局域网也能传全靠它;
  * **三条路一条都没有 → 不解析**,即不发一条哪个都走不了的 offer。
另守:走直连必须有领取令牌;中继 id/token 缺一视为没有中继(不当半个对象用)。

客户端 387 → 392/392。
## 背景:三层降级上线后做真机验证

产品负责人要求实测,臣写了探针用**客户端自己的代码**走三层
(另写一份测的只是"我以为的逻辑")。**第一层局域网直连当场全绿**,
**第三层中继连续三次失败** —— 过程与结论如下。

## 修掉的三个真 bug(都值得记)

**① 失败的中继会占满并发额度**
一次失败(对端没点收下)会让空壳会话活到总 TTL(10 分钟),
期间再开中继永远是"请等前一条结束"——**而那条根本没人传**。
→ 加 `sweepUnpaired()`:`open` 时先回收"两端都没连、且过了等待窗口(3 分钟)"的空壳。

**② HEAD 预检被 Express 的 `app.get` 抢走**
`app.get('/relay/:id')` **会自动处理 HEAD 请求**,且注册在 `app.head` 之前 ——
于是 HEAD 落进"下载端"逻辑,拿**上传令牌**去验**下载令牌**,必然 404。
症状是"发送端永远等不到对端已就绪",而日志只有一串 E_BAD_TOKEN,**看不出是 HEAD 引起的**。

**③ 客户端 close 顺序导致死锁**
原写法"先 `await req.close()` 拿响应头、再写 body" —— 但 Dart 的 `close()`
要等 body 完整才把请求发出去,于是它等一个永远不会来的响应,**请求根本没出门**。
⚠️ **服务端用 curl 手测全绿**(curl 是先发 body),**只有真客户端能暴露它**。

## 简化设计(前两次都在打补丁,这次换形态)

取消"发送端先探对端在不在"那整套预检 —— 它对,但脆。
改成 **`attachUploader` 对端没到就挂着等**,接收端一到就叫醒它。
"**谁先到都行**"本来才是中继的自然语义,客户端因此什么都不用协调:
直接写完 body 再 close 即可。

## ⚠️ 但仍然没打通

当前症状:**接收端(GET 侧)的连接被重置**,服务端日志为
`[relay] 中断: 接收端断开`。臣**三次尝试后停下**,不再盲试 ——
手上有明确下一步(查 GET 侧为什么先断:嫌疑在 `HttpClient.idleTimeout`
默认 15 秒,与"挂着等对端"的时长冲突),但**没有验证过的结论,
所以不写成"已修复"**。

## 现在的真实状态

- ✅ **第一层:局域网直连** —— 真机验证通过(4MB,sha256 一致)
- ❌ **第三层:中继** —— **未打通**,用户走到这层会看到"中继传输中断"
- ⚠️ **第二层:公网直连** —— 逻辑完整,但无真实公网可达地址可测
- 服务端 `relay-test` 18/18、客户端 392/392、analyze 干净(**它们覆盖不到这个缺陷**)

## 教训

**"服务端用 curl 测过"不等于"客户端能用"** —— 这次两个 bug(③ 与 ②)
都是只在真客户端形态下才现形的。**端到端探针必须用真代码走真链路**,
手测只能证明服务端自己没错。
onMetrics 每帧原 4 次顺序往返(pushFrame → touchPresence EXISTS →
markOnline → getSubscribers),合并为 pushFrameAndFanout 单 multi;
touchPresence 的 EXISTS 与写入并入同一 multi(2→1)。语义逐项对齐:
presence 只写 {ts}、SMEMBERS 原样返回、任一步抛错整体失败。
pushFrame / markOnline 两个再无调用方的旧入口按"同一件事不留两个
入口"的纪律删除。

三处全表扫描(Array.from(this.sessions...))改索引:
_agentByDevice / _viewersByAccount 存同一 session 对象引用(单一真相源
仍是 sessions 表),hello/subscribe 登记、disconnect 摘除;
_findAgentByDevice 带 !s.gone 竞态防护。首帧标记 Set<字符串> →
Map<账号, Set<设备>>,回收从 O(全表) 降为 O(该账号设备数),计数账目平。

正确性修复(均经变异测试自证:删掉即红、恢复即绿):
· relay sweep 双类判定:传输中按 lastActivityAt(每 chunk 刷新),
  修「慢传被误删致每账号并发 1 额度统计破」;挂死传输仍有兜底;
· LoginThrottle 加 FIFO 硬上限 + hit() 命中移队尾(淘汰序 FIFO→LRU),
  修「一次性 key 喷洒把热点 key 计数冲掉、限流被绕一截」;
· 角色翻转(协议违规:双角色挤一条连接)对称摘另一侧索引,
  修「索引留死 session 引用、反复违规无界增长」;
· relay 限速定时器覆盖前先取消,修「与背压 drain 交错时限速被突破一瞬」。

getOwnerId 加缓存(只缓存非 null —— 缓存 null 会造出"设备刚注册
2s 看不了"的拒绝窗口;owner_id 三个写点均不改非空值,已逐一实证);
claimDevice 成功后广播失效(纵深防御)。

实测(2000 帧突发 ×3 查看端,5+5 轮,tests/perf-bench.js):
墙钟 0.42s→0.21s、吞吐 ~4800→~9350fps(+95%)、p50 8ms→2ms、
p99 17ms→5ms、丢帧 0;稳态 68fps/订阅/offer 无差异。
tests/perf-bench.js:四场景基准(metrics 定速/突发/内存/服务端计数器、
churn 订阅风暴、offer 定向时延、scan 索引微基准),--json 机器可读。
存在理由:性能优化必须"前后同一把尺",不接受"应该更快了"。

本地四容器栈的镜像可能比仓库旧(曾吃过 09-15 镜像的亏:测的根本不是
当前代码)——工具注释里写明测前先 up -d --build 并用 docker exec grep
验证代码版本。本次基准就是这么发现容器缺 viewer:unsubscribe 的。

docs/perf-baseline-{before,after}-2026-10-14.json:5+5 轮完整存档。
结论:旧代码突发呈双峰(~4800/~9350fps 两档),新代码全快档;
中位墙钟 0.42s→0.21s、p50 8ms→2ms、p99 17ms→5ms、丢帧 0。

run-all.sh 接入三个离线套件(会话索引 50 / 登录限流 22 / 中继 30),
计数标签随实际断言数修正。
一个 pmon-app-1 容器跑 postgres + redis + gateway + business
(supervisord 各自守护、各自重启),外加跑完即退的 migrate。
合容器不合进程:两个 node 进程仍是独立进程,SIGTERM 直达原生优雅
关闭处理器,全部既有安全测试原样有效。

关键不变量(均有测试看守):
· compose project 仍是 pmon、数据卷仍是 pmon_pg_data/pmon_redis_data
  → 与四容器路径随时互切、数据不动(卷名锚点,改名=清库);
· 端口全部绑 127.0.0.1(云防火墙全端口放通,0.0.0.0=公网直露);
· init.sql「空数据目录才初始化」语义复用官方 entrypoint,不重写;
· 启动门 start-{gateway,business}.sh 复刻 depends_on service_healthy,
  防全新卷首启时 node 抢在 PG 初始化前 FATAL 停摆;
· 安全开关两个进程同读一份环境,一处传全。

dockerfile-coverage-test.js 扩展:「all」镜像双入口逐断 require 图、
安全开关通路按 compose 文件分别断言(含服务名映射)、新增第 6 段
端口回环断言(IP 前缀可选——裸映射 "3000:3000" 恰是绑 0.0.0.0 的
最危险写法,第一版正则漏了它,审计加固)。

代价如实记入 runbook 7.3:进程隔离弱于四容器、重启四进程齐动、
business 启动参数比四容器多堆上限(D-1)。
lan_service.dart 的服务器基地址示例换成 203.0.113.7:3000 并注明
「示例用文档段地址,勿写真值」。历史重写(tree-filter)已把含真实
IP 的三个提交改写,工作树这处是同一脱敏的最后落点。

privacy-scan 的公网 IPv4 规则加窄放行:172.15.0.1 / 172.32.0.1
两个精确串是 172.16/12 私有段判定的边界探针(tests/lan-endpoint-test.js
与 client/test/lan_service_test.dart 用),它们存在的意义就是
「形状是公网 IP 但不是私网」——换成文档段地址就测不到「把 172 整段
误判成私有」的回归。只放行精确串,不给任何真实地址开口子。

全量扫描(工作树 + 全部提交 + 提交信息):219 文件 / 59 提交零命中。
仓库审计(提交后)发现的三处文档漂移:
· README 快速开始缺单容器路径入口(runbook 第七节写了但门面没指路);
· README 验证段缺 perf-bench(性能口径工具,动热路径前后各跑一次);
· README 套件计数停留在 18,实际 25(本次全量跑出来的实数);
· rename-inkwatch.md 未登记 docs/ 下两个带旧名的历史方案文档
  (pmon-v4-架构方案-V4.0.html / 商业化实施方案V4.0.docx)——
  内容是 PMon 时代原文,刻意保留文件名作历史存档,登记备查。

TODO/FIXME 全库零命中;架构文档为纯设计定位,部署内容以 runbook
为准(第七节已含与 V4.0 第 11 章的互切说明),不强制对称。
用户定:除 README / LICENSE / .env.example 外,文档类文件只在本地保存,
除非项目运行离不开才提交。docs/ 全部 11 个文件(架构方案、专项设计、
腾讯云运维篇、改名记录、性能基准 JSON、两份旧名历史存档)无一是运行
依赖(引用全是注释与文档间链接),整体移出。

本地存档:~/WorkBuddy/水墨屏工具/inkwatch-docs-local/(412K)。

同时清掉 patch_relay_v2.py —— 一次性字符串手术脚本(给旧 relay 修复
用),现在跑只会损坏 relay.js,属运行无关物。

连带修正(不留悬空引用):
· README:目录树去 docs/、单容器取舍改为内联、perf-bench 数字改本地存档
· client/README.md:三处 docs 引用去路径留实质
· eink_board.dart / config_migration.dart:注释去路径留实质
· docker-compose{,.all,.prod}.yml:三处"详见 docs/…"改为内联关键纪律
· privacy-scan.js:quotepath 判据的例句随中文名文件消失而改写,判据保留
· .gitignore:加 docs/ 与 patch_*.py 段(政策机械固化,防再误提交),
  顺手去掉尾部重复的 .git-rewrite 条目

验证:隐私扫描 207 文件零命中;镜像覆盖 43;Dart 392 全过;
全仓库 docs/ 悬空引用为 0。
统一应用多模式连接设计的第 0 阶段(纯收口,行为零变化):

· net/probe.dart(59 行)+ net/offer.dart(246 行):从 host/lan_service.dart
  原样抽出探测(probeLanEndpoint/pickReachableLanEndpoint)与 offer/ticket
  编解码(LanIncomingOffer/LanTicket/parse*)。原文件只留 export 转出,
  既有调用方(viewer/transfer_card/测试)零改动——全量 409 项证明纯搬迁。
  这是为四驱动共用做准备:探测与邀约解码只许有一份。

· link/link_mode.dart:四种模式(云端/内网/P2P/中继)的词汇与可解释评分
  (100 − RTT/30 − 丢包×2 − 成本权重,钳 0–100)+ 迟滞常数 8 分。
  成本进分数是刻意的:轻量服务器按流量计费,不把成本写进排序,
  "自动选路"就会在半夜把流量包烧光。

· link/link_hub.dart:每(会话 × 对端)一实例的连接枢纽。**P0 只观测、
  只建议、不控制**——数据面仍走 ViewerSession 老逻辑。推荐变化经既有
  onLog 诊断出口可见("先让走哪条路变成可验证的事实,再去动数据面")。

· host/viewer.dart:十一处观测点接线(+18 行纯插入、零删除):云端连/断/
  连接失败、LAN 探测结果、LAN 数据面开/关、两条摄取路径的帧来源、stop 时回收。

测试:link_hub_test 16 项(评分三部件逐一钉住/迟滞边界恰好 8 分不换 9 分换/
活跃路追踪/observeRtt 不凭空造度量/表恒 ≤4 有界);viewer_test +1 接线测试。
变异自证 ×4:删迟滞 → 2 红;坏评分公式 → 4 红;摘云端观测 → 1 红;恢复即绿。

全量:Dart 409 项全过;viewer-e2e 真集成 21 项全过(真网关真会话)。

与设计的两条偏差已记入本地设计文档 §9(TransportDriver 接口推迟到 P1
等第一个真实现者;P0.3 以事件旁路接线代替驱动实例)。
P1 阶段的结论先行:**「中继端到端未打通」是过时批注**——对活网关实测,
服务端三条路由与客户端三个函数一直是通的(open 200 → PUT/GET → 字节
全等)。真缺的是自动化验证与代码归位。本轮补上:

· net/relay_client.dart(227 行):LanDownloadResult / receiveToFile /
  LanRelayChannel / openLanRelay / uploadViaRelay / downloadViaRelay 从
  host/lan_service.dart 原样抽出(纯搬迁,export 转出,调用方零改动)。
  这样 link/ 不必反向依赖 host/,分层纪律不破。

· link/driver.dart:TransportDriver 契约(mode / probe / close)。P1 的
  RelayDriver 是第一个实现者,契约按真实需要定;数据面方法故意不立
  (四种模式传输形态差异仍大,此时统一只会造空壳适配器)。

· link/relay_driver.dart:探活走 GET /health(**不烧配额**——探活若走
  open 会占满"每账号并发 1"的闸,选路一频繁用户就永远"请等前一条");
  openForOffer 委托 openLanRelay;upload/download 委托既有函数。
  host 的 openRelayForSend 改为仅注入凭据、逻辑全交驱动。

· tests/relay-route-e2e.js(14 项,需网关):打真路由验八组——
  完整往返 / 401 缺身份 / E_BAD_SIZE / E_TOO_LARGE / E_TOO_MANY(并发 1)/
  超声明即断连且不超量 / 错向令牌 404。relay-test.js 测不到"路由怎么接
  进来"(曾出过类全绿、路由 503 的事故)。

· client/bin/relay_check.dart + tests/relay-client-e2e.js(6 项):跑客户端
  自己的 Dart 实现打真网关——这是「服务端通 + 客户端函数在」真的连起来
  用的唯一证据。CLI 输出机器可读 JSON,e2e 断言字节数与 sha256。

· client/test/relay_driver_test.dart(7 项,真起本地 HttpServer):
  health 200+ok / degraded 200+ok:false 按不可达 / 500 / 端口没人听 /
  缺凭据抛人话 / 409 中文原因原样上抛 / close 幂等。
  最关键一条:**探活只许打 /health,绝不许打 /relay/open**(变异自证:
  改成打 open → 精确变红)。

全量:Dart 416 项全过(409+7);run-all 26 通过 / 3 失败——3 个失败中
2 个是既有(指标字段一致性 / FFI 冒烟的 Rust 事件漂移),第三个是隐私
扫描(另一笔提交信息含本机 denylist 词,已在下一轮处理)。
能力就位(不是端到端打通互联网 NAT —— 见文末诚实边界):

· net/candidates.dart:host/srflx 两类候选(relay 类别不做——本项目的中继
  是独立通道,再做 ICE relay 候选等于再造一套)。上限 8、按 endpoint 去重、
  脏项逐条丢:打洞器会对候选发包,脏地址等于替攻击者打探测。

· net/stun.dart:RFC 5389 最小 Binding 客户端(零新依赖,约 100 行纯逻辑)。
  只做两件事:发请求、解 XOR-MAPPED-ADDRESS。安全要点:校验事务号
  (防串包投毒)、只认 Binding Success、截断不抛。

· link/p2p_driver.dart:UDP 打洞。同时向所有候选发 PUNCH:<nonce>,
  收 PONG/PUNCH 即成功并回 ACK;先通先赢,死候选拖不住活候选。
  **非 PUNCH/PONG 的包一律忽略**——否则一次随机 UDP 扫描就能让
  "打洞成功"变成谎言(有专门用例守着)。对称 NAT 打不通如实返回失败。

· offer 管线:candidates 作为增量元数据随 agent:offer/viewer:offer 传递;
  服务端 normalizeCandidates 校验(host 只收私网、srflx 只收公网、
  拒回环/云元数据、上限 8),不参与"三条路至少一条"的判定。

· schema-consistency 第 13 段:MAX_CANDIDATES ↔ kMaxCandidates 相等、
  类型词汇一致、srflx 私网禁令在位(跨语言护栏)。

测试:Dart 436 全过(416 + STUN/候选 12 + 打洞 7 + offer 解析 2);
lan-endpoint 41 项(+14 候选);run-all 25 通过 / 2 失败(仅剩既有
Rust 事件集漂移)。变异自证:摘 host 私网禁令 → 恰 1 红。
网关容器已重建确认是新代码(grep normalizeCandidates = 2)。

诚实边界:真实跨网打洞成功率取决于双方 NAT 类型;对称 NAT 必失败→落中继。
数据面接入(P2P 承载指标/文件)属 P3 编排。
· link/handover.dart:切换状态机 idle→decided→proving→overlapping→
  retiring→done,失败 aborted(旧路保持)。不含定时器——超时由调用方
  isStalled() 判,测试不必等真时间;跳跃/倒序一律忽略(宁可停住也不
  做半截切换)。接入 LAN 进/出两个方向:探测证通即进重叠期,首帧到达
  才退役旧路。时序与既有行为一致,只是把散落 if 变成可读相位。

· LinkHub.setPreferredMode():手动覆盖跳过评分与迟滞(人的判断优先于
  算法);指定的路不可达时回落自动并在日志说明——不能因为用户选了
  一条不通的路就装死。

· ViewerSession:linkSummary / preferredMode / setPreferredMode;
  ViewerStatus 加 linkLabel / linkDetail;强制云端时禁用内网自动切换。

· UI:顶栏徽标「在线/断开」升级为「在线 · 内网」,点击弹菜单
  (自动/内网/云端/P2P/中继),勾选回显当前偏好;全走主题令牌。

★ 过程中抓到并修掉一处真事故:最初用 hub.changes.listen → _setStatus
  做同步,导致 widget 测试内存暴涨被系统杀(SIGTERM 137,且**回退页面
  改动后仍复现**才锁定是会话侧)。根因是"流订阅→状态更新→页面重建"
  在有周期重建的页面里形成失控环。改为三处显式同步(云端连上/进内网/
  离内网)后正常。教训已记入设计文档 §9。

测试:Dart 445 全过(+Handover 6、+偏好覆盖 2);页面 28 项回归;
run-all 25 通过 / 2 失败(仅剩既有 Rust 事件集漂移)。
P3 留下的边界("P2P 是能力而非在用通道")本轮闭合。

· P2pSession(并入 link/p2p_driver.dart):打洞成功后**保留 socket**——
  NAT 映射绑在本地端口上,关掉就废、重开等于重打一次洞。帧协议单包:
  PMON-FRAME-REQ:<nonce> → PMON-FRAME:<nonce>:<json>,payload 与云端
  server:metrics 同形(查看端两套数据源共用一套解析)。nonce 必须回显
  (迟到/串包丢弃);单包上限 8KB,超了宁可不回也不发分片包。

· 采集端:收到带候选的 offer → 开数据面 → startServing(() => _latestForLan)
  (与局域网直连同一份数据,不新造缓存)。

· 查看端:收到带候选的 offer → 开数据面 → 2 秒节拍拉帧 → seq 去重后走
  同一条摄取路径 → hub 记 LinkMode.p2p;连续 3 次无响应即关闭回落。

· LinkHub 加 observeP2pProbe(P2P 的分数进选路,打通与否如实记录)。

★ 两个坑(都已修,且值得记):
  1. RawDatagramSocket 是单订阅流——"打洞一个监听、数据面换一个监听"会抛
     "Stream has already been listened to",异常被 catch 吞成 null,表现
     为"打洞无声失败"。只能一个监听器活到底、按前缀分派。
  2. 测试辅助函数把对端候选端口写死,参数只改了本地端口 → 只有第一组
     恰好对上,其余全挂。辅助函数必须全参数化。

测试:Dart 451 全过(+会话 6 项);变异自证(响应 nonce 写死 → 拉帧用例
精确变红);run-all 25 通过 / 2 失败(仅剩既有 Rust 事件集漂移)。

限制(诚实):候选目前随文件 offer 交换,故指标走 P2P 发生在该对设备
发过一次文件之后。要"订阅即可 P2P"需把候选上报挪进 agent:hello/快照,
属协议扩展,下一轮做。
首次真跑(腾讯云切换 all-in-one)时抓到:migrate 连 -h 127.0.0.1 却
在自己的网络命名空间里,pg_isready 无限循环、迁移永远不跑。
四容器时代用服务名 postgres 所以没暴露;单容器化时漏了这条。
加 network_mode: "service:app" 后 migrate Exited(0)。
改 name: pmon → inkwatch,让容器名与项目名一致。
★ 关键防呆:name 同时是**卷名派生源**,故 volumes 段显式锁定
  pg_data: {name: pmon_pg_data} / redis_data: {name: pmon_redis_data}。
  不锁的话 compose 会去找 inkwatch_pg_data 这套新空卷 = 清库。
实测:切换后卷仍只有 pmon_pg_data / pmon_redis_data(无 inkwatch_*),
devices=6 不变,migrate Exited(0),gateway/business 200。
镜像同步改名 pmon-all → inkwatch-all(docker tag 复用,不重建)。
5432 原本只为本机 psql 直连排障而发布,收益仅是少敲一次 docker exec,
却让宿主多开一个可连的口子。改由 docker exec 进容器排障。

实测:docker port 只剩 3000/4000(均绑回环),devices=6、gw/biz 200。
对外只发布一个口:3500 = 容器内 nginx 统一入口
  /api/  → business(4000)
  其余   → gateway(3000)(socket.io / health / relay)

· deploy/nginx-all.conf(新):容器内入口配置。关键细节:
  - /healthz 本地直返(区分"入口挂了"与"后端挂了")
  - ★ X-Forwarded-For **透传**而非追加:若用 $proxy_add_x_forwarded_for,
    XFF 会变成 "client, 127.0.0.1",而 business 的 TRUST_PROXY_HOPS=1
    取到的就是 127.0.0.1 —— 所有客户端在配对码限流看来是同一个人。
    故用 map:上游有 XFF 就原样透传(hops 语义不变),无才补 remote_addr。
  - relay 必须 proxy_request_buffering off + proxy_buffering off(否则
    上传体被缓冲到临时文件,"不落盘"是假的)
· Dockerfile.all:装 nginx、整份替换 nginx.conf、EXPOSE 只留 3500
· supervisord:加 [program:nginx](QUIT 优雅退出)
· healthcheck-all:加第 5 项(入口 /healthz),入口挂=全站挂,必须一起判
· compose.all:ports 只留 "127.0.0.1:3500:3500"
· openresty-pmon-ip.conf:由"按路径分反代 3000/4000"简化为**单一 upstream 3500**
  ——"哪个路径给哪个进程"只在容器内定义一次,接入层不再有第二份分流清单
· dockerfile-coverage 第 6 段:断言由"≥3 条端口"改为"必须有 3500 且
  **不得发布 3000/4000/5432/6379**"(比原来更严,防绕开统一入口)

实测(现网):docker port 仅 3500;五进程 RUNNING;healthy;
3500 上 /healthz ok、/health 通、socket.io 握手拿到 sid、relay 往返 2048 字节全等;
经 80(OpenResty→3500)再验 relay 128 字节全等。
变异自证:删端口 → 精确 2 红;恢复 → 45 全过。
按评估结论(性能无压力 p99 5-11ms/CPU 0.09%,真风险是"挂了没人知道")
做的三处低成本加固,不改架构:

· PG 日志前缀 + 慢查询(migrate.sql / init.sql,ALTER SYSTEM 幂等):
  - log_line_prefix = '[postgres] %m [%p] ':单容器五进程共用一个日志流,
    PG 原来无标识,排查要靠猜;现在与 [gateway]/[business]/[supervisor] 对齐
  - log_checkpoints = off:每 5 分钟两条 checkpoint 是纯噪音
  - log_min_duration_statement = 200:**慢查询直接落日志**,不依赖
    pg_stat_statements(免装扩展、免重启),是排查"这次为何慢"最省事的抓手
  走 ALTER SYSTEM 而非改 supervisord command:%m/%p 里的 % 会被
  supervisord 的 %(ENV_x)s 插值解析,绕开这类坑。

· healthcheck-all.sh:set -e 改为**逐项 chk + ✓/✗ 输出**。
  原版一挂就退出,docker inspect 的 Health.Log 里只有空输出——
  unhealthy 时看不出是哪一项,等于把排查成本转嫁出去。

· deploy/watchdog.sh(新):每 2 分钟探 /healthz + /health,
  **只在状态变化时**写日志(DOWN/RECOVERED),可选 INKWATCH_ALERT_CMD
  外部通知。装法:/usr/local/bin + /etc/cron.d/inkwatch-watchdog。
  坑:手动以普通用户跑会 Permission denied → 预建 /var/log 与
  /var/lib 下的文件并 chown 给运维用户(cron 以 root 跑不受影响)。
  坑:curl 连接失败时 -w 已打印 000 且退出码非 0,再 `|| echo 000`
  会拼成 "000000" → 改为"输出为空才兜底"。

实测:
- PG 参数生效(show 三项确认)、日志里 checkpoint 噪音消失、
  business/gateway/supervisord 行清晰可辨
- 健康检查输出五项 ✓
- 看门狗:正常 exit 0 / 故障 exit 1 并记 DOWN / 持续故障不重复写 /
  恢复记 RECOVERED;crond 在跑,cron.d 已装
- schema-consistency 100 项(2 个既有 Rust 事件漂移)、
  dockerfile-coverage 45/0、privacy-scan 零命中
生产压测抓到的真缺陷(**统一入口引入**,四容器时代无此层):

现象:经 3500 传 1MB 中继,只收到 130699 字节就挂死;直打 3000 完整。
根因:容器内 nginx 配了 upstream keepalive 池,却在非 WebSocket 请求上发
`Connection: close`(map 的默认分支写成 close)。两者语义冲突——连接被
标记关闭却要归还到池里,导致上游响应流在 ~128KB(正好是 nginx 的
proxy_buffer 量级)处停住。
修法:map 的 `''` 分支改为空字符串(保持 keepalive 复用),WebSocket 仍 upgrade。
这是 WebSocket + keepalive 并存的标准写法。

修复后:1MB 经 3500 完整传输 0.433s(与直打 3000 一致)。

同时新增 tests/bench-srv.py(生产压测,三维度:HTTP 吞吐 / socket.io 握手 /
中继传输),用共享容器网络命名空间跑,只读不写库。

压测结果(现网 2 核 3.5G):
· HTTP /health:1000 请求 / 1.21s = **827 QPS**,p50 22ms / p99 66ms
· socket.io 握手:200 次 / 0.15s = **1369 QPS**,p50 6.5ms
· 中继 1MB:3.05 MB/s(限速目标 8MB/s 的 38%)
· 中继 10MB:**异常**,见下
· 容器 CPU 峰值 **113%**(2 核吃满 1 核以上),内存 170MB

⚠️ 遗留缺陷(已确认、未修,需专门一轮):
中继传 **>约 2.6MB** 时吞吐骤降到 60-180KB/s(curl 对照一致,非压测脚本问题)。
已排除三个假设:容器内 nginx(本轮已修 128KB 卡点)、限速常量(放大 128× 仍慢)、
背压 pause(摘除仍慢)。剩余嫌疑在 gateway 的 pipe/写入模型。
这是**既有缺陷**(直打 3000 同样复现,四容器时代即存在),非本轮引入。
背景:生产压测确认中继 >1MB 时吞吐崩塌(2MB 传 20s 只到 139KB)。
缺陷**本轮未修好**,如实记录尝试过的五条路都失败(详见 relay.js 注释与 runbook):
  ① 放大限速 128× → 仍慢;② 摘背压 → 仍慢;③ 背压改 once('drain')
  → 仍慢;④ 完全去掉限速 → 更差(139KB 卡死);⑤ 改 pipeline() → done()
  依赖的 onDrain 被删导致 /relay/open 崩,已回滚。
已排除:容器内 nginx(另修 128KB 卡点)、body-parser(PUT 路由无)、
TCP/内核(容器内纯 HTTP 4MB 响应仅 497ms)。

因此在根因修好前收紧上限:
· MAX_ONCE_BYTES: 2 GiB → **1 MiB**(实测稳定边界:1MiB 337ms 完整,
  2MiB 卡)。可用 RELAY_MAX_BYTES 覆盖,修好缺陷后调大即可、不必改代码。
· 报错文案改 **humanBytes() 自适应单位**(原来写死 "GiB",上限一变就错)。
  文案里给出路:"较大的文件请用局域网直连"(LAN 直连不走中继、不受此限)。
· compose/.env.example 透传 RELAY_MAX_BYTES。
· relay-route-e2e 的 E_TOO_LARGE 断言**解耦单位**(匹配「上限」二字,
  不再绑死 GiB —— 否则改上限就假红)。

现网验证:恰好 1MiB → 完整传输 337ms;1MiB+1 → 409 + 人话;2MiB → 409;
健康检查五项全过。
★ 这是**客户端连不上**的真凶,也是我上一轮报告里说"仍然待办"的那条。

根因:1Panel 的 WAF 在 http 级注入
  access_by_lua_file /usr/local/openresty/1pwaf/waf.lua
且**只放行登记在 1Panel 站点列表里的域名**。pmon 的接入层是手工落盘的 conf
(不在 1Panel 站点列表),于是所有走 upstream 的请求一律 403;
而 /healthz 是本地 return 200,**反而正常** —— 极易误判成"服务是好的"。

修法:在 pmon 的 server 块里写 `access_by_lua_block { }` 覆盖 http 级指令。
只对本站跳过 WAF,rbook/searxng 实测不受影响(502/301 原样)。

★ 途中踩的两个坑(都已固化进脚本,防止再犯):
  1. **仓库模板的 server_name 是脱敏占位** 203.0.113.10。直接 scp 覆盖生产
     → Host 不匹配任何 server → 落到 1Panel 的 default_server → 又被 403,
     且 /healthz 仍 200,看起来像"生效了"。我第一次就是这么被骗的。
  2. 只替换 server_name 不够:注释里还有一处占位(客户端指向的地址文案)。

新增 deploy/apply-openresty.sh:
  · 全文替换脱敏 IP(可用 --dry-run 预览)
  · **自检生成文件里不得残留占位 IP**(残留就报错退出,不写生产)
  · 自动 nginx -t + reload + 回环自检(打印 /healthz 与 /health 的状态码)

现网验证(从另一台外部机器 <EXTERNAL_HOST>):
  /health → 200,socket.io 握手 → 拿到 sid + upgrades:["websocket"]
  rbook → 502(其自身后端未起)、searxng → 301(均未受影响)
体检发现护栏自身有三处缺陷。护栏是后续所有改动的前提,故先修它。

1) schema-consistency-test.js 第 13 段被误包进 if (fail > 0)({ 在 643 行、} 在 668 行),
   全绿路径下那 7 条断言从不执行、脚本照样返回 0;而汇总行又打印在它之前,
   即使执行也不计入统计 —— 护栏看起来在工作,实际没有。
   改为:第 13 段无条件执行 + 汇总移到文件末尾 + 退出码按失败数返回。
   实测 100 通过/2 失败 → 107 通过/0 失败(多出的 5 条正是此前从未跑过的)。
   变异自证:把 candidates.dart 的 kMaxCandidates 8 改成 9,本套件 exit=1 并报
   「上限值一致」;改回即恢复绿。修复前此变异根本测不出来。

2) Rust 与 JS 事件清单漂移:transport.rs 上行 4/下行 6,protocol.js 上行 7/下行 8。
   Envelope::validate() 会拒绝表外事件报 E_BAD_TYPE,属潜伏故障。
   补齐 5 个事件并把长度常量改为 4→7、6→8,同步 transport.rs 里写死数量的单测。
   P2P 入口已下线但代码保留,故事件表按完整集合补齐;代码注释已记明:
   将来若物理删除 P2P 代码,必须同步删掉这三个事件,否则就是给死代码陪绑。

3) theme_guard_test.dart 的 _uiFiles 漏登记 transfer_card.dart(630 行) 与
   skeleton.dart(111 行),共 741 行界面代码不受护栏保护。已补登记。
   两文件本身无硬编码色值,登记后直接绿。

新增 tests/guard-structure-test.js(36 项):汇总行必须晚于最后一个断言 + node --check。
刻意不采用"process.exit 只许一次"(实测 12 个文件各有 2-5 次,前置依赖缺失时早退正当)
与"汇总行缩进为 0"(本仓有脚本主体包在 main() 里,属正常结构)。
变异自证:造一个复刻上述形态的探针,守卫报「汇总@17 最后断言@20」并 exit=1。

验证:run-all.sh 28 套件 27 通过/1 失败(唯一红为隐私扫描的存量公网 IP,另行处理);
cargo test --release 59/0;flutter test 451/0。
顺带订正 run-all.sh 头部过时计数(指标一致性 99→107、Dart 416→451),
并注明计数以实跑为准 —— 静态数 test( 会漏掉 for 循环内动态生成的用例。
P0-6。此前 pmon_install.dart 遍历 install_commands 时,非零只打印一行就继续;
循环结束后**无条件**打印「安装完成」并 return 0。于是 launchctl bootstrap /
systemctl --user enable --now 全部失败,安装器照样宣布成功。

根因在 :137 那行注释:"bootstrap 已加载时 bootout 会返回非零,属预期,不算失败"。
作者为了容忍 bootout 的预期非零,把 bootstrap 的非零也一并容忍了。

危害不是文字不对,而是**进程退出码失去意义**:远程部署脚本、CI 真机构建、
用户自己写的 supervisor 包装,全都只能拿到 0。叠加 P0-1 的 GUI 一键安装,
就是"点一下永远显示成功"——用户信了绿勾就不再自查。假成功比失败更糟。

修法(关键约束):
- Rust 的 AutoStartPlan 里,install_commands / uninstall_commands 从裸
  Vec<String> 改为 Vec<AutostartCommand>{cmd, allow_failure}。
  语义随命令一起产出,**不在 Dart 侧按命令文本猜**——contains("bootout")
  这类白名单等于把同一份语义写在第二处,本项目已因此翻车五次。
- 语义分配:幂等前置动作与增强项 allow_failure=true
  (bootout 未加载时必然非零、loginctl enable-linger 受限环境可能无权限);
  对外承诺 allow_failure=false(bootstrap、systemctl enable --now、
  reg add、schtasks /Create)。卸载侧一律容忍"删本来不存在的项"。
- Dart 侧:install / uninstall / restart 三处只要有必成失败即 return 非零;
  --no-start 未指定时轮询最多 3 秒等进程起来,没起来也算失败(原先只打印 ⚠)。
- restart 里 `id -u` 从平台判断之前挪到之后——Windows 上没有 id 命令,
  原顺序会先抛 ProcessException 而不是给出明确提示。

为什么把执行逻辑抽到 lib/host/autostart_runner.dart:
bin/ 下的私有函数无法被 flutter test 覆盖,而"安装器会不会撒谎"恰恰最该被测住。
抽出后判定逻辑可注入假 runner,新增 autocstart_runner_test.dart 12 项,
含一条回归守卫:全部命令失败时 ok 必须为 false(即 2026-10-25 的缺陷形态)。

新增 Rust 不变量测试:每个桌面平台的 install_commands 至少一条必成,
否则安装失败无从判定(防后来把语义整体宽化改回去)。

验证:cargo test --release 60/0(+1);flutter test 463/0(+12);
dart analyze lib bin 无问题;run-all.sh 28 套件 27 通过/1 失败
(唯一红仍为隐私扫描的存量公网 IP)。
⚠️ 如实说明:CLI 层的退出码接线只经单测与代码审查覆盖,未做真实 install 验证
—— 那会写用户的 ~/Library/LaunchAgents 与真实配置,不宜在本机跑。
真机验证应在测试机或装完立即 uninstall。
E1(网关重启后补传帧重复入库)的第一段:把 session_id 落到库。

## 做了什么
- device_metrics 加 session_id 列(init.sql 建表 + migrate.sql 迁移,两条路径同源)。
- gateway 入队指标帧时带上 session.sessionId —— 此前 806 行已经在用这个值判重,
  但没往下传,所以 DB 侧永远拿不到它。
- repository 的 INSERT 列清单加 session_id,并补 ON CONFLICT DO NOTHING。
- 固定头列清单(ts/device_id/seq/session_id)收到 metrics-schema.js 的
  METRIC_TABLE_HEADER_COLUMNS,仓储写入与一致性测试都引用它。

## 关键发现:原定的"DB 唯一索引"方案在本项目行不通
架构评审给的方案是建 (device_id, session_id, seq) 唯一索引。我在真实部署形态
(本机 Docker 的 TimescaleDB,device_metrics 有 262 万行)上实测,直接报错:

  ERROR: cannot create a unique index without the column "ts" (used in partitioning)

原因是 device_metrics 是 **TimescaleDB hypertable** —— pg_inherits 里挂着 6 个
_timescaledb_internal._hyper_1_*_chunk,hypertable 按 ts 分区,而 PostgreSQL
硬性要求分区表上的唯一索引必须包含分区键。把 ts 放进唯一键同样不行:
ts 由服务端改写,补传同一帧时 ts 不同,唯一键就失去幂等语义 ——
骨架期那条"不加唯一约束因为 ts 会被改写"的注释想说的正是这点,
它错在把结论下成了"所以不能建",而正确结论是"唯一键不该含 ts,改由应用层承担"。

顺带纠正一个认知:pg_relation_size 在本机报 0 bytes 是 Docker Desktop
VirtioFS 的统计怪象,262 万行是真实的(count(*) 45ms,全在 page cache)。
排查时不要被那个 0 带偏。

## 因此 ON CONFLICT DO NOTHING 是"安全网"而非本层去重手段
现在是 no-op(没有唯一约束)。留着是因为 PG 的 INSERT 是语句级原子:
一批 5000 帧里只要有 1 行撞约束,整条语句抛错、_flushStorage 只 catch 掉
打一行日志 → 整批(含合法帧)全丢。将来若在普通 postgres 分支补上约束,
没有这一句会立刻变成"一条脏数据毁掉整批"。

## 尚未完成(如实标注)
真正的幂等防线要挪到**网关侧持久化去重窗口**(原进程内 Map 重启即清空)。
方案已定:seq 窗口按 (deviceId, sessionId) 落 Redis,用 ZADD NX 的返回值
做原子判重,并并入 pushFrameAndFanout 那个已有的 multi —— 不新增往返
(本项目历史教训就是每帧多次 Redis 往返把帧率上限按死了)。
但这次没做完:_isDuplicateSeq 目前是同步函数、被 seq-session-dedup-test.js 的
28 项直接测,改成异步会波及那批测试,需要单独一轮谨慎处理。

## 验证
本机 TimescaleDB 实测:ALTER TABLE ADD COLUMN 3.67ms(PG16 元数据操作,不重写表);
自带的去重 CTE 在有 262 万行的表上耗时 2.85s(seq scan),这也是为什么
"部分索引只覆盖新数据"的思路值得 —— 但 hypertable 不支持,最终没用上。
schema-consistency 107/0;seq-session-dedup 28/0;run-all.sh 28 套件 27 通过/1 失败
(唯一红为隐私扫描的存量远端引用,本地历史已脱敏干净,待推送后消除)。
问题:桌面端有两种合法采集形态 —— GUI 内建(关窗即停)与无界面常驻 agent。
实测确认:加这把锁之前代码里**没有任何跨进程保护**(PmonCore.instance 只是进程内
单例,对另一个进程毫无约束)。两者同时跑会双写同一 device_id:各自 seq 从 1
独立递增,服务端的 seq 去重窗口把两条流混在一起判重 → 丢帧或重复帧,
历史曲线出现台阶与毛刺,且**不报任何错**。

方案 A+C(产品侧确认):
- A(兜底):新增 lib/host/agent_lock.dart,用 dart:io 的 FileLock 做跨进程
  互斥。agent 启动时 tryAcquire(),拿不到就退出。
- C(体验):GUI 启动采集前 isHeldByOther(),为真则不启动自己的采集,
  只做查看,并明确提示"后台常驻采集已在运行,本界面仅查看"。
  而不是静默地少采一半数据。

三个关键细节:
1. agent 抢不到锁必须 **exit 0**。launchd 的 KeepAlive.SuccessfulExit=false
   语义下,非零退出会被当成崩溃并立刻重启 → 两个进程互踢,无限重启循环。
2. 用 FileLock.exclusive(非阻塞)而不是 blockingExclusive:后者会让第二个
   采集进程一直挂着等,用户看到的是"卡住不动"。
3. 锁文件放 ConfigStore.dir(与 config.json 同目录),不另算一份路径 ——
   本项目"同一份路径写两处"已翻车多次,安装路径只能由一处派生。

为什么用文件锁而不是 pid 文件 / 命名管道:
- 不依赖平台:dart:io 的 FileLock 在 macOS/Linux/Windows 通用,
  不必为三个平台各写一套,也是多端扩面的最低成本路径。
- 不会留"僵尸锁":记录锁随 fd 由内核释放。pid 文件方案在 kill -9 后会留下
  死 pid 永久阻塞下一次启动。

验证:
- 单测 6 项(获取/释放/幂等/残留空文件不算占用/探测是只读的/路径口径)。
  其中"探测后别人仍能加锁"守的是最隐蔽的一种自锁 —— isHeldByOther 若忘了
  unlock,查一次就把采集永久锁死。
- **跨进程真实验证**(单测覆盖不到,因为 POSIX 记录锁是进程级的):
  用 Python fcntl.lockf 持锁(与 Dart 的 FileLock 同为 F_SETLK,可互通),
  Dart 侧得到 BLOCKED: Resource temporarily unavailable;
  外部释放后再测得到 ACQUIRED。双向都验过,排除"恒失败"的假绿。
- dart analyze lib bin 无问题;flutter test 469/0(+6)。
## 缺陷
`gateway._seqWindow` 是进程内 Map。网关重启窗口即清空,而客户端 session_id 不变、
会重发最多 120 帧补传 → 全部被判成新 seq → 重复入库 → 历史回溯的均值被慢慢拉偏,
且**不报任何错**(只翻库才看得见 seq 重复)。实测复现:10 帧正常入库后重启网关、
同会话重发同 10 帧,`device_metrics` 由 10 行变成 **20 行**(修复后仍为 10 行)。

## 为什么不能放 DB 层(实测已排除,不再试)
device_metrics 在 TimescaleDB 分支上是 hypertable(按 ts 分区),PG 要求分区表上的
唯一索引必须包含分区键,实测报:
  ERROR: cannot create a unique index without the column "ts" (used in partitioning)
而 ts 由服务端改写(同一帧补传时 ts 不同),放进唯一键就丧失幂等语义。
详见 deploy/init.sql 中 idx_metrics_device_seq 上方的注释。

## 改法(不新增任何 Redis 往返)
- store.pushFrameAndFanout(deviceId, frame, dedup):签名加可选第三参,null 时行为与
  改动前完全一致;去重命令**追加在原来那一个 multi 末尾**,返回值改为
  { subscribers, isNewSeq },并改用显式索引(原 res.length-1 会指错项)。
- gateway.onMetrics:按 `seqwin:<device>:<session_id>` 传 dedup。键里带 session_id
  是刻意的 —— 新会话 = 新键,天然实现"换进程就重置窗口"。
- gateway._hydrateSeqWindow:内存窗口不存在时(网关刚重启/设备首次出现)用
  zRange 从 Redis 读回最近 N 个 seq;在 onHello 里 await,**每设备只查一次**。
- 判据优先级:Redis 的 isNewSeq 优先(跨重启权威),内存窗口为兜底(零往返,
  Redis 不可用时用它挡住同进程重复)。
- 裁剪窗口与续 TTL 按 32 帧节流(首帧必发,保证键生下来就带 TTL)。理由见实测:
  三条命令每帧都发时突发 p99 由 6ms 升到 13~15ms(Redis 单线程,突发约 1 万 multi/秒,
  瓶颈是命令条数);节流后每帧 8 条命令,p99 回到改前水平。判重正确性不受影响。

## 失败语义:与配对码**相反**,别"统一"
配对码守的是安全边界(判定不了就关门,失败关闭);去重守的是数据正确性
(判定不了就**放行入库**,宁可偶发重复一行,不可整段历史丢数据)。
故 pushFrameAndFanout 抛错时 catch 住、`_dedupDegraded++`、warn 日志、
把帧照常入队落库,并在 /health 暴露 `dedup_degraded`。代码注释里写明了这个区别。

## 顺带发现并处理(请复核)
无 session_id 的老客户端(1.0.8 及更早)**刻意不落 Redis 窗口**:
没有会话标识就无法把窗口划到会话作用域,若按 `nosession` 建持久窗口,
老客户端进程重启后 seq 从 1 重来 → 全部命中旧窗口 → 被判重复丢弃,
正是当年"永久哑火"事故(这次上界是窗口 TTL,但一小时无数据同样是丢数据)。
实证:去掉这条守卫后连跑两次 tests/persistence-restart.js,第二次
"落库行数与上报帧数一致" 变成「期望 30 实得 0」。现行客户端
(client/lib/host/link.dart 的 _sessionId 每进程生成)不受影响。

## 新增套件 tests/dedup-restart-test.js(22 项,已接入 run-all.sh)
① 重启后同会话重发同批 seq → 行数不变(E1 修复目标)
② 新会话从 seq=1 重发 → 必须能入库(win.sid 历史事故的回归守卫)
③ Redis 不可用 → 帧仍落库 + dedup_degraded 增加 + 补齐不抛
④ 无 session_id 不建 Redis 窗口(见上)
变异自证:把 dedup 传参改成 false(等价"没接线")→ ①② 相关 3 项全红,
恢复后 22/22 绿。

## 验证
- tests/run-all.sh:28 通过 / 1 失败(唯一红 = 隐私扫描的公网 IP 项,本次未触碰)
  其中 持久化与重启恢复 24/24、去重跨重启 22/22、Rust 60/0、Dart 469/0
- cargo test --release:60/0(连跑 3 次;run-all 首跑偶发
  transport::tests::backoff_grows_... 抖动一次,见回报)
- client flutter test:469/0

## perf-bench 前后(同机同一脚本,本地栈;含 3 次交错复测)
改前(HEAD ae90726)burst p50/p99 = 1/6、4/18、5/14、1/9 ms,recv 9434~9709
改后(本次)        burst p50/p99 = 1/4、1/6、1/6、2/7、3/7 ms,recv 9302~9569
steady(定速 100fps)p50 0~1ms / p99 1~3ms,两边一致;churn p50 3ms、offer p50 1ms、
heap 增量 34MB→31MB 均在噪声内。p99 中位数由 14ms 降到 6~7ms,无明显回退。
## 结论:断言过严(抖动实现本身正确,未改退避算法)
`apply_jitter_downward` 只向下扣 [0, capped*ratio],结果恒落在
[capped*(1-ratio), capped] 内,**绝不突破 max_ms** —— 上限这条硬承诺没被破坏,
抖动叠加方式也没有写错,不存在"源码 bug"那一路。
严格单调不成立是设计内的必然:封顶后 capped 恒为 60s,扣减量
offset = (now_ms() ^ capped.rotate_left(13)) % (span+1),每跨 1 毫秒 offset 就 +1,
于是后一个样本比前一个正好小 1ms(实测 59009 → 59008、50901 → 50900)。
原断言要求"相邻样本单调不减",等于要求这件不可能的事。

## 复现(真复现,非推测)
- 并行单跑该用例:48 次内命中
  `退避应单调不减: [... 57955, 57954, 57954 ...]`
- 变异回严格单调后,16 进程 × --test-threads=61 并发整包跑:第 1600 次命中
  `[... 50901, 50901, 50901, 50900 ...]`
单跑命中率约 1/1000(12 次调用里只有封顶区那段约 1µs 窗口可能跨毫秒),
这正是它"平时全绿、负载高才红"的原因。

## 改动
- transport.rs 主用例改按**档位表**断言(capped(n)=min(base*2^n, cap)):
  每个样本必须落在本档位抖动带 [level*(1-ratio), level];相邻样本回落不得超过
  一个抖动幅度(这才是"防退化"该守的不变量);末次必须真的顶进封顶带。
  注释写清为什么不能要求严格单调,防后人改回去。
- 新增 capped_backoff_dips_within_jitter_band:在封顶区**跨毫秒**采样,
  断言"必然观察到回落,且回落幅度 ≤ 一个抖动幅度",把"回落是正常行为"
  钉成可执行断言(而不是靠注释约定)。
- 新增 Backoff::base_delay_ms / max_delay_ms / jitter_ratio 只读访问器:
  测试不再重抄 1000 / 60_000 / 0.3,避免第二个真相源。
- buffer.rs 头注释 "±jitter" 与实现(只向下)不符,一并改正。

## 变异自证
1) 把 dips 用例的容差断言改成 `after >= before`(严格单调)→ 确定性变红 3/3,
   报文 `45363 → 45362`。
2) 把主用例容差断言改回 `w[1] >= w[0]` → 上述并发压测第 1600 次变红,
   特征与原报告一致;恢复后 500 次整包并发复跑全绿。

## 测试
改前 60 passed / 0 failed;改后 61 passed / 0 failed(净增 1 条)。
仅动 rust-core/src/buffer.rs 与 rust-core/src/transport.rs,server/ tests/ client/ deploy/ 未触碰。
## 缺陷
设备时钟被 NTP 调整(尤其回拨)时,喂给 Rust 状态机的 now 会倒退,而
transport.rs 的重连判断是**绝对值比较**:
  transport.rs:570  if self.reconnect_at_ms != 0 && now >= self.reconnect_at_ms
`reconnect_at_ms` 是 on_disconnected 时用 now + delay 算出来的
(transport.rs:511 / 577)。墙钟一回拨,它就落在"未来"→ 条件长期不成立 →
**重连挂死**:界面只有灰灯,不报错、不留痕,用户只能说"连不上了,重开就好"。

三处病灶(都已核实):
  · client/lib/host/link.dart:273   static int _now() => DateTime.now()...
  · client/lib/host/viewer.dart:761 同上
  · client/lib/link/link_hub.dart:234 `_monotonicishMs() => DateTime.now()...`
    而它上一行注释写着"刻意不用 DateTime.now()" —— 注释写了正确意图,
    实现和函数名(monotonicish = 假单调)都是错的。

## 改法
新增 client/lib/host/clock.dart(纯 Dart,不 import flutter,与 timing.dart 同风格):
  · `wallMs()`            —— 墙钟,只用于线格式/落盘/日志
  · `MonotonicClock`      —— **真单调**:构造时取一次墙钟作基准,
                             之后 `originMs + Stopwatch.elapsedMilliseconds`
  · `processNowMs()`      —— 进程默认单调读数(各处的默认注入值)
  · `debugOverrideProcessClock()` —— 仅测试用,验证"默认值确实走单调时钟"
刻意不是"把 DateTime.now() 包一层":那样回拨问题原样存在,等于白做。
用 Stopwatch 而非墙钟的好处是增量真单调;基准取墙钟则让读数仍贴近真实时间,
日志/排查时看得懂(不会变成"启动后第 12 毫秒")。

三处调用点改为走该时钟,且都可注入(`ClientAgent(nowMs:)` /
`ViewerSession(nowMs:)` / `LinkHub(nowMs:)`,后者本来就有这个缝)。
link_hub 的假单调函数删除,注释改写为真实原因(并留下这段历史教训)。

## 故意保留墙钟的地方(改了就是新 bug)
| 位置 | 用途 | 为什么必须墙钟 |
|---|---|---|
| link.dart:294 | 日志行前缀 [HH:MM:SS] | 人要读"几点发生的事" |
| link.dart:541 | 生成 session_id 的时间戳段 | 给人看、可排序的标识,不参与间隔计算 |
| link.dart:557 | agent:hello 信封 ts | 线格式字段,服务端据此排障/落库 |
| viewer.dart:1036 | viewer:subscribe 信封 ts | 同上 |
| viewer.dart:1318 / 1378 | lastFrameLocalMs | 界面用 DateTime.now() 与它相减算"离线多久" |
| viewer.dart:1579 | 缓存 saved_at | 下次启动要跟真实"上次保存时间"比,单调基准跨进程不可比 |

## Rust 侧持久化结论:**没有任何持久化**(换基准安全)
依据:
  · rust-core 全仓无 `fs::` / `File::create` / `write_all`(grep 无命中),
    状态机不落盘、不跨进程传递;
  · `reconnect_at_ms` 只是 `Transport` 的普通字段
    (transport.rs:465 定义、495/503/511/531/570/577 使用),生命周期 = FFI 句柄对象;
  · `Serialize/Deserialize` 只挂在 `Envelope`(transport.rs:134),不含状态机时间戳;
  · 故"进程内单调基准"完全安全,不需要动存档格式。
一处**既有**的轻微混用(本次未改,rust-core 不在边界内):`with_capacity`
用 Rust 自己的 `now_ms()`(系统时钟,transport.rs:487)给 HalfOpenDetector 播种,
而宿主喂的是单调值 —— 两者只差"进程启动→建句柄"的毫秒级偏移,且所有比较都走
saturating_sub,不会出现负数或大跳。若要彻底单一时间源,可让 host 在
onConnected 首次喂值时覆盖播种(建议另开一条)。

## 新增测试 client/test/clock_test.dart(16 项)
① MonotonicClock:10 万次调用严格不减 / origin 权威(origin=0 时读数必须是个位数
   毫秒级 —— 这一条能抓住"包一层 DateTime.now()"的假实现)/ 贴近墙钟 / 实例独立 /
   注入可恢复
② **真 Rust 状态机**(走 FFI,不是模拟):正常推进到点才重连(差 1ms 不触发);
   回拨 1 小时连喂三拍全 Idle(缺陷本体的证据);同一实例改喂单调值立刻 ReconnectNow;
   前跳 1 小时只触发 1 次 ReconnectNow(不连发,避免唤醒后连接风暴)
③ ViewerSession:注入脚本时钟 → policy 收到的 now 就是脚本值,且全部不是墙钟量级;
   等 700ms 让 500ms 事件循环跑一拍 → nextAction 收到的 now 也来自时钟且单调不减
④ 默认路径:LinkHub / ViewerSession 不传 nowMs 时确实用 processNowMs
⑤ 源码结构守卫:三处不得退回 `_now() => DateTime.now()`,不得再出现假单调函数名,
   clock.dart 必须真的用 Stopwatch 算增量(与 theme_guard_test 同一套纪律)

## 变异自证(逐个注入 → 变红 → 恢复 → 绿)
1) clock.dart 的 nowMs() 改回 DateTime.now()(假单调)→ 3 项红(含"origin 权威")
2) viewer.dart 忽略注入时钟、改回 DateTime.now() → 4 项红,含两条 ★ 行为断言
   ("policy 收到的 now 就是脚本值" / "事件循环 now 单调不减")
3) link_hub.dart 默认时钟改回 DateTime.now() → ④ LinkHub 用例红
4) link.dart 默认时钟改回 DateTime.now()(采集端无行为观测缝)→ ⑤ 源码守卫红
恢复后:16/16 绿。

## 测试数字
- cd client && flutter test:**485 通过 / 0 失败**(基线 469,净增 16)
- cd rust-core && cargo test --release:**61 通过 / 0 失败**(未动 rust-core)
## 缺陷(最后一个数据正确性缺陷,与 E1 同级)
gateway.js 的 `_flushStorage()` 旧实现是「先把队列整个 splice 走,再 try/catch 只 log」:
    const batch = this._storageQueue.splice(0, this._storageQueue.length);
    try { await this.repo.insertMetricsBatch(batch); }
    catch (e) { this.log.error('指标批量落库失败', e.message); }
catch 之后批次**已经不在队列里**:PG 抖动 / 磁盘满 / 连接池耗尽时,
一次失败丢掉**整批(最多 5000 帧)** —— 不留计数、不重试、`/health` 完全正常。
对照同文件"队列满丢最旧"那条路径(_storageDropped + 周期 warn),这条是裸的。
与"静默丢数据"同源,只是这次连丢都没记。

## 为什么用**独立重试槽**,而不是把失败批次放回主队列
放回主队列是最直觉的写法,但有两个硬问题:
  ① 与"队列满丢最旧"语义打架:`_enqueueForStorage` 满时会 shift 掉**队首**,
     把失败批次放回去等于让"最该被救的旧数据"变成"最该被丢的旧数据";
  ② 毒丸(某帧的值始终违反约束,重试多少次都失败)会永久占据队首,
     把后面所有**新数据**一起堵死 —— 一条坏帧拖垮整台设备的落库。
故改为 `this._storageRetry = { batch, attempts, lastError }` 独立存放。

## 毒丸如何隔离
每轮 `_flushStorage()` 的顺序是:**先试重试槽一次 → 然后无论成败都照常处理主队列**。
  · 旧批失败**绝不阻塞**新数据落库(这是本缺陷最关键的性质);
  · 不在同一次 flush 内忙等重试(会把 2 秒定时器拖住,让"暂时抖动"滚成"持续积压");
  · 有界:attempts 超过 `_storageMaxRetries`(默认 3,即首次 + 3 次 ≈ 8 秒窗口)
    → 调 `_discardStorageBatch()` **明确丢弃**;
  · 重试槽已被占用时新失败的批次不挤掉它(挤掉等于"更早的数据先死"),
    也不攒着(内存必须立刻有界),同样走"明确丢弃 + 计数"。

## `_storageFailed` 的语义(与 `_storageDropped` 不是一回事)
  · `_storageDropped`  = 队列满,主动丢**最旧**(有设计的有界策略,正常压力下的取舍)
  · `_storageFailed`   = 写库失败、重试也救不回来而**明确丢弃**的**帧数**
    (>0 就意味着真丢数据,要查 PG/磁盘;`/health.storage_failed` 直接暴露)
告警节奏:首丢必报一条(带帧数、已尝试次数、错误原文、前 3 帧 `device_id@seq`,
便于事后判断丢了哪台设备的哪一段、要不要补),之后每累计 100 帧一条,
对齐 `_storageDropped` 的节奏,避免持续故障时每 2 秒刷一条把日志淹了。
`/health` 另加 `storage_retry_attempts` / `storage_retry_frames`:
运维据此区分"暂时抖动(attempts 在涨但 finally 落库)"与"PG 一直不可用"。

`drainStorage()` 改为最多 3 轮:一次 flush 只给重试槽一次机会,关闭时多给几轮,
仍救不回来的打印一行"关闭时仍有 N 帧未能落库"(不静默)。

## 顺带删一处死代码
`_releaseSeqWindow(deviceId)` 全仓零调用(只有定义),已删除并在原位置留注释说明
为什么现在不需要它:E1 之后去重窗口的权威副本在 Redis,断连**刻意不删**
(那正是"重启后补传仍能判重"的依据,靠 TTL 收敛);内存窗口只是兜底判据,
条目数由 `_seqWindowMaxDevices` 的 LRU 上限约束,不需要断连时主动回收。

## 新增 tests/storage-failure-test.js(29 项,已接入 run-all.sh 离线段)
1. 有界重试:批次进槽不消失 → attempts 1..3 → 超限明确丢弃(6 帧计数准确)、
   总尝试 4 次、丢弃告警只 1 条且含帧数/次数/错误/样本
2. 重试成功后不丢数据:前 2 次抛错、第 3 次成功 → 该批 4 帧**原样补上**(seq 完整)、
   `_storageFailed` 保持 0
3. **毒丸隔离(最关键)**:含 dev-poison 的批持续失败,同时灌入新帧 →
   新帧照常落库(3 → 6 帧),毒丸最终被丢弃且不计入新帧
4. 多次丢弃:累计帧数 5 / 批数 2 准确,告警仍只 1 条(未到 100 帧阈值不刷屏)
5. 回归:正常路径 `storage_failed` 恒 0、队列满 `storage_dropped` 语义不变、
   `drainStorage()` 把重试槽那批也刷净
6. 真进程 `/health`:`storage_failed=0`、`storage_retry_attempts=0`、
   `storage_retry_frames=0` 且 `storage_dropped` 仍在(PG/Redis 不可达时明确跳过)

## 变异自证
把 `_flushStorage` 的失败分支改回旧实现(只 log、批次消失)→
**16 项红**(第 1 段 7 项全红、第 3 段"进槽/最终丢弃"2 项红、第 4 段 3 项红、
`drainStorage` 1 项红);恢复后 29/29 绿。
注:第 3 段那两条 ★"新帧照常落库"在变异下仍绿 —— 因为旧实现是"立刻丢",
同样不阻塞;它们守的是"将来别为了重试而阻塞新数据",与本缺陷的计数/重试断言互补。

## 三侧数字
- GATEWAY_PORT=3107 bash tests/run-all.sh:**29 通过 / 1 失败**
  (30 个套件;唯一红仍是隐私扫描,本次未触碰)
- cargo test --release:**61 通过 / 0**
- cd client && flutter test:**485 通过 / 0**
## 改判内容(team-lead 复核结论)
上一版(58f82a6)在"重试槽已被占用"时把新失败批**直接丢弃**,只让最早那一批
享有重试特权。幂运算一下就看出问题:PG 抖动 10 秒(2 秒一轮 → 5 轮)时,
轮 1 批 A 进槽(有救),轮 2/3/4 的批 B/C/D 全丢,轮 5 槽 A 超限丢、批 E 也丢 ——
**10 秒抖动只救回一批**,等于把"每批都丢"降级成"每 2 秒丢一批",
而短暂抖动恰恰是重试最有效的场景。故改为**合并进槽**。

## 实现
- 槽的数据形态不变(`{ batch, attempts, lastError }`),但 `batch` 现在是
  **多轮合并累积**的帧数组:失败时一律 `concat`,不再"占用即丢弃"。
- **有界性靠槽容量,不靠丢新批**:合并后若超过 `_storageQueueMax`(默认 5000,
  与主队列同一上界)→ **截断**,保留**较早**的帧(先保已经等了更久的数据),
  超出部分走 `_discardStorageBatch(..., '超出重试槽上限 N 帧')`:计入
  `_storageFailed` + 告警,**明确丢弃不静默**。于是重试槽内存上界 = 主队列上界,
  重试不会让内存膨胀。
- `attempts` 语义改为**槽的重试轮次**(每轮 flush +1,首个失败轮记为 1),
  不再是"某一批的尝试次数"。超过 `_storageMaxRetries` → 整槽丢弃。
- 顺序不变(上一版写对的地方保留):**先试槽 → 无论成败都照常处理主队列**,
  旧批失败绝不阻塞新数据;不在同一次 flush 内忙等重试。

## 附带发现:默认重试窗也要放宽(这一条请复核)
改完合并后我用临时量具实测"PG 抖动 10 秒"(5 轮 × 4 帧 = 20 帧,第 6 轮恢复):
- 默认 `storageMaxRetries = 3` → 只救回 **8/20** 帧(第 4 轮 ≈8 秒整槽被丢,丢 12 帧)
- 因为合并后 attempts 记的是**轮次**,上限 3 意味着"只覆盖 4 轮 ≈ 8 秒",
  与"要覆盖一次真实抖动"的意图不匹配。
故默认值改为 **9 轮 ≈ 20 秒窗口**(2 秒/轮 × (1+9))。放宽不增加内存:槽容量由
`_storageQueueMax` 硬约束,窗口长短只影响"抖动期间攒多久"。改后同一场景
**20/20 全部救回、`storage_failed` = 0**。上限机制本身仍由测试 1(显式传
`storageMaxRetries: 3`)钉住。

三个版本在同一量具下的实测对照:
| 版本 | 落库 | storage_failed |
|---|---|---|
| 最初(只 log) | 0/20 | 字段不存在(静默) |
| 58f82a6(槽占用即丢) | 4/20 | 16 |
| 本次(合并 + 20 秒窗) | **20/20** | **0** |

## 测试(tests/storage-failure-test.js:29 → 41 项,全绿)
新增:
- **3b 跨 flush 合并**:3 个周期各失败 3/4/2 帧 → 槽内 **9 帧**、`attempts` = **3**
  (不是 9);第 4 轮 PG 恢复 → **9 帧全部落库**、seq 1..9 顺序完整、`_storageFailed` = 0
- **3c 合并仍受容量约束**:槽上限 10,连续失败凑 15 帧 → 槽里只留 10 帧、
  另 5 帧计入 `_storageFailed`、**只告警一次**、保留的是较早帧(1..10)
- **3d 抖动 10 秒(默认窗口)**:5 轮 20 帧全攒槽、恢复后 20 帧一次落库
原有 5 条全绿(含"失败不阻塞新数据"毒丸隔离、重试成功不丢数据、计数/告警节奏、
队列满丢最旧语义不变、`drainStorage` 刷净)。
另:把 1/3/4 段的重试上限**显式**传 3,让"上限机制"与"抖动窗口"两件事在测试里
各自独立可读。

## 变异自证
把失败分支改回旧实现(只 `log.error`、批次消失)→ **41 项里 27 项红**:
第 1 段 8 项全红、第 2 段 2 项、第 3 段 2 项、**3b 4 项、3c 5 项、3d 2 项**、
第 4 段 3 项、`drainStorage` 1 项;恢复后 41/41 绿。
(顺带加固了 3b/3c 的空槽判定为 `?.`,让回归时"报红"而不是抛异常中断整个套件。)

## 三侧数字
- GATEWAY_PORT=3107 bash tests/run-all.sh:**29 通过 / 1 失败**(30 套件;唯一红仍是隐私扫描,未触碰)
- cargo test --release:**61 / 0**
- cd client && flutter test:**485 / 0**
team-lead 批准 9 轮窗口的前提是"注释里写明依据",否则后人看到 9 会以为
是随手写的数。补清三点:
  · 9 轮 ≈ 18~20 秒(flush 周期 2 秒),覆盖 PG 重启 / 连接池耗尽后恢复的典型时长;
  · 放宽**不增内存**:上界由槽容量 _storageQueueMax(5000 帧)硬约束,
    窗口只决定"抖动期间攒多久",两件事必须解耦、不要合在一起调;
  · 改回 3 会让 tests/storage-failure-test.js 的 3d 段("PG 抖动 10 秒必须全救回")
    立刻变红,并直接说明"10 秒抖动救不回"。

纯注释改动,无行为变化。验证:storage-failure-test 41/41 绿;
10 秒抖动量具仍为 20/20 落库、storage_failed=0。
对应架构评审 2.6 节(P0-6):中继/PM-4 选②(先明示)、P2P/PM-5 选②(下线)。

## A1 中继大文件:明示上限(不做流控)
`RELAY_MAX_BYTES=1MiB` 让服务端从"卡死"变成"明确拒绝",但用户是
**选完文件、点了发送、进度条不动**之后才知道 —— 界面在骗人。
现在两处改:
1. `transfer_card` 的发出区在**还没选文件时**就把话摆在按钮下面:
   「跨网时走服务器中继,当前单次上限 1.0 MB;更大的文件请在两台设备处于
   同一局域网时传(直连不受此限制)」;
2. `_pickAndOffer` 里在 `openRelayForSend()` **之前**用纯函数
   `relayLimitWarning(size)` 拦一道:超限则撤销本地票据、**不发起传输**、
   把原因显示出来("没有开始传输"说清楚,别让人以为在等)。
阈值只有一处来源:`net/relay_client.dart` 的 `kRelayMaxOnceBytes`,注释指向
`server/gateway/relay.js:44-47`(服务端 `MAX_ONCE_BYTES`),界面不再手写 1048576。
文案已避开"监视/监听/追踪"类词(合规红线),并有测试守着。

## A2 P2P 下线:隐藏档位,不删代码
按 2.6.2 的五条动作(第 4 条 Rust 事件常量已在 03b30c1 完成):
1. `viewer_page.dart` 数据路菜单摘掉 `p2p` 与 `relay`,只剩「自动 / 内网直连 /
   云端订阅」;`onSelected` 同步去掉这两个分支。
   `relay` 一并摘掉的理由:它只服务**文件面**(PM-E7),放在指标路菜单里
   同样是"界面在说谎"。
2. `link_mode.dart`:`LinkMode.p2p` 枚举值保留(协议词汇/评分表/既有测试都引用),
   加"待接线"注释;`relay` 同注。新增 `isModeSelectable()`(可选项的唯一判据)
   与 `normalizePreferredMode()`(偏好收敛点)。
3. `link_hub.dart`:推荐逻辑**排除**已下线档位(`isModeSelectable` 过滤
   candidates + 手动偏好分支复检),**不改评分权重** —— 只排除产出;
   将来接线后权重原样生效。
4. `stun.dart` / `p2p_driver.dart` 文件头各加"待接线"说明,写明
   "`stun_test.dart` 是真测试、保留;生产路径未调用"(依据:`stunDiscover()`
   全仓唯一调用点是测试;`publishOffer` 两处注入点都没传 `candidates`)。
5. 没删任何 P2P 代码,没动既有 stun/p2p 测试。

## 旧偏好回落(摘菜单后的必答题)
存量的 `p2p`/`relay` 偏好一律收敛成 **null = 自动**,收敛点只有
`normalizePreferredMode()` 一处,在三个入口生效:`ViewerSession.setPreferredMode`、
`LinkHub.setPreferredMode`、`viewer_page` 菜单勾选态。
不崩、不空白、不留"选不回来又不生效"的勾选态。

## 测试
新增 `test/link_mode_test.dart`(8 项):可选项只有 3 个 / p2p+relay 不可选但枚举值仍在 /
normalize 收敛 / hub 与 session 收到旧偏好都回落 / 只有 p2p 可达时推荐为空 /
p2p 分数更高也不抢推荐 / 内网照常推荐。
`test/transfer_card_test.dart` +5 项:1 MiB 以内不提示 / 超限文案含"多大+上限+没开始" /
必须给出"换同一局域网"的可行动作 / 合规禁词 / **卡片在未选文件时就显示上限**(widget 级)。
`test/link_hub_test.dart` 改 1 处:那条"手动指定不可达模式 → 回落自动"原本用
`relay` 举例,relay 已下线会走不到该分支,改用在线档位 `lan`(用例意图不变)。

## 验证
- `flutter test`:全绿(本文件改动前的共享树基线 485,本分支新增 13 项;
  另有 engineer-2 并行新增的 16 项 app_paths 测试,合计 514)
- `dart analyze lib bin`:**No issues found!**(不用 flutter analyze:本机中文路径会让它崩)
对应架构评审 2.5 节。此前 `bin/pmon_agent.dart` + `bin/pmon_install.dart`
(install/uninstall/status/restart/verify)都已就绪,但 **GUI 一个入口都没有**,
用户得自己敲命令 —— 等于这个能力不存在。

## 这一笔包含什么(GUI 侧)
- 新增 `lib/host/resident_service.dart`:GUI 与 `pmon-install` 的**唯一接缝**。
  只做三件事:**找到 CLI → 跑它 → 把 `status --json` 解析成类型**。
  · 不在 GUI 里做 launchctl/plist:沙箱 GUI 写不了 `~/Library/LaunchAgents/`,
    自启产物内容由 Rust 生成、落盘由非沙箱 CLI 执行;GUI 再写一套就是第二套自启逻辑。
  · 不在 GUI 里自己 stat 文件/pgrep 进程:判据只有 CLI 那一处。
  · `ResidentState` 三态(在跑 / 装了没起来 / 未安装)+ unknown(问不到)。
    unknown **不猜**:字段缺、执行失败、输出不是 JSON 都如实说"看不清"并带原因。
  · CLI 定位按三级回落:装好的 `installBinDir/pmon-install` → 开发树
    `build/pmon-install` → `dart run bin/pmon_install.dart`;都没有就报
    "先 dart compile exe ...",**不假装已安装**。
- 新增 `lib/ui/resident_card.dart`:卡片本体。
  · 三态色点 + 一句话状态;"装了但没起来"必须说清判据(自启未加载 / 进程没起)
    并给日志路径 —— 这一态最容易被误解成"装过就好了"。
  · 按钮:安装并启动(未装)/ 重新安装并启动(装了没起)/ 卸载(已装)。
  · **失败回显 CLI 原文**(stderr 尾部):P0-6 已让 installer 返回非零并打印
    哪条命令失败,卡片把那几行原样显示(可复制),不用"安装失败"盖掉。
  · 5 秒低频轮询(状态在外部进程里,事件拿不到;5 秒够"改完能看见")。
- `lib/main.dart`:采集页 dashboard 挂载,**仅桌面端**
  (`core.canRunInBackground && !AndroidForeground.isAndroid` —— Android 的常驻
  是前台服务,语义不同)。
  · `onResidentStarted` → 停掉 GUI 自己的采集:两个进程同时上报会把两条 seq 流
    混进同一 device_id,服务端按会话去重判不清 → 丢帧或重复帧,且不报错
    (与 `_start()` 里 AgentLock 那条同源)。
- `test/theme_guard_test.dart`:登记 `lib/ui/resident_card.dart`
  (新增 UI 文件必须进 `_uiFiles`,这条吃过亏)。

## 测试(新增 21 项)
- `test/resident_service_test.dart`(13):解析四类判据 / 三态判定只在一处 /
  字段缺失不崩不猜 / **失败带具体原因** / 输出非 JSON → unknown /
  真机噪声(`dart run` 把 "Running build hooks..." 拼在 JSON 同一行)仍能解析 /
  CLI 路径从 `installBinDir` 派生 + 三级回落 + 找不到给明确指引。
- `test/resident_card_test.dart`(8):未安装 / 在跑(含 pid、且不再给"安装"按钮)/
  装了没起来(说清判据 + 日志路径)/ 状态未知如实说 / **失败原因原样可见** /
  安装成功才通知宿主停采集 / 安装失败不停采集 / 卸载回未安装。

## 验证
- `flutter test`:**540 通过 / 0 失败**(本笔新增 21 项)
- `dart analyze lib bin`:**No issues found!**(不用 `flutter analyze`:本机中文路径会让它崩)

## 与并行任务的边界(重要)
`pmon-install status --json` 那一半(CLI 侧 `statusSnapshot()` + `--json`)
落在 `client/bin/pmon_install.dart`,而该文件正被 **software-engineer-2** 的
路径收敛任务改写中(未提交)。为不把他的在途改动卷进我的提交,本笔只含 GUI 侧;
CLI 侧与 `test/installer_status_json_test.dart` 紧随其提交后单独落一笔。
`test/installer_status_json_test.dart`(4 项)。它 import `bin/pmon_install.dart`
**在同一进程里**跑一遍 `statusSnapshot()`,再把结果 `jsonEncode → jsonDecode`
喂给 GUI 侧的 `ResidentStatus.fromJson`,断言四类判据(自启注册态 / 进程 /
配置 / 日志)两端字段名对得上。

## 为什么必须有这条
`pmon-install status --json` 与 `ResidentStatus.fromJson` 是**同一条契约的两端**。
任一端改字段名,另一端不会报错,只会静默退化成"状态未知"——卡片永远显示
"看不清",而日志里没有任何异常。这与本项目当年"指标字段四处各写一套、交集只剩
3 个字段、静默丢数"是同一类故障(那次是靠 schema-consistency-test 才抓住的)。
故这里不测"CLI 能不能跑通"(真机验),只测**两端字段是否还对齐**。

## 断言要点
- 四类判据齐全(缺一 GUI 就判不出三态);
- `jsonEncode(snapshot) → fromJson` 后 state 落在合法三态内(若变成 unknown,
  说明字段名漂移了);
- `config.path` / `log.path` / `installed.agent_path` 三个路径字段非空
  (这三条正是 engineer-2 路径收敛与本卡的交汇点);
- `status --json` 返回 0(只读命令永不失败退出);
- 人话输出与 JSON **共用同一份快照**(同一条断言保证"判据只有一处")。

## ⚠️ 已知的串联顺序(必须连着落地)
本笔单独存在时会**红**:GUI 侧读的 `statusSnapshot()` 落在
`client/bin/pmon_install.dart`,而该文件正被 software-engineer-2 的路径收敛
任务改写(未提交,且两人的改动都集中在 `_status()` 同一处,非交互环境下
`git add -p` 用不了),按 team-lead 的决定由他一次性合并提交(commit message 里
会注明那段由我实现)。他那一笔落地后本测试即绿。
若有人 bisect 到本笔,看到的是"契约测试先行、实现紧随",不是回归。

验证:working tree(含未提交的 `statusSnapshot`)上 `flutter test
test/installer_status_json_test.dart` → 4/4 通过;
`dart run bin/pmon_install.dart status --json` 真机输出四类判据齐全。

## 更正(数字,勿再照抄旧账)
`698b680` 的 message 写「新增 21 项(13 + 8)」,**实际 B 的 GUI 侧为 22 项**
(`resident_service_test.dart` **14** + `resident_card_test.dart` 8 —— 中途补了
「`dart run` 把 `Running build hooks...` 拼进 JSON 同一行」那条用例,从 13 变 14)。
`698b680` 未 amend,以保持"当时验证的是什么"可复核;此处补记以保证 `git log` 自洽。
逐文件实测:`link_mode_test` 8 / `transfer_card_test` 14(原 9 + 新 5)/
`resident_service_test` 14 / `resident_card_test` 8 / `installer_status_json_test` 4,
合计新增 39;`540 = 485(基线)+ 39(本任务)+ 16(并行任务 `app_paths_test`)`。
## 缺陷(合并前四处口径互相矛盾)
- `install_paths.dart:27` 安装根目录**硬编码 macOS 路径**,零平台分支
- 同文件 `:33` 可执行文件名不带 `.exe`(Windows 上不可执行)
- `app_config.dart:245-260` 反而有 macOS/Windows/其它三分支
- 动态库文件名另有 3 份(`core_lib.dart:17-21`、`pmon_install.dart:89` 二分支、
  `:287` 三分支);plist 路径 `pmon_install.dart:184`/`:271` 各写一遍
后果不是"不好看",是**装得进、读不到**:Windows 上 agent 落到
`%USERPROFILE%\Library\Application Support\pmon_client\bin`,配置却写进
`%USERPROFILE%\AppData\Roaming\pmon_client`,两边都不报错。

## 合并后的真相源:`client/lib/host/app_paths.dart`
两条**正交**维度都做成显式参数(不是靠 `if` 补):
1. **平台** —— `PmonPlatform` 参数化,且**路径语法随参数走**(`p.windows`/`p.posix`),
   于是 Windows 口径能在 macOS 上被真测到(测真机形态,不是 posix 伪装)。
2. **进程形态** —— `PmonProc{installer, gui, guiSandboxed, injected}`。
   Android 的 `getFilesDir()` 注入被**收进**这个参数,不再是并列的第二套机制。

消费方全部改为委托,不再复制任何路径字面量:
`install_paths.dart`(保留全部既有 getter 名,bin/ 无需改调用点)、
`app_config.dart`(`ConfigStore._dir` 只剩一句 `dataDirFor`)、
`core_lib.dart`(动态库名取 `coreLibNameFor`)、
`pmon_install.dart`(`coreLibName` + `launchAgentPlistPath`)。

## 沙箱维度:怎么处理的(架构文档的四段代码没覆盖,这里补上)
先核实事实:`client/macos/Runner/{Release,DebugProfile}.entitlements` 里
`com.apple.security.app-sandbox = true` —— **本项目 GUI 确实是沙箱的**,
bundle id = `com.pmon.pmonClient`。所以这不是假想维度。
处理方式(不嗅探、不加注入通道):
- 沙箱只体现为"家目录取值不同",不是另一套目录规则;
  `dataDirFor(..., proc: guiSandboxed)` 从**真实家目录**插入容器前缀,
  与沙箱 GUI 拿自己 HOME(容器路径)算出的值**恒等**。
- 该等式写成了测试(三种形态各一条):
  macOS 非沙箱 → `安装目录 == GUI 数据目录`;
  macOS 沙箱 → `guiDataDirForInstallerView(h) == dataDirFor(容器家目录, gui)`;
  Windows/Linux → 配置目录同源(Linux 安装目录走 XDG 是有意的,见注释)。
- bundle id 有自动比对:测试读 `macos/Runner/Configs/AppInfo.xcconfig` 逐字核对。

## 顺带修掉两个"假判断"(都在同一个文件,且直接决定 e2e 能不能说真话)
1. **`Future<int> main` 把退出码吞掉**:本机 SDK 实测 `int main` / `Future<int> main`
   返回 3,进程退出码都是 **0**。即上一轮「安装器不再撒谎、返回非零」的修复
   **到不了进程边界** —— 屏幕打印"安装失败",shell / CI 仍拿到 0。
   改为 `Future<void> main` + `exitCode = await runCli(argv)`。
2. **`_agentPids` 只按 `pmon-agent --collector` 匹配**:会把**别人家**的采集端算进来
   (本机就有一份 pid 1737 在跑),于是 install 假成功、uninstall 假失败。
   改为按落位后的**绝对路径**匹配(`RegExp.escape` 防路径里的 `.` 当通配符)。

## 新增 `tests/installer-clean-e2e.js`(26 项,已接入 run-all)
临时 HOME 隔离,从零走:落位 bin+dylib → 写配置 → plist → status 四项判据 →
卸载摘干净。要点:
- 各平台期望路径在测试里**独立写死**(不 import Dart 口径)——
  否则口径写错时测试会"自洽地通过",等于没测。
- 特殊断言:**屏幕上说「安装失败」时退出码必须非零**(就是上面那个 P0)。
- `launchctl bootstrap` 在本环境必然失败(`Bootstrap failed: 5: I/O error`):
  落位/配置/plist **无条件断言**,自启注册一项明确打印 ⚠ 并记 skip(附原因),
  其它任何非零退出判失败。缺 dart / 缺 Rust 产物 / 缺采集端时 ⚠ 跳过并返回 0
  (不静默放行、也不假红);跳过分支不打印汇总行,免得结构守卫把"没跑"看成"全绿"。

## 验证(改前 → 改后)
- `cargo test --release`:61/0 → **61/0**
- `flutter test`:485/0 → **540/0**(含他人新增;本次新增 `app_paths_test.dart` 22 项)
- `dart analyze lib bin`:**No issues found!**
- `bash tests/run-all.sh`:30 套件 29/1 → **31 套件 30/1**(唯一红仍是隐私扫描,按要求未碰)
- `node tests/installer-clean-e2e.js`:**26 通过 / 0 失败 / 1 跳过**(跳过=launchd 环境受限)
- `guard-structure-test.js`:42/0(新增套件已通过"汇总晚于断言"守卫)

## 未做(清单外,只报告)
- **macOS 沙箱下"配置读不到"仍未接线**:GUI 是沙箱的,读容器;
  而安装器(非沙箱)与 launchd 拉起的采集端读真实家目录。路径层已能算出
  两者位置(`guiDataDir`),但"配置究竟写给谁、要不要双写、GUI 怎么拿到"是
  配置交接的决策,不属于本次路径合并,未擅自改。
- `pmon_agent.dart` 同样是 `Future<int> main`(退出码同样会被吞),不在本次边界内。
- 默认 label `com.pmon.collector` 在 `pmon_install.dart` 与他人新文件
  `resident_service.dart` 各有一份(不是路径,未动)。
端到端验收抓到的真缺陷(不是理论问题,本机可复现)。

## 血案形态
开发机上 `client/build/pmon-install` 是**几天前编译的旧二进制**,它不认识
`--json`,照旧打印人话 + 一行**配置 JSON**:
    === 配置 ===
    {"server_url":"http://127.0.0.1:3000","device_id":"mac-autostart-test"}
而 `ResidentService` 原先的解析是「从下往上取第一个能解析成 Map 的行」——
于是它抓到了**配置 JSON**,`ResidentStatus.fromJson` 得到:
    state=notInstalled、configPath=""、logPath=""、agentPath=""
卡片会一本正经地显示「尚未安装」—— **用户明明装过、而且在跑**。
这正是"形状对、内容错":接口对得上、值是错的,且不报错、不留痕。

## 修法(两条,缺一不可)
1. **形状校验**:新增 `ResidentService.looksLikeSnapshot()` —— 只接受同时含
   `autostart` 与 `process` 两段的 JSON。配置 JSON 缺这两段,直接不认。
2. **候选链逐级回落**:`resolveCliCandidates()` 返回全部候选(装好的 →
   `build/pmon-install` → `dart run bin/pmon_install.dart`),`status()` 逐个试,
   **只采用形状合规的那一个**;全都不合规时返回 unknown,并给出可操作原因
   ("这个 pmon-install 不支持 `status --json`,请重新编译",附输出前 120 字)。
   这样开发机上"编译产物过期"不再静默给错状态;生产上若用户只升级了 GUI
   没升级 CLI,卡片会说"CLI 太旧",而不是谎报"未安装"。
`install`/`uninstall` 仍走第一个候选(那两个动作没有"输出形状"问题,失败原因原样回显)。

## 测试(+3,共 17 项;全仓 543/0)
- `looksLikeSnapshot`:只有 config 段的 JSON **不算**状态快照(血案本体);
  缺 autostart/process 两段的不算;齐了才算。
- 过期二进制的输出 → **unknown**,绝不冒充 notInstalled;文案含"不支持"与
  "dart compile exe"(告诉用户该做什么)。
- 候选链逐个试:过期那个被跳过,合规那个被采用,且断言调用顺序
  `[过期二进制, dart]`(顺序本身也是契约)。

## 真机端到端(验具走 GUI 真正走的那条路 `svc.status()`)
```
候选 CLI 链:
  · .../client/build/pmon-install      exit=0 输出: === 自启注册 === ...(旧版,无 JSON)
  · dart run .../bin/pmon_install.dart exit=0 输出: {"label":"com.pmon.collector",...}
--- svc.status() 解析结果 ---
state=running  pids=[1737]
autostart: registered=true loaded=true unit=~/Library/LaunchAgents/com.pmon.collector.plist
config: exists=true  path=~/Library/Application Support/pmon_client/config.json
log:    exists=false path=/tmp/com.pmon.collector.err.log
agent:  exists=true  path=~/Library/Application Support/pmon_client/agent
✓ 端到端通过
```
(`status --json` 的实现仍在 `client/bin/pmon_install.dart`,随
software-engineer-2 的合并提交落地;本笔只修**消费侧**,不碰他的在途改动。)

验证:`flutter test` **543/0**;`dart analyze lib bin` **No issues found!**
`'com.pmon.collector'` 原本在 `resident_service.dart` 与 `bin/pmon_install.dart`
各写一份。漂移的症状极隐蔽:GUI 查 A.plist、安装器注册 B.plist —— 两边都
"正常",只是问的不是同一个自启项,表现为"明明装了、卡片却说未注册"。
这与 install_paths / 指标字段 / 配对码是同一族缺陷,故同样收成一份常量。

本笔只含常量的**定义**与 GUI 侧消费者(纯 lib,自洽可红可绿):
  · 新增 `lib/host/autostart_label.dart`
  · `ResidentService.label` 默认值改为引用它

安装器侧那半行(`const _defaultLabel = kDefaultAutostartLabel;`)落在
`client/bin/pmon_install.dart`,该文件当前含另一位同学的在途改动,随他的
commit 一起落地;随之落地的还有源码守卫测试(字面量只许出现在
`lib/host/autostart_label.dart`)——守卫必须与两处消费者同笔,否则该 sha
单独 checkout 会红。

验证:dart analyze lib → No issues found;flutter test → 545/0
## 本笔包含什么
1. **进程探针三态化**(`_agentPids` → `probeAgentProcess()` / `AgentProcessProbe`)
   - 原实现 `Process.run('/bin/zsh', ['-c', 'pgrep -f ...'])`:**Linux 上 `/bin/zsh`
     通常不存在** → `ProcessException` 直接抛穿 `install` / `uninstall` / `status`;
     Windows 上 `pgrep` 一定没有 → 返回空列表,于是"查不了"被显示成"未运行"
     (用户会去重装,而真问题是本平台没有探针)。
   - 改为**不经 shell**直调 `pgrep -f <RegExp.escape 后的绝对路径> --collector`:
     macOS/Linux 通用,且路径含空格/引号时不再交给 shell 解析。
   - 返回值带 `available`:`pgrep` 退出码 1 = 确实没匹配(唯一可信的"没在跑"),
     其它非零 / `ProcessException` = **无法判定**。上层:`install` 不把"查不了"
     算成失败(只 ⚠),`uninstall` 不把"查不了"说成"已停止"(只 ⚠),
     `status --json` 增 `process.probe_available/probe_tool/probe_error`,
     人话输出显示「采集端: 无法判定(原因)」。只增字段,`running`/`pids` 语义不变,
     GUI 侧解析不受影响。
   - **Windows 未实现**(刻意):写一条未经真机验证的 PowerShell `-like` 分支,
     只会"看起来能判定",匹配一错就是又一次"用错事实下判断"。已留
     `TODO(多端前置)` 并明确报"无法判定"。

2. **`tests/installer-clean-e2e.js` 新增 8 项 `status --json` 断言**(26 → 34 项)
   - 解析策略按 team-lead 要求与 GUI 侧一致:**从后往前**扫行、行内从后往前找 `{`
     试解析 —— 因为 `dart run` 会把 `Running build hooks...` **拼在 JSON 同一行前面**
     (实测 `Running build hooks...Running build hooks...{"label":...}`),
     按"整行是 JSON"判断会把唯一有效输出判成解析失败。
   - 断言 `config.path` / `installed.agent_path` / `autostart.unit_path` 与真相源
     按**临时 HOME** 推导的值逐字相等 —— 硬编码旧值不可能跟着临时 HOME 变,
     故这是"路径收敛真的生效"的最强证据。

3. `statusSnapshot` 里 `log.path` 加 `TODO`:这是**唯一还没收敛**的路径
   (Rust 的 `macos_launchagent_plist` 把 `StandardErrorPath` 写成同一形状,
   Dart 侧又写一份)。正确做法与 `allow_failure` 同款(让 `AutoStartPlan` 多返回
   `stderr_log_path`),但需改 Rust 数据结构 + FFI 出口,超出"client 路径"边界,本轮未做。

## 归属说明(team-lead 要求留痕)
- `pmon_install.dart` 的 `statusSnapshot()` + `--json` 改动由**另一位工程师**实现;
  因与路径收敛同处一个文件、非交互环境下无法按 hunk 拆分,实际已在上一笔
  **c152ea2** 一并落地(该文件在 c152ea2 里是 151+/37-,即两人改动的合并体)。
  本笔只在其上追加"探针三态化 + e2e 断言 + TODO",未改动其判据逻辑。
- 反向留痕:`51c85b0`(team-lead 记作 `3ee0630`,该 hash 在全部历史与分支中都不存在,
  实际是 `51c85b0 test(常驻采集): 把「CLI 输出 ↔ GUI 解析器」两端的契约钉在一起`)
  是"测试先行"那一笔 —— 它 import `installer.statusSnapshot`,而当时 CLI 里
  `statusSnapshot` 出现次数为 **0**,单独 checkout 会因编译失败而红;
  **本笔的前置 c152ea2 使其转绿**。保留它不 revert,两侧都留痕,避免 `git bisect`
  把"顺序"读成"回归"。

## 验证(改前 → 改后)
- `cargo test --release`:61/0 → **61/0**
- `flutter test`:540/0 → **545/0**(含 `installer_status_json_test.dart` 4 项)
- `dart analyze lib bin`:**No issues found!**
- `bash tests/run-all.sh`:31 套件 30/1 → **31 套件 30/1**(唯一红仍是隐私扫描,未碰)
- `node tests/installer-clean-e2e.js`:26/0 → **34 通过 / 0 失败 / 1 跳过**
  (跳过=launchctl `Bootstrap failed: 5` 环境受限,原因逐条打印)
- `guard-structure-test.js`:42/0

## 已核实的现场事实(供 team-lead 复核)
`status --json` 在**真实 HOME** 与**临时 HOME** 下都取到正确路径,后者全部跟着临时
HOME 走,证明三个路径字段来自 `app_paths.dart` 推导而非硬编码:
- 真实:`config.path=/Users/me/Library/Application Support/pmon_client/config.json`、
  `installed.agent_path=.../bin/pmon-agent`、`log.path=/tmp/com.pmon.collector.err.log`
- 临时:`config.path=/tmp/pmon-json-probe-xxxx/Library/Application Support/pmon_client/config.json`、`installed.agent_path` 同源变化、
  且 `process.running=false`(**正版 pid 1737 不再被算进来**,路径作用域生效)
## 一、探针不可用 ≠ 没在跑(把上一轮糊回去的洞补上)

installer 在 ccb6c3f 里给 `process` 加了 `probe_available/probe_error`,把
「查不了」与「没在跑」分开了(人话输出已经用上);但 JSON 消费方没跟上:
`ResidentStatus.fromJson` 只读 `running`/`pids`,于是 Windows(无 pgrep)上
`running=false + registered=true` → 卡片一本正经显示 **"已安装但没起来"(橙色告警)**,
而真相是"一次都没查过"。这比"没有卡片"糟得多:它看起来**很确定**,用户会
照着它去重装一个已经装好的东西。

- `fromJson` 读 `probe_available`,**只有明确 false 且没观测到进程**才降级为
  `ResidentState.unknown`(中性灰)+ detail 写明原因与"这不等于没在运行"
- `probe_available` 做成**三态**(`bool?`):缺字段(老二进制/老快照)→ 不看该判据。
  若把"缺字段"当 false,所有老机器都会被判成"未知"——那是用另一种方式骗人
- 正面观测优先:`running=true`/有 pid 时仍判 running(探针元数据不压过证据)

## 二、采集端实际读到的配置:诚实标注,不做同步

macOS GUI 是真沙箱(容器内 `ConfigStore`),常驻采集端由 launchd 拉起、读真实
家目录 —— 磁盘上**两份 config 同时活着**:界面里改网关地址只改容器那份,采集端
看到的还是旧的,两边都不报错。三条同步路本轮都不通(沙箱无权写容器外 / 沙箱子
进程继承沙箱 / 让 agent 读容器会给主路径加上"必须装过 GUI"的依赖),故只做标注:

- 卡片显示「采集端当前读到:<server_url> · <device_id>」(来自 `status --json`)
- 与界面输入不一致时明说不一致,并给出下一步(点一次「重新安装并启动」)
- 配置面板加一句:上面两项只对「界面内采集」生效,常驻采集读的是安装器那份
- 未安装/问不到时不显示这一行(没有"采集端读到的配置"这回事,不能凭空造)

## 三、`log.path` 的漂移种子写进注释

`/tmp/<label>.err.log` 在 Rust 的 `macos_launchagent_plist` 与 Dart 的
`statusSnapshot()` 各写一份,今天"逐字相同"只是碰巧。已在 `logPath` 的文档里
写明正解:让 `AutoStartPlan` 返回 `stderr_log_path`,Dart 只读不算。

## 四、顺带补上 label 守卫(等 ccb6c3f 落地后才敢提)

`⑥` 组:字面量只许出现在 `lib/host/autostart_label.dart`,两个消费者必须引用
`kDefaultAutostartLabel`。

## 验证

- `flutter test` → **556/0**(新增 7 条:接缝 6 + 卡片 5,另含 label 守卫 2)
- `dart analyze lib bin` → No issues found
- 变异自证:
  · 把 `fromJson` 里 `probeAvailable == false` 分支短路 → `⑦` 两条 ★ 报红(其余仍绿),恢复即绿
  · 把卡片的 `if (mismatch)` 短路 → 「与界面输入不一致」报红,恢复即绿
## 这个洞怎么来的

installer 这轮把「探针查不了」从**失败**改成**只 ⚠**(对的:不能把平台限制说成
缺陷)。而卡片的判据是"installer 退出码 0 → 停掉本界面采集"。两者相乘:

    探针不可用的平台(当前 Windows)
      → install 返回 0
      → 卡片停掉 GUI 采集
      → 而常驻进程是否起来**没人知道**(state=unknown)
      → 净效果:两个都没在采,卡片还很平静

## 为什么仍选择"停",而不是"等确认了再停"

两个代价不对等,且 team-lead 已核实**跨形态的锁是失效的**(GUI 在沙箱容器里查
`Containers/.../agent.lock`,agent 由 launchd 拉起锁真实家目录 —— 两个进程锁的是
两个不同文件,谁也不挡谁)。因此"等确认"并不能防双写:GUI 查容器那把锁永远是
空的,照常启动 → **双写是必然,不是可能**。

   · 停早了 = 缺数据(用户看得见、能补救:手动点开始采集)
   · 停晚了 = **双写**:两条 seq 流混进同一个 device_id,服务端按会话去重分不清
     它们,历史曲线出现无法解释的毛刺,**而且不报错**

故"宁可少采也不污染数据",但必须让用户知道这是我们的选择:

    「已提交安装;<为什么确认不了>。为避免与它重复采集同一台设备,已停止本界面
      的采集 —— 宁可少采也不污染数据。请用系统工具确认后再决定是否手动开始。」

## 实现

`_run` 里**先停、再刷新、再解释**(顺序不能反):停采集的时序一个字没动,
只是刷新之后多一步判断 —— `state == unknown` 才把这段话写进 `_note`。
拿到"在跑"证据时一个字都不加(不制造多余焦虑)。

## 验证

- `flutter test` → **559/0**(+3:装成功但确认不了 → 有解释;装成功且 running → 无解释;安装失败 → 不停也不解释)
- `dart analyze lib bin` → No issues found
- 变异自证(两次均恢复):
  · 删掉 `_explainStopIfUnverified` 的调用 → 「装成功但确认不了进程」报红,另一条仍绿
  · 去掉 `state != unknown` 的提前返回 → 「装成功且 running」报红(说明"不多话"这半边也被守住了)

## 未做(已单独立项,不在本笔)

锁的跨形态修复:GUI 侧改用进程探针(`pgrep -f <installBinDir>/pmon-agent`)判断
agent 在跑 —— agent 的绝对路径 GUI 能由 `app_paths` 推导,`pgrep` 是查进程不是读
文件,不需要跨沙箱访问权限;agent 侧继续用锁,只管"防两个 agent 实例"。
跨形态靠进程探测、同形态靠锁,各管一段。
## 依据:本 SDK 上 main 的返回值完全不设退出码(实测矩阵)
Dart SDK 3.13.3,各跑 `dart run` 与 `dart compile exe` 两种形态:

| 写法 | `dart run` | `dart compile exe` |
|---|---|---|
| `int main() { return 3; }` | **0** | **0** |
| `Future<int> main() async { return 3; }` | **0** | **0** |
| `void main() { exitCode = 3; }` | 3 | 3 |

即**只有显式 `exitCode =` / `exit()` 才生效**(官方文档说 `Future<int> main` 会设,本 SDK 不成立)。
所以 `cffdc50`「安装器不再撒谎、返回非零」的代码逻辑对,但**进程边界是死的**;
`pmon_agent.dart` 同样是 `Future<int> main`,后果更重:plist 用
`KeepAlive.SuccessfulExit=false`,launchd 靠「非零退出」判崩溃 —— 退出码被吞成 0
等于**采集端崩了也不会被拉起**,用户只看到历史断档,无任何报错。

## 改动:`bin/pmon_agent.dart`
`Future<void> main` + `exitCode = await runAgent(argv)`;函数体抽成 `runAgent`
(返回码语义清晰、可被测试直接调)。**语义逐条守住,没有一刀切归零**:

| 退出点 | 改前 | 改后 | 理由 |
|---|---|---|---|
| `--help` | return 0(被吞成 0) | 0 | 帮助是正常完成 |
| `--print-plan` 成功 | return 0 | 0 | 只打印,正常完成 |
| `--print-plan` 且核心返回 error | return 0 | **1** | 用户要的「计划」没拿到(iOS 不支持/路径非法);退出 0 会让脚本以为拿到了产物 |
| 抢不到单实例锁 | return 0 | **0(刻意不变)** | 非零会被 launchd 当崩溃立刻重启 → 两进程互踢成无限重启循环,比"没起来"更糟 |
| `--duration` 正常到期 | return 0 | 0 | 冒烟/CI 的正常路径 |
| Ctrl-C(SIGINT) | `exit(0)` | 0 | 用户主动停;非零会让 launchd 立刻拉起("关不掉的进程") |
| 常驻等待(Completer) | return 0 | 0 | 理论不可达(只有信号或 kill 能结束) |
| 未捕获异常(核心库缺失等) | 无 catch → 255 | **255(不变)** | 实测 `Future<void> main` 抛异常仍是 255;崩溃必须非零,否则 launchd 不重启 |

顺带把 `--print-plan` 里硬编码的 label 换成 `kDefaultAutostartLabel`
(label 唯一真相源是 `host/autostart_label.dart`,不在 bin/ 再写一份)。

## `pmon_viewer.dart` / `relay_check.dart`:**不需要改**,它们已经是显式 `exit()`
核对结果(`grep -c "exit("`):
- `pmon_viewer.dart`:**7 处** —— `exit(4)`(缺 --account / 启动失败 / 缺 --device)、
  `exit(3)`(配对失败 / 未订阅成功)、`exit(0)`(--list),末尾 `exit(ok ? 0 : 2)`。
- `relay_check.dart`:**2 处** —— `exit(same ? 0 : 1)`、catch 里 `exit(1)`。

`exit()` 比 `exitCode =` **更强**(立即终止并带上码),失败语义本来就到达边界。
我实测确认并写进了新套件:`pmon_viewer` 缺 `--account` → **4**,`relay_check` 打不通 → **1**。
顺带说明:`viewer-e2e-test.js:257` 那条 `cli.code === 0` 断言**因此是有效的**(不是死断言)。
为"统一写法"去把它们改成 `exitCode =` 是纯噪音,且会削弱语义,未做。

## 新增 `tests/cli-exit-code-e2e.js`(11 项,已接入 run-all)
**一律读子进程真实 exit code**(`spawnSync`/`spawn` 的 status),**不 import 被测代码断言返回值**
—— 断言返回值正是这次漏掉的东西。覆盖:
- 必须 0:`--help`、`--print-plan`、**抢不到单实例锁**、`--duration` 到期(后两条用"先起一个持锁实例再起第二个"构造,temp HOME 隔离)
- 必须非零:`pmon-install install --agent-bin <不存在>`、`pmon-viewer` 缺 `--account`、`relay_check` 打不通网关

## 变异自证
1. **旧写法复现缺陷**:把 `main` 临时改回 `Future<int>`,并让 `--help` `return 7` ——
   新写法实测进程退出码 **7**;旧写法实测 **0**(7 被吞)。同一份代码、只改 main 签名。
2. **测试确实会红**:临时注释掉 `pmon_install.dart` 的 `exitCode = await runCli(argv);`
   → `cli-exit-code-e2e` ⑤ 变红(`exit=0`),`installer-clean-e2e` 的
   「说失败⇒退出码非零」同时变红;恢复后两套全绿(11/0、34/0/1)。

## 验证
- `dart analyze lib bin`:**No issues found!**
- `flutter test`:**559/0**(含他人新增)
- `cargo test --release`:**61/0**
- `bash tests/run-all.sh`:**32 套件 31 通过 / 1 失败**(唯一红仍是隐私扫描,未碰)
- `node tests/cli-exit-code-e2e.js`:**11 通过 / 0 失败**
- `guard-structure-test.js`:**44/0**
## 为什么必须补(不是防御性代码)

`agentLockPath` = `ConfigStore.dir/agent.lock`,而 `ConfigStore.dir` 由 `HOME`
决定。macOS **沙箱** GUI 的 `HOME` 是应用容器,agent 由 launchd 拉起、`HOME`
是真实家目录:

    GUI   → ~/Library/Containers/com.pmon.pmonClient/Data/…/agent.lock
    agent → ~/Library/Application Support/pmon_client/agent.lock

**两个进程锁的是两个不同文件,谁也不挡谁** → 沙箱 GUI 里 `isHeldByOther()`
恒为 false。于是"agent 已在跑、用户又点了一次开始采集"→ 必然双写:两条 seq
流混进同一个 device_id,服务端按会话去重分不清,历史曲线出现无法解释的毛刺,
**且不报错**。`da88e30` 只堵住了"装完之后"那个窗口,这条路径它堵不住。

## 只修 macOS(不要把三个平台一起改)

| 形态 | GUI 的 HOME | agent 的 HOME | 锁是否同一文件 |
|---|---|---|---|
| macOS 沙箱 | 应用容器 | 真实家目录 | ❌ |
| macOS 非沙箱 / Windows / Linux | 同 agent | 同 agent | ✅ |

故 `probeInstalledAgent()` 在非 macOS 上直接返回 `notApplicable`,**一次都不执行
`pgrep`**(Windows 根本没有这个程序)。有一个测试专门守这条,防后人"统一成全平台"。

## agent 路径为什么取自 CLI,而不是本地推导

沙箱 GUI 里 `installBinDirFor(macOS, homeDir)` 的 `homeDir` 就是容器路径,算出来
是"容器/…/bin/pmon-agent",用它 `pgrep` 永远匹配不到 —— 更糟的是会得到一个
看起来确定的 `notRunning`。CLI 是非沙箱进程,`status --json` 报的
`installed.agent_path` 才是 launchd 实际启动的那条(也是 installer 侧
`probeAgentProcess()` 用的同一处真相源)。

## 判据

· `/usr/bin/pgrep -f <RegExp.escape(path + ' --collector')>` —— 绝对路径(沙箱 PATH
  可能被裁剪)、转义(家目录里的 `.` 会当通配符)、带 `--collector`(与 plist 的
  `ProgramArguments` 对齐,与 installer 同一套口径)
· exit 0 → running;**exit 1 → notRunning(唯一可信的"没在跑")**;
  其它非零 / `ProcessException` → `undetermined`(不能当成"没有")
· `undetermined` → **不放行**:双写是污染数据,漏采只是缺数据
· 锁命中优先(它能挡住探针看不出的"两个 GUI 实例"),且命中后不再多花一次 CLI 往返

## 一个如实记录的边界

`status()` 拿不到快照(打包后的 .app 且从未装过 CLI)时 `agentPath` 为空、无从
探测 → **放行**并留 `resident_probe_skipped` 日志。理由:若这也拦,等于让没装过
常驻采集的机器彻底无法采集(确定的永久性回归),换来挡住"装过之后又把 CLI 删了"
这个很偏的边界。这一条我在回报里也点了名。

## 验证

- `flutter test` → **572/0**(新增 13 条)
- `dart analyze lib bin` → No issues found
- 真机(非沙箱调用方,agent pid 1737 在跑):
  `cli 报的 agent_path=…/pmon_client/bin/pmon-agent` → `探针结论=running`
  → `合成结论=后台常驻采集已在运行(进程探测确认),本界面仅查看(不会重复采集)`
- 变异自证(三次,均已恢复):
  · 去掉 main.dart 的 macOS 分支 → 源码守卫 ⑤ 报红
  · `exitCode != 0` 一律当 notRunning → 「其它非零 → undetermined」报红
  · 去掉"非 macOS 不调探针"守卫 → 「Windows/Linux/Android 一次都不执行」报红
## 为什么单为一条日志加测试

`_guiCollectorBlockReason()` 里只有一处"不拦"的例外:`status --json` 拿不到快照
(打包成 .app 且从未装过 CLI)时 `agentPath` 为空、无从探测,于是放行。理由成立
(否则没装过常驻采集的机器彻底不能采集),但它是**唯一一处靠日志才能复盘的判断**:

    将来有人问"这次为什么没拦住" → 没有日志就只能猜,
    而猜出来的原因通常比没有原因更糟。

故加两条:
1. **落盘验证**:临时目录注入 `StartupLog.dir`,写一条 `resident_probe_skipped`,
   读回 JSONL,断言 `e` 与 `f.reason / f.state / f.detail` 原样保留 ——
   特别是 `detail` 不能被脱敏逻辑吞掉(那条是排障的唯一线索)。
2. **源码守卫**:`main.dart` 里必须留着这句 `StartupLog.event('resident_probe_skipped'`,
   防"嫌麻烦删掉"。

## 管道本来就在工作(实机证据,不是推断)

沙箱容器里的 `startup_log.jsonl` 现有 `boot ×3 / restore_done ×2 / started ×2`,
且 `main()` 第一段就 `StartupLog.dir = ConfigStore.dir`(先于任何 `_start`)。
也就是说这条链路是通的,新事件只是加了一个名字。

## 验证

- `flutter test` → **574/0**
- `dart analyze lib bin` → No issues found
- 变异自证:把事件名改成 `resident_probe_SKIPPED_MUTATION` → ⑥ 的源码守卫报红,恢复即绿
## 根因(不是我们的代码,是 SDK 行为变了)
```
$ dart compile exe bin/pmon_install.dart
'dart compile' does not support build hooks, use 'dart build' instead.
Packages with build hooks: objective_c.
```
`objective_c` 是 `file_picker` 的传递依赖。于是 README 与历史脚本里的
`dart compile exe ...` **全部失效**,用户照抄必失败 —— 典型"文档在说谎"。

替代命令:`dart build cli -t bin/<x>.dart -o <dir>`,产物布局也变了:
`<dir>/bundle/bin/<x>` + `<dir>/bundle/lib/<libs>`(多一层 `bundle/`,多出动态库)。

## 1) "怎么构建"收敛到一处:`client/scripts/build_cli.sh`
- 默认构建 `pmon_agent` + `pmon_install`,可追加其它入口(`pmon_viewer` 等)。
- 权威产物是 **bundle**(exe 与 lib/ 必须一起用),同时产出**扁平副本**
  `build/pmon-agent` / `build/pmon-install`,并把 `bundle/lib/` 的动态库平铺到 `build/`。
- 构建命令不再出现在别处:`client/README.md:98`、`:765-766` 已改为
  `bash scripts/build_cli.sh`,根 `README.md:916` 的描述也改为指向脚本;
  `bin/pmon_agent.dart:13`、`bin/pmon_install.dart:6` 的注释同改,
  `pmon_install.dart` 找不到可执行文件时的提示也改为指向脚本。

## 2) 安装器落位要带运行期库
`_install` 新增 `_runtimeLibsNextTo()`:覆盖两种产物形态
- bundle:`<bundle>/bin/<exe>` + `<bundle>/lib/<libs>`
- 扁平:exe 与库同目录(脚本会把库平铺到 `build/`)
只按扩展名收 `.dylib/.so/.dll`,**不整目录拷贝**,并排除核心库(它单独落位,避免拷两遍)。

**依据(实测,不是猜)**:
- `otool -L build/cli/pmon_agent/bundle/bin/pmon_agent` 里**没有** `objective_c.dylib`
  —— 它不是链接期依赖,而是运行期按需 dlopen 的库。
- 把 exe 单独拷到临时目录(不带该 dylib)后跑 `--help` 与
  `--collector --duration 2`:**都 exit 0,日志无 dlopen/缺库错误**
  ⇒ 当前 CLI 已走到的代码路径**并不需要**它。
- 结论:拷贝是**自包含保险**(bundle/lib 是 SDK 为该 exe 声明的运行期依赖集),
  不是修复一个正在发生的失败。代价一次文件拷贝;收益是将来若某条代码路径用到
  ObjC 桥接,不会在运行期才报 dlopen 错、且错得与"装不上"完全不像。
第一版只在"exe 的 ../lib"里找库,实测**扁平形态装出来没有 objective_c.dylib**
(脚本已把库拷到 build/,但 exe 的 ../lib 指向 client/lib);已补上"exe 同目录"这一支。

## 3) `build/pmon-install`:**保留**,继续由脚本产出
理由不是"兼容旧习惯",而是:GUI 的常驻采集卡按候选链找 CLI,
**第一候选就是 `build/pmon-install`**(`lib/host/resident_service.dart`)。
若它不存在,候选链只剩 `dart run` —— 而**最终用户机器上没有 Dart SDK**,
那条候选在真实安装里等同不可用,卡片会永远显示"找不到 CLI"。
故让脚本继续产出该路径,候选链才有意义。

## 4) 完整验证:新命令构建 → 临时 HOME 安装 → 落位后真跑
```
$ client/scripts/build_cli.sh
   dart: .../flutter/bin/dart   版本: Dart SDK 3.13.3
   -- pmon_agent --   ✓ bundle: build/cli/pmon_agent/bundle    ✓ 扁平副本: build/pmon-agent
   -- pmon_install -- ✓ bundle: build/cli/pmon_install/bundle  ✓ 扁平副本: build/pmon-install

$ ./build/pmon-install --help                      → exit 0
$ HOME=$TMP ./build/pmon-install install --no-start --label com.pmon.collector.flat
  ✓ 已落位: $TMP/Library/Application Support/pmon_client/bin/pmon-agent
  ✓ 已落位: .../bin/libpmon_core.dylib
  ✓ 已落位运行期库: .../bin/objective_c.dylib
  ✓ 配置: .../config.json
  ✓ 已写入: $TMP/Library/LaunchAgents/com.pmon.collector.flat.plist
  ✗ launchctl bootstrap → exit 5 Input/output error   ← 本环境受限(老事实),失败语义正确非零
$ <落位目录>/pmon-agent --print-plan               → exit 0,打出完整 plist 计划(核心库加载成功)
$ HOME=$TMP ./build/pmon-install status --json      → 路径全部指向临时 HOME,running=false
$ HOME=$TMP ./build/pmon-install uninstall --label ...flat → exit 0,plist 已摘除
```

## 5) e2e 跟着更新(`tests/installer-clean-e2e.js` 34 → 37 项)
- 采集端候选**优先取 bundle 形态**(`build/cli/pmon_agent/bundle/bin/`),
  否则"落位要不要带运行期库"这条测不出来。
- 新增断言:`bundle 的运行期库已一并落位(objective_c.dylib)`。
- 新增断言:**落位后的可执行文件真的能跑** —— `--help`(exit 0) 与
  `--print-plan`(exit 0 且输出含 `install_commands`,证明 exe + 核心库 + 运行期库三件齐)。

## 验证
- `dart analyze lib bin`:**No issues found!**
- `flutter test`:**574/0**
- `cargo test --release`:**61/0**
- `bash tests/run-all.sh`:**32 套件 31 通过 / 1 失败**(唯一红仍是隐私扫描,未碰)
- `node tests/installer-clean-e2e.js`:**37 通过 / 0 失败 / 1 跳过**(跳过=launchd 受限)
- `node tests/cli-exit-code-e2e.js`:**11/0**
- `guard-structure-test.js`:**44/0**

## 未做(边界外,需他人跟进)
`client/lib/host/resident_service.dart:317/351/414` 与
`client/test/resident_service_test.dart:299` 里仍写着 `dart compile exe ...` 的提示文案
(那是 GUI 侧文件,本轮不碰)。建议替换为:
`请先执行 client/scripts/build_cli.sh(产物 build/pmon-install)`。
另 `client/lib/**` 里"纯 Dart 才能 dart compile exe"的说法是**能力描述**(仍然成立),
未改。
`statusSnapshot` 里 `/tmp/<label>.err.log` 是唯一还没收敛的路径(Rust 的
`macos_launchagent_plist` 把 `StandardErrorPath` 写成同一形状,两边各一份)。
原注释只说了"应该让 AutoStartPlan 返回它",没写清**改成什么样**,
后人读到仍不知道落点在哪。按 team-lead 要求补上具体形态:

  读 `AutoStartPlan.stderr_log_path`,**勿再在本地拼 `/tmp/<label>.err.log`**
  —— 与 `allow_failure` 同款:语义由 Rust 给,Dart 只消费。

仅注释改动,无行为变化。边界不变:需要改 Rust 数据结构 + FFI 出口,
不在"client 路径"这一轮范围内。

验证:`dart analyze bin` No issues found;`guard-structure-test.js` 44/0。
## 问题:我们把用户送到了一条死路

`resident_service.dart` 有三处**面向用户**的提示让用户敲
`dart compile exe bin/pmon_install.dart -o build/pmon-install`:

- 找不到 CLI 时(`status()` 的 detail)
- CLI 太旧、不认识 `--json` 时(`_statusOnce` 的 detail)
- 安装/卸载前找不到 CLI 时(`_action` 的 message)

而 Dart 3.13 起该命令遇到 build hooks(依赖链里的 `objective_c`)**直接失败**,
用户照做后看到的是 `'dart compile' does not support build hooks` ——
比没有提示更糟,因为**他会以为是自己环境坏了**。

## 改法:只指真相源,不抄命令

三处统一成:`client/scripts/build_cli.sh`(产物 `build/pmon-install`)。
构建入口的真相源是那个脚本(engineer-2 已把 README 与两个 bin 的注释/错误提示
一并改指它),这里不抄一份参数序列 —— 抄一份就多一处会漂移的副本。

## 加了一条守卫,防它再长回来

`⑧ 构建命令只许指向能跑通的那条`:
1. 源码守卫:`resident_service.dart` 里不许再出现那个精确形态
   (`dart compile exe bin/pmon_install.dart` —— 它只会出现在"让用户照着敲"
   的位置),且必须含 `client/scripts/build_cli.sh`
2. 行为断言:`status()` 在"找不到 CLI"时给出的 detail 必须含 `build_cli.sh`;
   装过 CLI 的机器跳过该用例(不假绿)

## 顺带确认:回落链不再被触发

新构建产物(`build/pmon-install`,6.2 MB,2026-10-27 23:46)实测**认识 `--json`**:

    候选: …/client/build/pmon-install        ← 第一候选直接答话
    候选: dart run …/bin/pmon_install.dart   ← 备用,未用到
    state=running pids=[1737]

`resolveCliCandidates()` 一个字符没改 —— 回落链从"今天的主路径"退回它本来的
身份(防御性代码),这正是不该把它写成主路径的理由。

## 验证

- `flutter test` → **576/0**、`dart analyze lib bin` → No issues found
- 真机 `status --json` → `state=running pids=[1737]`,三路径与 `serverUrl/deviceId` 解析正常

## 备案(未做)

`lib/` 里还有约 10 处文档注释用"能 `dart compile exe`"来解释"为什么这个模块
必须纯 Dart、不 import Flutter"。约束本身仍然成立(`dart build cli` 同样要求),
只是叫法过时。属文档口径整理,不在本笔。
## 改判:`r.ok` 不足以作为"已在跑"的判据

`install` 退出码 0 只承诺"**已注册**",三个平台语义不同(Rust `keepalive.rs` 事实):

| 平台 | 安装命令 | 装完立即在跑? |
|---|---|---|
| macOS | `launchctl bootstrap` + plist `RunAtLoad=true` | 是 |
| Linux | `systemctl --user enable --now` | 是(`--now`)|
| Windows | `reg add HKCU\...\Run` + `schtasks /SC ONLOGON` | **否,只在下次登录时起** |

旧逻辑"拿到 0 就停 GUI 采集"在 Windows 上是**必然**事故(不是概率):
停掉 GUI 采集 + 常驻要等下次登录 → 两个都没在采,而卡片还很平静。
这比双写更糟 —— 双写至少还有数据。

## 新判据

```dart
bool get _hasRunningEvidence =>
    _st.state == ResidentState.running && _st.probeAvailable != false;
```

- 有证据(`running` 且探针没说"我没查")→ 停 GUI 采集(防双写)
- 无证据 → **不停**,并在 `_note` 里说清:
  · 探针不可用(Windows 现状)→「已注册;本平台需重新登录或手动「开始」才会运行。本界面继续采集,不会断数据。」
  · 探针可用但没跑起来 →「安装已完成,但采集进程没有起来(原因见上)。本界面继续采集,不会断数据。」
- `probeAvailable == null`(老 CLI 无此字段)视同**有证据**:那种二进制报的
  `running` 是 `pgrep` 真查出来的,不能因为"没这个元数据"就否认证据

## 为什么这规则不放过双写

双写只可能发生在探针可用的平台(macOS/Linux)——那些平台恰好拿得到正面证据,
于是会停。Windows 拿不到证据,但它**压根不会有两个进程同时在跑**(安装命令里
没有 start 这一步)。**双写风险与"探针不可用"的交集为空**,故既不放过双写,
也不误停采集。上一轮的 A/B 是伪两难:把"所有平台"当成了一回事。

## 顺带:`_run` 的时序随之调整

以前是"先停 → 再刷新"(停不用等状态)。现在**必须先刷新再决定**,因为停的判据
就是刷新回来的状态。代价是:证据存在时,停采集比旧实现晚一次 CLI 往返
(新版 `build/pmon-install` 约 50ms);换来的是"没证据不乱停"。

## 验证

- `flutter test test/resident_card_test.dart` → **17/0**
- `dart analyze lib` → No issues found
- 变异自证(两次,均已恢复):
  · `_hasRunningEvidence` 短路成 `r.ok`(退回旧逻辑)→ 「探针不可用 → 不停」「探针可用但没起来 → 不停」两条 ★ 报红
  · `probeAvailable != false` 改成 `== true`(把"缺字段"当没证据)→ 「拿到在跑证据 → 停采集」报红
`tests/privacy-scan.js` 把 `/Users/<非白名单名>` 判成「高置信个人数据」,而我
在正则转义那条用例里用了 `/Users/me/Library/pmon(v2.3)/...` —— 那是**合成路径**,但扫描器
按形状判定,它照样红。

改成 `/Users/me/Library/pmon(v2.3)/pmon-agent`:
- `/Users/me` 在扫描器白名单里(与 `resident_service_test` 等既有用例一致)
- `(` 和 `.` 挪到目录名上,转义断言(`\.` / `\(`)一个都没减弱

工作树命中从 12 条降到 11 条(余下 6 条是我此前几笔提交历史里的同一字符串,
2 条是既有的公网 IP,3 条是 commit-msg 里的既有内容)。**历史那 6 条只能靠
重写历史清掉,不在本笔**(会连带重写并行同事的提交,需 lead 拍)。

验证:`flutter test test/agent_probe_test.dart` → 15/0;`node tests/privacy-scan.js`
不再报 `client/test/agent_probe_test.dart`。
## 现象(2026-10-27 实测)

`$HOME/.cargo/bin` 不在 PATH 时(非登录 shell / CI / cron 都是这样):

```
tests/run-all.sh: line 52: cargo: command not found
 ◉ FAIL  Rust 核心单测(59 项)
 套件结果: 30 通过 / 2 失败
```

而单独 `cargo test --release` 是 **61/0**。也就是说脚本把**环境缺失**报成了一个
**测试失败**,汇总里那条红指向 Rust 代码 —— 排查方向被彻底带偏。这与之前修过的
"护栏假装在工作"是同一族:**不能让环境问题冒充被测对象的问题**。

## 改法(两处 + 一个计数)

1. 脚本开头主动补 PATH:`$HOME/.cargo/bin/cargo` 存在就 `export PATH`,
   否则退回 `command -v cargo`(允许自定义安装位置)
2. 仍找不到 → 新增 `skip()`,打印「⚠ 环境缺 cargo($HOME/.cargo/bin 不在 PATH?)
   ——这是环境问题,不是 Rust 代码的问题」,标 **◉ SKIP**,**不计入失败**
3. `skip()` 与 PASS/FAIL 三态分明:算 FAIL 会让人查被测对象,算 PASS 会把覆盖
   缺口藏起来 —— 两者都是说谎。汇总行同步变成 `N 通过 / M 失败 / K 跳过`

## 验证

| 场景 | 结果 |
|---|---|
| PATH 含 cargo | 31 通过 / 1 失败(唯一红=隐私扫描,已知)|
| **PATH 不含 cargo,但 `$HOME/.cargo/bin/cargo` 存在** | **自愈生效** → Rust 套件 PASS → 31 通过 / 1 失败(改前是 30/2 假红)|
| 两者都没有(模拟 CI 无 Rust 工具链) | `◉ SKIP` + 原因说明,**失败计数仍为 0**(脚本不因此判失败)|

`bash -n` 语法检查通过。第三种场景用假 HOME + 精简 PATH 单验过检测段与 `skip()`。
## 为什么必须补这句(team-lead 拍板采纳)
三个平台的 `install_commands`(真相源 `rust-core/src/keepalive.rs`)语义不同:

| 平台 | 安装命令 | 装完是否立即在跑 |
|---|---|---|
| macOS | `launchctl bootstrap` + plist `RunAtLoad=true` | 是 |
| Linux | `systemctl --user enable --now`(`--now`) | 是 |
| Windows | `reg add`(HKCU Run 项) + `schtasks /SC ONLOGON` | **否,只在下次登录时起** |

于是 Windows 上「install 返回 0」**只承诺"已注册",从不承诺"已在跑"**。
可上游(GUI 常驻卡)曾把它读成"已启动",进而停掉自己的采集 ——
两个都没在采、界面还很平静。这行字把「启动」从**承诺**降级成**事实描述**。

## 改动(`client/bin/pmon_install.dart`)
1. 新增 `launchSemanticsNoteFor(PmonPlatform)`(平台作参数的纯函数):
   · Windows → `仅在下次登录时启动`
   · 其它   → `安装命令本应已使进程立即启动,但本平台无法探测其是否在运行`
2. `install` **成功路径**打印 `说明:已注册 · <上面那句>`。
   位置刻意放在 `hardFailures` 判定**之后**:安装失败时不该说"已注册"。
   触发条件是"本平台有没有探针"(平台属性),与是否 `--no-start` 无关。
3. `status --json` 的 `autostart` 增 `note` 字段(探针可用时为 null;
   不可用时为该句)—— 因为 Windows 上这行字是**唯一**的状态说明,
   GUI 读的是 JSON,不能只留在人话输出里。人话 `status` 也打印同一句。

⚠️ **这是"平台 → 启动语义"知识的第二份**(第一份在 Rust 的 `install_commands`)。
不能改成嗅探命令字符串(本项目明令禁止判 `cmd.contains('bootout')` 那类)。
正解与 `stderr_log_path` 同款:让 `AutoStartPlan` 返回 `starts_immediately: bool`,
Dart 只读不算。**本轮未做**(要改 Rust 结构 + FFI 出口),已写进注释,
并集中在**这一个函数**里,不允许出现第三份。

## 测试
- 新增 `client/test/installer_launch_note_test.dart`(5 项):本机是 macOS,
  跑不到 Windows 分支,故把平台做成参数逐条断言 —— 与 `app_paths_test.dart` 同一手法。
  含反面对照(Windows 那句不得出现"已启动";macOS/Linux 那句不得出现"登录"),
  以及两条**形状护栏**:读 `keepalive.rs` 断言 Windows 分支确实有 `/SC ONLOGON`
  且没有 `systemctl`/`launchctl`、Linux 分支确实有 `enable --now`。
  (写这条时踩了一次"抓错对象":`Platform::Windows =>` 在 `as_str`/`autostart_mechanism`
  里也出现,第一版抓到显示名导致假红,已限定在 `plan_autostart` 内、
  边界同时认 `=> {` 与 `=> Ok(AutoStartPlan {`。)
- `installer-clean-e2e.js` 38 项(+1):JSON `autostart.note` **两条分支都断言** ——
  探针可用必须为 null(不给假说明),不可用时必须非空且含"启动"。
- 变异自证:临时让 `probeAgentProcess()` 恒返回 unavailable →
  `status --json` 得到 `probe_available=false` + `note='安装命令本应已使进程立即启动…'`,
  人话 `status` 出现 `说明: …`,e2e 走"非空"分支仍全绿;恢复后回到 null 分支。
  ("install 真正成功 + 探针不可用"这个组合在本机**无法构造**:`launchctl bootstrap`
  在此环境恒失败(I/O error 5),install 永远走不到成功分支 —— 如实说明,
  不假装验过。install 侧与人话/JSON 侧调用的是同一个函数,字符串由单测覆盖。)

## 顺带修掉一个我自己引入的偶发失败(必须修掉,不能留着)
`cli-exit-code-e2e.js` 原用 `127.0.0.1:3107` 当"打不通的网关",
但实测**8 次里有 2 次 3107 上真的有人在听**(relay 往返成功、`relay_check` 退出 0),
"打不通 ⇒ 退出码非零"这条被静默反转 —— 它先是在 run-all 里红了一次我才发现。
改为**端口 1**(特权端口,正常环境永不监听),并在注释里写明"别换成看起来空闲的端口"。
换端口后连跑 10 次 0 失败。

## 验证
- `dart analyze lib bin`:**No issues found!**
- `flutter test`:**582/0**(含本笔新增 5 项)
- `cargo test --release`:**61/0**
- `bash tests/run-all.sh`:**32 套件 31 通过 / 1 失败**(唯一红仍是隐私扫描,未碰)
- `node tests/installer-clean-e2e.js`:**38/0/1 跳过**
- `node tests/cli-exit-code-e2e.js`:**11/0**(连跑 10 次稳定)
- `guard-structure-test.js`:**44/0**

## 归属备案
`tests/run-all.sh` 里我把套件计数 37→38 的改动,被他人提交
`205e862` 一并带走了(同文件、`git add` 粒度拆不开,与我上次遇到的情况对称)。
内容已在 HEAD,无丢失;此处留痕避免日后考古困惑。
此前账号体系只到"你是谁",这一轮补"你能做什么"与"账号出事怎么办":
忘了口令等于报废、授权只能发配对码(等于公开招租)、配额只报数不拦。

服务端
- shared/permissions.js:13 能力 × 3 角色 + PLAN_TABLE + authorize(),
  canViewDevice 降为 authorize(...,'VIEW_SNAPSHOT') 薄封装(单一真相源)
- auth_tokens 一表统管 verify/reset/invite,只存 sha256,原子消费
- 新端点:会话列表/踢人、改密、验证与找回、注销(30 天冷静期)、
  成员 CRUD + 角色、邀请接受、转移归属、解除归属、审计
- 配额 ENFORCE_DEVICE_QUOTA 改默认开启(唯一破坏性变更);用尽返回 402;
  未验证邮箱收紧到 1 台
- server/shared/mailer.js:outbox/console/smtp(smtp 未实现,如实回 delivered:false);
  令牌绝不进 HTTP 响应

客户端
- role_catalog.dart(Dart 侧镜像,跨语言一致性由测试锁定)
- device_members_card.dart + account_card 增验证状态/改密/注销/找回
- ViewerDevice.role 透传;成员卡只对"当前查看的那台 + 已登录 + 有角色"出现

护栏
- permissions-test 29 / user-lifecycle-test 66 / schema-consistency 第 14·15 段
- account_security_test 13 / viewer_page_test ⑩ 3
- 全量回归 34 套件 / 1838 项 / 0 失败

顺带修(同一类"环境冒充缺陷")
- user-lifecycle-test 改为自起临时实例(随机端口 + 自己的 outbox + 放宽限流)
- persistence-restart 端口不再写死 3100(被同机另一项目占用→假红)
- auth-mode-test 走真实邮箱验证,配额期待从 403 改为 402
- run-all/README 的套件计数与实跑对齐(Dart 416→598,标签长期漂移)
此前**没有任何运营端**:38 个端点全是用户视角(每条都带 WHERE owner_id=我),
看全站账号靠 psql、改档位靠手写 UPDATE、看在线上靠 docker logs。
运营动作既没有界面,也没有留痕。

身份模型
- ADMIN_TOKEN 环境变量(不加 users.is_admin:新库上"谁来设第一个管理员"
  要么手改 SQL,要么留一个"首次访问即管理员"的接口 = 经典接管漏洞)
- ADMIN_NAME 只作审计标签,不参与鉴权

四条失败关闭
- 没设口令 → 404(401 等于宣布"这里有管理台")
- 短于 32 字符 → 同样 404(ADMIN_TOKEN=123456 比没有更危险)
- 口令错 → 401,不回显任何片段
- 口令走 ?token= → 401(它会进 nginx 日志、浏览器历史、Referer)
- 比较用定长摘要 + timingSafeEqual;10 次/分钟/IP → 429,锁定期间正确口令同样被挡

交付
- server/business/admin.js:18 个端点 + CSV 导出(BOM + 公式注入防护)
- server/shared/admin-queries.js:运营者查询(分页/统计/存储/降采样)
- server/business/admin/:5 页 + assets,零依赖无 CDN,手写 SVG 图表
- server/shared/presence.js:在线判定抽出来,业务端与管理端共用(不写第二份)
- 写动作五个(改档位/封禁/强制验证/踢会话/解除归属),每个都落 admin_* 审计;
  CSV 导出同样记审计(带走的是全站邮箱)

三个真缺陷
- GET /api/audit **匿名可读全站审计**(实测 HTTP 200)→ 删除,改 /api/admin/audit
- 存储统计报错 99.9%:TimescaleDB 数据在 chunk 里,父表 32KB vs 实际 2802MB;
  超表 reltuples 也不可信(2802MB 报 0 行)→ 探扩展后累加 chunk + 精确 count(3s 上限)
- COPY server/business/*.js 不含子目录 → admin/ 漏拷时"镜像能建、容器健康、
  /api/admin/* 全通,只有 /admin/ 404";覆盖性测试按 require 图查,静态文件是盲区
  → 新增「静态资源目录必须被 COPY」段,并修正 expandGlob 对目录的处理

护栏
- tests/admin-api-test.js 80 项(9 个隔离实例,每个口令形态独占一个进程)
- dockerfile-coverage 45 → 49;全量 35 套件 / 1901 项 / 0 失败 / 0 跳过
- 变异自证:短口令当真→红;未配置改 401→红;CSV 去防护→红(直接打出裸公式行);
  删 COPY 那行→7 个文件点名报出

顺带修
- .env.example 的 ENFORCE_DEVICE_QUOTA 原写 0,与"代码默认开启"相反 ——
  照模板部署会把配额静默关掉,改 1
三处都是实测截图发现的,不是"看着可能有问题":

1. 总览「当前在线」用 LIMIT 500 采样算法。库里 1.3 万台设备时取到的是物理
   顺序最前一批,真正在线的可能一台都不在里面,页面显示"0",而 0 会被读成
   确定结论(旁边"采样 500 台"的小字没人看)。改为 SCAN 数 `presence:*`
   键个数,与设备表规模无关;Redis 不可用仍返回 null 而非 0。

2. 审计「明细」列的 JSON 没有断行机会(无空格),`white-space: normal`
   找不到断点 → 整串作为不可断词把表格撑宽,再被 `.card{overflow:hidden}`
   从右边缘硬裁,既无省略号也看不出被截了。改为包一层带 max-width 的 div
   (`table-layout:auto` 下 td 的 max-width 不可靠)+ 省略号 + title 悬停全文。

3. 存储表的行数把估算值和精确 count 混在一列,分不清真假。估算值灰化,
   精确值带「精确」徽标,表头去掉"(估算)"。

顺带修 countPresence 插进来时把 mountAdmin 的 JSDoc 顶开了的注释错位。
ENFORCE_DEVICE_QUOTA 默认翻成 1 之后,account/device-merge/auth-mode 三套
清一色报 E_QUOTA_EXCEEDED。根因不是配额坏了:这三套的造数方式是"注册 →
认领 2~8 台设备",而「未验证邮箱只能绑 1 台」比档位上限更早生效。

按"谁能表达意图"分工,没有改产品规则:
- account / device-merge:造数前 markEmailVerified(归并另抬到 team 档,它要
  造 6~8 台)。配额会把断言挡在更前面 —— 本该 409 的"认领别人的设备"先返回 402。
- auth-mode [D]:对照组原拿宿主机 :4000 顶替,隐含"它一定没开配额"。改为自己
  拉一个 ENFORCE_DEVICE_QUOTA=0 的 :4012,并断言它对 /me 报 quota_enforced=false。

顺带校正两处标签漂移(结构性守卫 48→50、镜像覆盖性 45→49),重算全量为
35 套件 / 1965 项 / 0 失败,README 同步。

全量实跑:35 通过 / 0 失败;隐私扫描通过。
新装一台实例"归谁管"以前只能靠 .env 里手敲 ADMIN_TOKEN:运维要记住随机串、
没有管理员概念、改任何业务配置都得改文件再重启。本轮把这件事收敛成
「浏览器里走一遍向导 + 配置落库 + 管理端在线可改」。

数据库(deploy/migrate.sql,两条路径共用)
- instance_config 单行表(CHECK id = 1):实例名、对外地址、自助注册、
  强制登录、设备令牌、配额、中继上限、邮件、审计保留期
- users.is_admin 列 + 部分索引
- bootstrap_state 三态 pending / adopted / ready,由「库里有没有用户」裁决 ——
  存量库升级不能断服,故不是布尔

向导(server/business/setup.js + setup/index.html)
四道护栏是前置条件而非加固项:
  1. INSTANCE_SETUP_TOKEN 未设或 <32 字符 → 404(不是 401,不对外宣布它存在)
  2. 只认 Authorization: Bearer;失败闸门在令牌校验**之前**(否则猜对就绕过限流)
  3. 只能成功一次,由数据库裁决:UPDATE ... WHERE bootstrap_state <> 'ready'
  4. 完成后永久 404,判据在库里(内存变量会让重启后复活)

鉴权(server/business/admin.js + admin-queries)
- 管理端双通道:ADMIN_TOKEN(应急/自动化)或库里 is_admin 的会话;
  审计记 operator,能区分"用管理员账号改的"和"用令牌改的"
- 新增 /api/admin/admins、账号提权/降权(不许取消最后一个可用管理员 → 409)
- 新增 /api/admin/instance-config:被 env 锁定的字段拒绝修改(409 E_ENV_LOCKED),
  不静默成功

配置层次
- 库是真相源,env 是显式覆盖层;GET 返回每个字段的来源(db/env/default)
- 密钥类配置一律不入库(库会被备份/导出/管理端读出)
- 改完同进程立刻生效(onChanged),不等 3 秒轮询

网关(server/gateway)
- **修复半个开关**:业务端的 REQUIRE_AUTH / REQUIRE_DEVICE_TOKEN 搬进库后,
  网关若仍只读 env,管理端点开"强制登录"只会关住业务端,订阅通道照样放行匿名。
  改由 index.js 每 3 秒把「库 + env 覆盖」推入 gateway.setSwitches(),
  网关自己不碰数据库(热路径只读内存字段);/health 报生效值而非 env 字面值
- 单测用假 this 直调原型方法时 this.sw 为空 → 用冻结单例兜底,避免
  TypeError 被误报成"会话索引坏了"

测试
- 新增 tests/setup-test.js(97 项):四道闸、参数矩阵、成功路径、
  并发(用行锁把两个请求压进临界区,让"输家 409"成为必然而非运气)、
  存量库升级(只提权不改口令)
- 它自带 4 个临时库并跑真实 init.sql/migrate.sql —— 顺带把"全新库首次部署"
  这条历史上出过建表顺序坑的路径也覆盖了
- dockerfile-coverage:开关扫描补上声明式 ENV_MAP(动态下标扫不到,
  覆盖曾从 5 条悄悄掉到 2 条),并按字段名判定真正的消费方

验证:bash tests/run-all.sh → 36 个套件全部通过;六组变异测试逐一证明
关键护栏"删掉即红"。README 增「首次部署:先过一遍初始化向导」。
上一轮交付了实例配置表、向导与管理端,但有五个字段只落库没人读 ——
界面上能改、改完不报错、实际什么都不发生。本轮把它们全部接上,
并修掉一处更危险的同源缺陷。

## 同一道闸漏了两个入口(安全)

「不许取消最后一个可用管理员」只守了降权。封禁与自助注销都没守:
- 封禁唯一管理员后,available() 只查 `is_admin`(不看 disabled_at /
  deleted_at)→ 管理端继续挂登录页,却没人能登进去;没设 ADMIN_TOKEN
  就只能改库救。
- 管理员注销自己同理:账号进冷静期,is_admin 还在。

修法:把「可用管理员」口径收敛到 admin-queries.js 一处
(ACTIVE_ADMIN_WHERE / hasActiveAdmin / isLastActiveAdmin),
available()、降权、封禁、注销四个使用点全部走它。

## 五个字段接线

- mailer:从模块级 `process.env.MAIL_DELIVERY`(启动定死)改为宿主注入
  getter,每次发送重新解析 → 管理端改完立刻生效。
  顺带消除双名:ENV_MAP 写 MAIL_MODE、mailer 读 MAIL_DELIVERY。旧名保留
  (仅当 MAIL_MODE 未设时生效并警告),否则老部署会静默退回 console。
- 库默认 mail_mode 从"实际行为是 outbox"纠正为 console:outbox 会把重置/
  验证令牌明文写进服务器磁盘,这个便利不该是默认值。测试改为显式声明。
- public_url / instance_name:邮件落款(唯一真实消费点),解决"收件人分不清
  是哪个自建实例发的"。
- free_device_limit:**最严重的一个**。界面写"免费 3 台",配额却一直取
  PLAN_TABLE 常量,改成 10 也不生效。新增 planFor() 只覆盖 free 档
  (pro/team 是产品定义,不接受运维改),规则只在 permissions.js 一处。
- audit_retention_days:新增 shared/audit-prune.js,每 6 小时按批清理
  (ctid 分页 5000/批,make_interval 参数化),留空=永久,删完写一条
  audit_pruned 留痕。留痕本身也会被同样对待,符合规则。

## 顺手挖出的三处漂移

- idx_audit_created 只在 init.sql 有、migrate.sql 没有(按 created_at 删除
  会全表扫)。已补。
- 两份 compose 透传不一致:RELAY_MAX_BYTES 单容器有、四容器无;
  FREE_DEVICE_LIMIT 两边都没有(.env 设了进不了容器)。已补齐。
- user-lifecycle-test 断言配额 3,却依赖开发库里当时的值(被上一轮冒烟改成 5
  → 假红)。已显式声明 FREE_DEVICE_LIMIT=3,并把开发库复位。

## 新增守门:tests/instance-config-wiring-test.js(56 项,纯静态)

每个 instance_config 字段必须在**真正的代码**里被读一次(admin/assets 与
setup/ 不算 —— 出现字段名是展示/提交,不是消费);每个 env 名必须在
.env.example 与两份编排里;扫描器自己也要自证(编个不存在的字段名必须报红)。
它第一次跑就抓到 free_device_limit 的 0 消费方 —— 立刻证明不是空转。

## 界面如实标注

smtp 从未实现,向导页原写"smtp(真实发信,需 .env 配 SMTP)"是谎言,改为
"暂未接入传输层(选了也发不出信)";设置页对 outbox 标"令牌明文落盘"、
对 smtp 标"传输层未接入"。

## 验证

- setup-test:97 → 126 项(新增三入口、发信真实行为、保留期清理与留痕)
- 变异测试 4 组全部转红:封禁闸、注销闸、保留期留空照删、mailer 忽略库配置
- bash tests/run-all.sh → 37 个套件全绿
上一轮把 mail_mode 接通了,但 smtp 分支是 `return delivered:false` ——
也就是说生产上"验证邮箱 / 找回口令"等于不可用。这轮把它补成真的。

## 为什么自己写,不装 nodemailer

需要的只有 EHLO / STARTTLS / AUTH / MAIL / RCPT / DATA,是 RFC 5321 里最稳定的
一层;而为它引入一棵依赖树,等于把**后端进程的出网权限**交给一堆第三方代码,
而这个服务处理的是口令与重置令牌。零依赖(net/tls/crypto)约 600 行,可控。

## 三条硬约束

1. 失败必须说出来:任何一步不是预期码都抛 SmtpError,带 reason / stage /
   permanent(4xx 可重试、5xx 不可)。绝不"看起来发了"。
2. 口令不许明文上网:配了 SMTP_USER/PASS 而服务器不给 STARTTLS → 直接拒发
   (starttls_unavailable),不降级成明文 AUTH。
3. 口令不进异常/日志:错误消息只由「阶段 + 服务器回复」拼成,AUTH 命令行
   (含 base64 口令)永不入日志。测试里有专门一条断言异常不含口令。

## 协议细节里真会咬人的

- 多行回复(250- / 250 ):把半截当完整 → 后续命令与残行错位、莫名 500
- STARTTLS 后必须重新 EHLO:服务商故意只在密文通道公布 AUTH
- DATA 点填充:正文里独占一行的 `.` 会被当结束标记,**报文被截断且服务器回 250**
- 编码:纯 ASCII 走 7bit(可读),非 ASCII 走 base64(不赌 8BITMIME)
- 头折行:200 字中文主题 base64 后 800 字符,会顶到报文行上限
- 头注入:主题里一个 CRLF 就能凭空多出 Bcc:
- 交出 socket 给 TLS 后仍要兜 error 监听,否则未捕获异常带走进程

## QQ / 163 的三处不一样(PROVIDERS 表,只对识别到的主机生效)

- 465 是**隐式 TLS**,不是 587;未显式配置按预设取 465+secure
- **发件人必须等于登录账号**,否则 553 "Mail from must equal authorized user"。
  mail_from 是独立配置项,极易填成 noreply@example.com → 现在在**连之前**拦住
  (smtp_from_mismatch),文案直接说该改成哪个值
- 口令是**授权码**不是登录密码;535 不含这条信息 → 认证失败时追加提示
  (腾讯企业邮不追加,它用真密码,加了就是误导)
- 顺带堵纯配置矛盾:465 + SMTP_SECURE=0 会连上后一直等不到问候、15 秒超时,
  现在 normalize 阶段直接报"去掉 SMTP_SECURE 或改 587"

## 真实服务器只读探测(不带任何凭据)

smtp.qq.com:465 → 350ms,AUTH LOGIN PLAIN XOAUTH XOAUTH2,证书过系统 CA
smtp.163.com:465 → 314ms,AUTH LOGIN PLAIN XOAUTH2
说明 465 可达、多行解析在真实回复上正确、两家都公布 AUTH LOGIN。
真实投递需账号本人的授权码,用 tests/smtp-live-test.js(未配凭据时跳过)。

## 测试

- tests/smtp-test.js(92 项):自带假 SMTP 服务器,覆盖 STARTTLS 与 465 隐式 TLS,
  证书用 openssl 现场生成(含 IP SAN)。断言落在信封、报文头、编码、点填充,
  以及 **AUTH 是在明文还是密文通道上发的**
- 五组变异全部转红:去掉 From 校验 / 去掉 465 守卫 / dotStuff 恒等 /
  去掉服务商预设 / 失败也返回 delivered:true
- 教训:断言"连之前就拦住"的用例不能用 sendMail —— 防线一旦被删,测试会真的
  去连 smtp.qq.com 试 AUTH(收到 535),把离线断言变成依赖外网。改用纯函数
  normalize() 后同样抓得到变异,且永不联网
- tests/smtp-live-test.js:有凭据就真发一封(默认发给账号自己),没凭据 [skip]

## 其它

- 启动自检把"用哪个中继、什么形态、有没有明显配错"打在日志里
- 向导页与设置页去掉"smtp 未接入"的旧文案(现在真的会发)
- .env.example / 两份 compose 补 SMTP_*;示例邮箱用占位符,不写成像真人地址
- bash tests/run-all.sh → 39 个套件全绿
main 自合并基点 723852e 起 5 个提交、11 个文件(均在 client/),
与 eink 的对应实现是平行重做,且全部被 eink 逐文件超越:
- client/lib/ui/viewer_page.dart   eink 2229 行 / main 1517 行
- client/lib/host/viewer.dart      eink 1719 行 / main 1436 行
- client/lib/main.dart             eink 1559 行 / main 1368 行
- client/lib/host/lan_service.dart eink 483 行 / main 687 行(main 把 LAN 逻辑并进单文件,eink 拆到 lib/link/** 六文件)
- client/lib/ui/transfer_card.dart eink 668 行 / main 525 行

main 唯一独占的资产是 8 个 docs/ 文件(含 V4.0 方案 1038 行),
已抢救并将在下一提交落到 docs/legacy-v4/。

采用 -s ours 而非逐文件 cherry-pick:目的是让 main 成为祖先,
此后任何分支从 main 拉出都不会再撞上这 11 个文件的假冲突。
逐文件复核记录见 docs/decisions/ADR-005.md。
PLAN_TABLE 的每个字段都是卖点承诺,但 export / audit 两个字段活了
一个版本周期都没接线:export 连端点都没有,audit 只按角色判。
它们不报错、不红、没有任何信号 —— 因为从来没人做过「字段有没有消费者」
这个差集。

本提交加 PLAN_WIRING_STATE 台账 + 双向差集断言:
每个字段要么有消费者(cites 指向的字符串必须在源码里真实存在),
要么登记为未接线并写明裁定出处。加新字段不登记即红。

同时修准一处注释性谎言:permissions.js 文件头原文声称发配对码需要
device_token 作为在场证明,而实测 POST /api/pairing/code 不验任何设备
凭据。注释改成如实描述,并注明 M4′ 只修认领码、配对码同款洞本期不修。
注释性谎言比代码 bug 更难发现 —— 它会让下一个读代码的人停止审查这条链路。

对应:矛盾 17 / PRD §5.6.1 / OPEN-DECISIONS 已裁定表白名单会通胀,定义册不会
一、6 处 Unicode 状态符号清零
· link.dart 日志前缀改 [WARN](纯 ASCII,可 grep、跨终端不乱码)
· transfer_card 3 处、viewer_page 1 处用户可见符号改 Material Icons(size 16)
  ✓ → Icons.check_circle,⚠ → Icons.warning_amber,全仓语义与图标一一对应
· EinkBoard.diagnostics 由 (String, String) 扩为 (String, String, IconData?):
  图标单列槽位,而不是把符号拼回文本("走内网"的判定仍只在 _lanSummary 算一次)

二、配额卡档位机器键泄漏(用户可见 P0)
原来渲染「… · 档位 free · 我的设备 2/3 台」,free/pro/team 是机器键。
拆成两行:
· 我的设备 {n}/{m} 台(达上限加「 · 已达上限」),任一数字缺失则整行不渲染
· {plan_name} · 查看三档权益,服务端没给就是「未知档位」
不回落机器键、不造第四个词、不按配额反推档位(free_device_limit 支持实例级覆盖)。

三、让"缺失"可表达
· deviceQuota / ownedDeviceCount 改 int?,删掉解析层 `?? 3` / `?? 0`
  (否则配额永远是 3,上面"缺失则不渲染"根本不触发)
· plan 去默认值 'free';新增 planName(顶层 plan_name,不是 plan.name,无默认值)
· me() 里 `?? info.xxx` 保留并注明区别:那是"另一处也带了同名值",不是编造

四、防复发护栏 test/plan_text_guard_test.dart
扫 lib/ 三类形态:${…plan…} 插值、「· 档位」、Text( 之后**跨行**的 .plan。
每条都配正控制(喂真违规样本,断言确实命中)与负控制(planName 不误报)——
只验证"没抓到"不验证"抓得到",等于恒绿断言。
⚠ 在本仓库有两种码位形态:U+26A0+U+FE0F(emoji)与 U+26A0 单独(文字)。
按肉眼删字形会把不可见的 U+FE0F 留在字符串里 —— 源码看不出异常、界面是豆腐块,
而按 emoji 区间(U+2600-27FF)匹配的护栏看不见 U+FE0F,属静默回归。

本护栏同时量两把尺子:基字符与变体选择符;判据只认字符串字面量内的码位
(注释里的 ⚠️ 合法,本仓库有 60+ 处这种提醒,不设豁免清单)。
正控制专测「只删了字形、VS16 留在原地」这一种;负控制测注释与 URL 里的 // 不误判。
图标与颜色是两层独立信号:_result 失败分支颜色是 danger(红),
图标却用属琥珀层的 warning_amber —— 两层互相取消,且与既有先例不一致
(pairing_card.dart:377 / viewer_page.dart:2013 都是 error_outline + danger)。
warning_amber 留给 suspect 层(走中继、会消耗流量之类)。

symbol_guard 补两条负控制,把为什么不剥注释钉成可执行断言:
· 文档注释里的 ✅❌★ 表格不误报(不区分注释的实现会在这种行上红一大片)
· 行尾注释不误报(剥 // 到行尾会把 'http://x' 后面的真违规一起吃掉)
原判据是「按行数引号奇偶、只扫字面量内」,实测 4 种写法会**静默漏检**
(不是误报,是漏报 —— 不报错、不红、不留痕,只会得到一条绿):
· ''' 跨行块里单独占一行的符号(逐行扫描看不见,而长文案必然跨行)
· Text("…'⚠'…") 双引号外套单引号
· Text('…"⚠"…') 单引号外套双引号
· Text('${x ? '⚠' : ''}') 插值内再嵌字符串
根因:引号奇偶表达不了「两种引号类型 + 跨行状态」。一个奇偶位装不下两种状态。

改成判「在不在**注释**里」而不是「在不在字面量里」:
三态状态机(普通/字符串/注释),字符串状态跨行保持;扫**注释之外的全部字符**。
理由:UI 文案只可能来自字符串,字面量外的符号同样违规 —— 于是猜在不在字符串里
这件事可以整个省掉,只留一个状态机就能判对,且**错的方向是多报**。
选判据优先选错得响的那个:哑错误没有下一次修正机会。

残余(三处,方向全是多报,已写进文件头):
块注释不认嵌套 / raw 字符串不特殊处理转义 / 插值内嵌字符串会错位。

同时补:
· 星光平面 emoji 按代理对解码判定(原表只有 BMP,等于只防用过的那几个)
· 「对真文件也响」的自检:扫描本文件必须命中 —— 防止状态机卡死后对一切沉默,
  否则 lib/ 的零命中是假的
· 块注释内外分别断言的用例
## 修的是什么

改之前 `gateway.js` 把 `msg.payload.owner_id || null` 直接交给 `registerDevice`,
而 payload 是攻击者可任意填的 —— **任何未登录的人**发一条 `agent:hello` 自报
owner_id 就能凭空占有一台设备,连 claim 都不用走。这比认领洞更浅:认领洞还要登录。

## 四件事(顺序有意义)

1. **前提**:`registerDevice` 的 owner 实参改成**字面量 null**。不改这行,后面三件
   都只是摩擦。归属只能由已认证的 claim 建立。
2. **码**:设备端本地生成 6 位数字,随 `agent:hello` 的 `payload.claim_code` 上报,
   网关写 Redis(`claimcode:<id>`,TTL 300s,**断连即删** —— 码的语义是"设备此刻
   在这条连接上活着",TTL 只是崩溃兜底)。不给码单开 HTTP 端点:端点本身没有设备
   凭据,要求 device_token 会成环(令牌只发已归属设备),不要求则谁都能替任意
   device_id 发码 —— 那是自证,不是在场证明。
3. **验**:claim 对**已存在**的设备行必须有码且匹配;失败只有**一个** error 码一条
   文案(缺失/不匹配/过期/次数用尽四态合并,细分等于告诉攻击者爆破进度)。
   验码排在配额与归属之前:排在后面会泄露"这台设备有没有主人"。
4. **审计条件化**:`auditDeviceClaimed` 用 `INSERT … SELECT … WHERE EXISTS
   (… owner_id IS NOT NULL)`。原先是无条件 INSERT —— 会把没发生过的认领记成已认领,
   方向是以假为真,比漏记更坏且事后无法从表里分辨。

## 顺手修掉的一个真 bug:多参数 `del` 在 node-redis v5+ 只删第一个键

`clearClaimFailures` 写成 `del(k1,k2,k3,k4)`,实测只删掉 k1,其余**原样留着且不报错**
(网关用 v4,同一写法在那边是"删全部")。症状是"清零没生效":用户在一次成功认领后
仍被上一次的冷却拦住。实测证据:

    del('a','b','c')   → after [0,1,1]
    del(['a','b','c']) → after [0,0,0]

`tests/claim-proof-test.js` 的 B4 段以 `[429,429,…]` 抓到它。同一写法在
`shared/pairing.js` 的 verify 分支里还有一处(**既有代码**,单独一提交修)。

## 码格式不另写一份

`\d{6}` 与位宽改用 `shared/pairing.js` 的 `PAIRING_CODE_RE` / `PAIRING_CODE_LENGTH`。
本轮新代码一度在 business 里硬编码正则,被 `pairing-security-test` 的既有断言当场抓到
(那条断言本来就是为此存在的)。

## 测试

`tests/claim-proof-test.js` 57 项全绿,已接入 `run-all.sh`。断言的是**结果状态**
(查库 / 查 Redis)而不是"请求返回成功":旧实现里"静默忽略"与"静默接受"对客户端
都是成功的,只有查状态才分得开。每条"空集 / 恒为 NULL"的断言都配了必须变红的正控制
(claim 后必须看到非 NULL / 植入一条有主无审计的行必须数出 1 / 扫描器指向旧写法必须命中)。

套件默认连**独占 Redis db 14** 并在收尾 FLUSHDB:失败计数键名不含随机后缀,
不隔离会导致"连跑第二次开局就在冷却中"(实测第二次 41 通过 / 16 失败)。

回归:配对安全 84/0、协议 34/0、权限矩阵 53/0、schema 一致性 116/0、认领 57/0(连跑两次一致)。
## 这是一处既有代码的真 bug,不是新引入的

`verify()` 里码损坏时的清理写成 `del(key, pairingDeviceKey(deviceId))`。本类由
`server/business` 实例化,那里是 node-redis v5+ 语义:

    del('a','b')   → 只删 'a','b' 原样留着且**不报错**
    del(['a','b']) → 两个都删

实测(2026-11-08,`server/business/node_modules/redis`):
`del('k1','k2','k3')` 之后 `exists` 得到 `[0,1,1]`;数组形式得到 `[0,0,0]`。

## 为什么留着比删掉更坏

`pairing:device:<id>` 是"这台设备当前的码"的反向索引。它在本该失效时留着,就指向
一个已经删掉的码 —— 而 `issue()` 正是靠它找回旧码去删。留下的后果不是报错,而是
下一轮发码时删掉一个不存在的键、旧索引继续指向空气。

## 症状为什么难查

写入方(网关)用的是 node-redis **v4**,同一份调用在那边是"删全部"。所以这是
"同一行代码在两个大版本里语义不同、编译期不报、运行时也不报"的一类问题,
唯一症状是"清理没生效"。它不是本轮新写的代码带进来的,是随着 M4′ 的认领码
同类实现被对照着发现 —— 新代码里那处(`clearClaimFailures`)已在前一提交修掉。

回归:配对安全 84/0、认领在场证明 57/0、协议 34/0。
## 三件事

**M2 升档流程**:`/api/accounts/me` 下发顶层 `plan_name`(客户端只认它、缺失即显示
「未知档位」,此前服务端零下发 → 界面被迫显示机器键 `free`);新增
`GET /api/plans`(**台账驱动投影**:`PLAN_WIRING_STATE` 里 `unwired` 的字段一律不出现在
响应里)、`POST /api/plans/requests`、`GET /api/plans/requests/mine`;管理端三条
审批路由。`PLAN_TABLE.audit` 加档位判定 → 免费档 403 `E_PLAN_REQUIRED`(矛盾 17 裁决甲),
判定排在 `authzDevice` **之后**:先问"你有没有权看这台设备",再问"你的档位够不够",
否则会把"别人的设备"答成"你档位不够"。

**M3 历史窗口**:`/history` 下发 `allowed_windows: [{key,label}]`(**对象数组**,四个中文
标签只有这一条传输路径;写成裸键等于标签无来源);按档位截断并如实标注
`truncated` / `requested_window`,`bucket_sec` 跟**生效**窗口走而不是请求窗口;
400 文案改为固定文案(不回声 `req.query.window`、不念全量白名单)。
`team.history_days` 90→30。

**M7 保留期**:`retention_days` 改为读实例配置,不再有字面量。

## 一个真错误:`ok:true` 但档位没变

审批对**已驳回**的申请原来也返回 `ok:true, idempotent:true` —— 管理端看到成功回执,
而档位一点没变,且没有任何东西会告诉操作者他做错了。**回执与事实不一致比违反字面更坏**:
它没有下一次修正机会。现在区分两种"非 pending":

- `approved → approve` 是**真幂等**(同一结果,200,不重复写审计);
- `rejected → approve` 与 `approved → reject` 是**非法状态转移**,409 `E_REQUEST_NOT_PENDING`。

路由层原来把 `!r.ok` 一律映射成 404("这条申请不存在"),现在 404 与 409 分开:
否则操作者会以为挑错了行,而真实原因是那条申请已被驳回。

## 审计事件名:改的是 AC,不是代码

SPEC AC-09 原写 `plan_request_approved`,而 `API-CONTRACT` §8 与 `openapi.yaml` 写
`admin_plan_request_approved`。裁定采用带前缀的一侧,**依据不是"另一份文档那么写"**,
而是代码里已有 **12 个** `admin_*` 审计事件(`admin_plan_changed` / `admin_kicked` /
`admin_export` …):前缀让"管理端动作"这个集合可以按前缀一次扫出来;靠逐个记名字,
这个集合只能靠枚举 + 记忆,而枚举必漏。SPEC 已同步订正。

留一句方法论在那两个常量旁边:**差集只告诉你两边不一样,不告诉你哪边对。**

## 顺手清掉一份档位清单副本

`admin.js` 的 `setPlan` 硬编码 `['free','pro','team']`,改为 `Object.keys(PLAN_TABLE)`。
副本的害处不是"写错了",而是**它能跑**:将来加一档时这份列表不跟着变,新档位永远改不到,
而且不报错,只是校验说"档位不合法"。

## 测试

新增 `tests/plan-history-tier-test.js` **99 项**,已接入 `run-all.sh`。含三集同集断言
(响应键 / 台账非 unwired 键 / OpenAPI 属性集)与 **5 组负向对照**(多返回 export /
漏 audit / 台账种假字段 / OpenAPI 少写 / 多写),负向对照必须能红才有意义。

回归:permissions 60/0、admin-api 80/0、user-lifecycle 68/0、schema 一致性 116/0、
认领在场证明 57/0、配对安全 84/0。
## 三处前置此前**全部不存在**(我实测,不是文档说)

```
shared/events.js                     → 不存在
deploy/migrate.sql  CREATE TABLE events → 0 命中
server/ 下 "api/events"               → 0 命中
```

所以在这之前任何"事件名单差集为空"的断言都是**恒绿的空断言**:文件不存在 → 读到空
集合 → 差集为空,而"文件不存在"与"文件里没有多余事件"在输出上是同一个东西:空。

## 服务端

- `shared/events.js`:5 个名字的**唯一机器副本**(权威在 SPEC §6.1.1)。净化做的是
  **形状白名单**而不是字段名白名单 —— SPEC 只锁了事件名、没定义逐事件 props,此时
  在这里发明一套字段名等于实现方单方面定契约,然后让客户端照着写。形状约束
  (键名 `^[a-z][a-z0-9_]{0,31}$` / 值只接受标量 / ≤12 键 / ≤2KB / 敏感键名直接丢)
  能挡住"埋点变成第二张用户表"这条路上最现实的风险,且不假装知道产品要什么。
- `POST /api/events`:未知名与非法条目**丢弃并计 dropped**(AC-20,不是 400)、
  超 20 条只受理前 20(AC-21)、**写库失败仍 202**(AC-22)。认证可选但
  **带废令牌一律 401**(降级成匿名等于"发个废令牌就关认证");
  `body.account_id` 自报**被忽略** —— 否则谁都能往别人账号的漏斗里灌数,而漏斗是
  拿来算率的,被灌了就没有意义。
- 管理端 `/api/admin/events/summary`:只出按事件名的计数与首末时间。它的判据不在
  "能查出来",而在**查不出来** —— 这个端点没有任何办法取回某一条事件的内容。

## 客户端:必须有真实产生方,否则这张表永远是空的

这是本轮最容易"看起来交付了"的地方:端点全绿、表建好了,而**没有任何上报** →
表恒空、漏斗没有分子分母,而所有测试照样通过。**"以空为通过"的形态里这一种最难发现,
因为每一层单独看都对。**

`client/lib/host/event_reporter.dart` + `main.dart` 两个真实触发点(`app_open` 在配置
就绪、首帧将出时;`first_frame_shown` 用 `addPostFrameCallback`)。三条纪律:
不阻断业务(失败吞掉)、不重试(补报时序已经错了)、有界(丢最旧的)。

`app_open` **不能**放在 `main()` 最前面 —— 那时还不知道业务地址(`businessUrl` 来自
用户配置)。所以它落在"配置已就绪、首帧将出"这一刻;语义仍是"打开了应用",且这一批
能真的发出去。

## 建表只有一个落点

`events` 只写进 `deploy/migrate.sql`,`init.sql` 用 `\i /migrate.sql` 引用它 ——
这是本仓库既有约定(新表进 migrate,init 引用)。我最初给执行方的判据是"两个文件都
必须有"——那会把一份定义复制成两份,是错的,按约定改正。

`account_id` 用 `BIGINT REFERENCES users(id) ON DELETE SET NULL` 而不是 SPEC §6.1
原写的 `TEXT`:`users.id` 是 BIGSERIAL,TEXT 会让"按账号算率"每次都 `::bigint` 强转,
转不动时(LEGACY 自报串)只能静默留 NULL,而 NULL 与"匿名上报"在表里长得一模一样。
`ON DELETE SET NULL` 让注销后**保留计数、解除关联**(CASCADE 会让历史漏斗在注销那一刻
缩水,分母变了率就不可比)。

## 测试

`tests/events-test.js` **29 项全绿**,已接入 `run-all.sh`。关键一条是 **A7「表里真的
长出了行」**——没有它,前面每条都能在一个"什么都没落库"的实现上全绿。
AC-22 不是写注释:把 `events` 表临时改名造一次真实写失败,断言仍 202 **且**
`accepted=0` **且** 带 `write_failed` **且** 恢复后行数未变(只断 202 不够 ——
任何实现都会 202,因为 catch 住了)。

客户端 `event_reporter_test.dart` 6 项全绿,其中一条钉住"先抽干再发":发送期间
`track` 进来的新事件属于下一批,不被这一批的失败连坐(否则是"失败一次丢两批")。

回归:permissions 60/0、account 75/0、schema 116/0、admin-api 80/0、认领 57/0、
档位与历史 99/0;客户端全量 **595 通过 / 2 失败**,两条失败均为
`libpmon_core.dylib` 未编译(Rust 核心未构建,本机环境问题,与本次改动无关)。
## 修的是什么

`POST /api/pairing/code {device_id}` 原先**零鉴权地生成码并在响应体里返回**:

```
POST /api/pairing/code  {"device_id":"<任何存在的设备>"}
→ 200 {"code":"483920","expires_at":...}
```

只要知道一个存在的 `device_id`,**任何人不登录、不持有任何凭据**,就能拿到该设备
的配对码,再去查看端输入,换到该设备的查看授权。比认领洞更浅:认领洞还要先登录。

根因不是"端点忘了加鉴权",是**码的来源错了**。服务端凭什么相信"请求方就是那台
设备"?`device_id` 是可猜的公开标识;`device_token` 只发给已归属设备(新设备会
成环,M4′ 里已经推演过)。唯一不可伪造的是**那条已建立的连接本身**。

## 改成什么

码由设备本地生成(`client/lib/host/pairing_code.dart`),随 `agent:hello` 的
`payload.claim_code` 上报,网关在这一刻登记为配对码(`PairingStore.registerDeviceCode`),
**断连即删**(`forgetDeviceCode`),TTL 300s 只是崩溃兜底。

于是码的语义变成真话:**"设备此刻在这条连接上活着"**。屏幕上那个码因此永远是服务端
认可的那一个,不存在"屏幕显示 A、服务端认 B"。

**配对码与认领码是同一个字符串**:用户只有一个六格输入框的心智,两条链路各发一个码
只会让人念错。

`POST /api/pairing/code` 保留为 **410 `E_ENDPOINT_GONE`** 而不是删掉:404 会让人以为
路径拼错或版本不对,410 明确说"它被有意移除了,码从哪来"。

## 客户端

`pairing_api.issueCode` 改为**幂等只读**:返回单例里的当前码,不发网络。别把它改成
"每次调用都换一个码" —— 换了却不同时重发 hello,屏幕就会显示一个服务端从未登记过的
值,用户念它必然失败,而且看起来像服务端坏了。

## 顺带补上 M4′ 在客户端的缺口

上一轮 M4′ 只做了服务端:`grep claim_code client/lib` 命中 **0**。也就是服务端能力建好
了、测试全绿,而**真实客户端从不上报码** —— 生产上认领只能走 `isNew` 路径。同一个
"必须有真实产生方"的坑在 M5 里刚踩过一次(表恒空)。本次 `link.dart` 的 hello 已带
`claim_code`,两条链路同时有了产生方。

## ⚠️ 一个比原洞更大的发现:84 项配对回归网跑的是 9 天前的镜像

`tests/pairing-security-test.js` 默认打 `http://127.0.0.1:4000`,那是 docker-compose
的实例。实测 `pmon-business-1` / `pmon-gateway-1` 镜像构建于 **2026-10-30**,而本轮
改动在 **2026-11-08** —— 早 9 天。所以那些"84 项全绿"只证明旧镜像行为没变,对仓库里的
代码一个字都没说。

所以本套件(`pairing-code-origin-test.js`)**自起源码实例**(business + gateway,
随机端口,源码直跑),并在 `run-all.sh` 里那一段加了大字警告:在其余套件改成自起
之前,**不要把它们的绿当作本仓代码的回归证据**。

## 测试

`tests/pairing-code-origin-test.js` **16 项全绿**,已接入 `run-all.sh`。16 条里 13 条是
"拿不到"型(410 / 无码返回 / 猜码被拒 / 跨设备被拒 / 断连失效),3 条是"该拿到的人
拿得到"(hello 登记、verify 成功、一次性消费)—— 只验前者的话,一个把配对整体做死的
实现也能全绿。

客户端:`pairing_card_test` 6/6、全量 **595 通过 / 2 失败**(两条仍是 `libpmon_core.dylib`
未编译)。两条断言按行为变更改写而不是删除:
- "发码失败不静默" → 改为"**一次请求都不发**"(失败路径已不存在,但新风险更大:
  任何一次发出的取码请求都意味着码又能被第三方索取);
- "服务端返回 123456" → 改为"卡片显示的码必须与上报给服务端的是同一个"。

第一次跑本套件时 E 段红了两条,原因是我自己的夹具没关掉前一条连接 —— 网关判定
"这台设备还有别的活连接",正确行为就是不删码。已修,并在测试里写清这个坑。

回归:认领 57/0、埋点 29/0、权限 60/0、schema 116/0。
mtfyun added 2 commits 2026-11-08 20:12:32 +08:00
## 范围与边界

**本次只改代码层**:客户端包名、Android/iOS/macOS 标识、环境变量前缀、Rust crate 名、
类名/文件名、以及全部文案与注释。**数据层标识一处未动**(卷名 `pmon_pg_data`、
`POSTGRES_USER/DB`、连接串默认值),那些在阶段 B 单独做迁移,理由见下。

统计:**114 个文件、1687 处**替换 + **8 个文件/目录重命名**。

## 改了什么

| 类别 | 旧 | 新 |
|---|---|---|
| Dart 包名 | `pmon_client` | `inkwatch_client`(41 个文件的 import 同步) |
| Android namespace | `com.pmon.pmon_client` | `com.inkwatch.eink` |
| Android applicationId | `com.inkwatch.eink` | **保持不变** —— 它本来就已是新名 |
| Kotlin 包目录 | `kotlin/com/pmon/pmon_client/` | `kotlin/com/inkwatch/eink/` |
| macOS bundle | `com.pmon.pmonClient` | `com.inkwatch.eink` |
| 环境变量 | `PMON_*`(12 个) | `INKWATCH_*` |
| Rust crate | `pmon-core` / `pmon_core` | `inkwatch-core` / `inkwatch_core` |
| 动态库 | `libpmon_core.dylib` | `libinkwatch_core.dylib` |
| 类/文件 | `PmonApp` `PmonColors` `PmonChannel` `PmonCollectorService` `pmon_agent.dart` … | `Inkwatch*` / `inkwatch_*.dart` |

## 三处**必须单独说**的判断

**① applicationId 刻意没动。** `build.gradle.kts:49` 有既有说明:水墨屏专属版用
`com.inkwatch.eink`,与主干通用版**刻意不同**,为的是两版本能在同一台手机上共存
(versionCode 各自递增,同包名会互相覆盖且降级被拒装)。它已经是新名,动它反而
破坏这个设计。我改的是 `namespace`(内部分包名,只影响编译期),并把它统一到同一个
`com.inkwatch.eink`。

**② 局域网 P2P 线协议前缀一起改了**(`PMON-PUNCH:` / `PMON-PONG:` / `PMON-FRAME:` →
`INKWATCH-*`)。这**不是纯文案**:两端必须同时升级,否则新旧版本在同一局域网里互不
认。判断依据是这三个前缀只用于同一用户的两台设备直连,且当前尚未对外发布;若以后
要支持跨版本,正确做法是加协议版本协商而不是回退旧前缀。

**③ 数据层标识一处未动。** `deploy/` 下 28 个文件仍是 `pmon`,因为卷名/库名/库用户
的改动会直接导致连不上库或指向新空卷(`docker-compose.all.yml:26-29` 里写着:
改名会让 compose 去找 `inkwatch_pg_data` 这套新空卷,等于把生产清库)。实测当前
数据是 **2.5GB、128 个账号、58 台设备**,不是测试库。

## 安全网

动手前做了完整备份:
`.backup-rename-20261108/pmon_before_rename.dump`(`pg_dump -Fc`,152MB),
并记录了迁移前各表行数基线,供阶段 B 逐表比对。

## 验证

客户端全量 **595 通过 / 2 失败** —— 与改名前的数字**完全一致**,两条仍为
`libinkwatch_core.dylib` 未编译(Rust 核心未构建,环境缺口)。**数字一致**是这次
改名的核心判据:一个纯机械改名必须不改变任何测试结果,若通过数变了就说明改坏了东西。

服务端代码只动了注释与环境变量名,逻辑零改动,阶段 B 后一并回归。
## 做了什么(顺序有意义)

1. **备份**:`pg_dump -Fc` → 152MB dump,落在仓库外
   `.backup-rename-20261108/`,并留了改名前后的表行数文件。
2. **停应用**(nginx/business/gateway),保留 PG/Redis。
3. `ALTER DATABASE pmon RENAME TO inkwatch` + `ALTER ROLE pmon RENAME TO inkwatch`
   + `ALTER ROLE inkwatch WITH PASSWORD 'inkwatch_dev_only'`。
4. **复制卷**(不是改名):`pmon_pg_data → inkwatch_pg_data`(3.807GB)、
   Redis、nginx 日志三个卷。**旧卷全部保留**,作回滚点。
5. 改 compose 的卷名与环境变量默认值、`deploy/` 下全部脚本/配置、测试默认连接串、
   `.env.example`、`docs/`。
6. 用新 compose 起栈,删旧容器(`pmon-*` 共 6 个,含 9 天前的 migrate 容器)。

## 数据校验:这是本次唯一不能靠"应该没事"的部分

第一次我拿 `pg_stat_user_tables.n_live_tup` 当基线,**那是错的** —— 它是估算值,
实测 `devices` 估 58、实际 14559,`device_metrics` 估 0、实际 257 万。用它做校验
等于没有校验。

改成的办法:把 dump **恢复到一个临时库**,然后两张库**逐表精确 `count(*)` 对比**
(排除 `_hyper_*` chunk,父表计数已覆盖)。结果:

```
唯一差异:device_metrics  当前 2572652 / 备份 2572456  → 当前多 196 行
```

多出来而不是少了 —— 因为 dump 是 20:04:57 取的,而停服在 20:07:53,期间设备仍在
正常上报。最新数据时间戳 `12:07:53 UTC` 佐证了这一点。**196 行是正常写入,不是差异。**
逐表其它 40+ 张表行数完全一致。

校验库随后删除,容器内临时 dump 也删了。

## 两个踩到的坑(都已记录在流程里)

**① `docker exec` 不加 `-i` 时 stdin 不转发。** 我第一次用 heredoc 把四条 SQL 喂给
`psql`,结果**一条都没执行、也不报错** —— 看起来像"执行了但没生效"。改成 `-c` 逐条。

**② `ALTER ROLE` 不能重命名当前会话用户**(`session user cannot be renamed`)。
解法是建一个临时超级用户来执行,用完立刻删(已验证 `pg_roles` 里查不到它)。
注意 `DROP ROLE` 同样不能删当前用户,必须换回原身份再删。

## 有意没做的事

- **旧卷没删**:`pmon_pg_data`(3.807GB) / `pmon_redis_data` / `pmon_nginx_logs` 都留着。
  这是唯一的整体回滚点。确认新栈稳定运行一段时间后,可自行
  `docker volume rm pmon_pg_data pmon_redis_data pmon_nginx_logs`。
- **`pmon_dbg1` / `pmon_claim_verify` 两个遗留库没动**:它们是历史测试/调试残留,
  不是产品库。要清可以直接 `DROP DATABASE`,但删数据这种事我不代做决定。

## 验证

新栈 5 个容器全 `healthy`,`/health` business 与 gateway 均 **200**;
库内 `users=128 devices=14559`,与迁移前一致。

服务端套件(全部自起源码实例或纯离线):
认领 **57/0**、埋点 **29/0**、配对码来源 **16/0**、权限 **60/0**、schema **116/0**
—— 与改名前的数字**完全一致**。

客户端上一提交已验证 **595 通过 / 2 失败**(两条为 `libinkwatch_core.dylib` 未编译)。
mtfyun merged commit 7f8e02d4ad into main 2026-11-08 20:28:44 +08:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mtfyun/inkwatch#1