Commit Graph
138 Commits
Author SHA1 Message Date
PMon Dev 1d76c3b8ac feat(design-system): 合入 V2 线并统一命名为 Inkwatch,补齐深色主题与状态符号图标化
Design System Guard / guard (push) Failing after 7m11s
本提交收口本轮四件事。三者改动在同一批文件里交织(改名触及 theme.dart、
深色化也在 theme.dart),按文件无法干净切分,故合为一个可审计的提交,
下面按事由分述。

一、命名统一:Pmon* → Inkwatch*(合入 V2 线后的收尾)
  · 包名 pmon_client → inkwatch_client,Rust crate pmon-core → inkwatch-core。
    改名后必须 flutter pub get 重建 package_config,否则 dart analyze 会报
    1688 个"找不到 package:pmon_client"的假错误。
  · 符号:PmonColors→InkwatchColors、PmonPalette→InkwatchPalette、
    PmonTextStyles→InkwatchTextStyles、PmonRadius→InkwatchRadius、
    PmonEinkText→InkwatchEinkText、PMON_LOG_*→INKWATCH_LOG_*。
  · 覆盖 server/ client/ rust-core/ tests/ 全树;已用
    git grep -In 'Pmon\|PMON\|pmon' 复核,零残留(tokens.css 里最后一处
    §2.8 的类名注释也在本提交修掉)。
  · rust-core/Cargo.lock 的变动只是包名变化导致 [[package]] 字典序重排。

二、深色主题:58 键同名覆写 + Flutter 整屏深色化
  · tokens.css 增 dark 块(58 键),键名与浅色**逐一相同**——
    这是 token_parity_test.dart 的深色 parity 组在守的约束。
  · theme.dart:新增 InkwatchDarkColors / _assemble(palette, brightness),
    把语义色从"常量"改为"随 brightness 装配"。
  · main.dart:darkTheme + 显式 themeMode(ValueNotifier 持久化,不用
    ThemeMode.system —— 水墨屏用户要的是自己定,不是跟系统)。
  · theme_guard_test.dart C-15 → C-15′:从"禁止出现深色"改为"允许深色,
    但必须 58 键同名",护栏跟着设计意图一起改,而不是删掉。
  · 新增 dark_theme_test.dart / dark_ui_test.dart 覆盖装配与整屏渲染。

三、状态符号图标化:Unicode 字形 → Material Icons
  · symbol_guard_test.dart 按**码位**扫 lib/(注释之外),命中 5 处:
    transfer_card.dart 的 ⚠️/✓/✓/⚠,viewer_page.dart 的 ✓。
  · 只删字形会留下不可见的 U+FE0F,源码看不出、界面渲染成豆腐块,
    且按 emoji 区间匹配的旧护栏看不见它 —— 故护栏按码位判,且判据选
    "扫注释之外全部字符"(错了只会多报,不会漏)。
  · 改法:状态图标单独一槽,不拼进文案。
    - transfer_card.dart:_offerHint 由 String? 改为
      ({IconData? icon, Color? tint, String text})?,渲染为 Row(Icon+Text);
      「已收下」结果块同理(check_circle / error_outline)。
      图标着色:warning_amber→_p.suspect,check_circle→_p.online。
    - viewer_page.dart:_lanSummary() 返回 ({IconData? icon, String text}),
      诊断行由 ('对端内网', _lanSummary(), null) 改为 (…, lan.text, lan.icon)。
      调用点提到 return 之前只算一次。
  · 理由不只是"好看":字符符号在水墨屏上会渲染成方框,且无法随主题着色。

验证
  · client/scripts/test.sh:674 项全过(+674,0 失败,23s)
  · tests/run-guards.sh(RUN_AXE=1):
      web-token-guard.js              PASS  21/0
      phase3b-static-guard.js         PASS  87/0
      dockerfile-coverage-test.js     PASS  53/0
      schema-consistency-test.js      PASS 116/0
      client Dart 全量                PASS  674 项
      admin-axe-audit.mjs             PASS  42/42 格 viol=0(inc=12 有台账)
    6 套件 PASS 6 / FAIL 0 / SKIPPED 0
  · dart analyze:9 issues / 0 error(全部为既有 info/warning,非本次引入)
  · 两处 tokens.css(仓库内 admin/assets 与设计系统交付目录)逐字节一致
    sha256 088873cf6ece8da78135f96ca4633a537ac7802ea5f0fc8bd9c9294bb8a7bcaa

已知未做(写下来,不藏)
  · flutter analyze 在本机因仓库路径含中文(水墨屏工具)触发 analysis_server
    的 LSP 分帧 UTF-8 错误(FormatException: Unterminated string)。
    绕法是用 dart analyze,或把工作树放到纯 ASCII 路径下。
  · 窄屏降级态 + 失败态的组合真机复核:仍未做。
  · VoiceOver / NVDA 实机验证:NOT RUN。
  · 管理台登录框仍缺持久 <label>(现靠 aria-labelledby 指向标题)。
2026-11-09 17:24:56 +08:00
PMon Dev 89260024ee merge(mtfyun/main): 合入 V2 线(Pmon→Inkwatch 改名与 V2 功能),冲突保留本地设计系统
# Conflicts:
#	client/lib/main.dart
#	client/lib/ui/account_card.dart
#	client/lib/ui/device_members_card.dart
#	client/lib/ui/eink_board.dart
#	client/lib/ui/eink_mode.dart
#	client/lib/ui/pairing_card.dart
#	client/lib/ui/resident_card.dart
#	client/lib/ui/skeleton.dart
#	client/lib/ui/theme.dart
#	client/lib/ui/transfer_card.dart
#	client/lib/ui/viewer_chart.dart
#	client/lib/ui/viewer_page.dart
#	client/test/theme_guard_test.dart
2026-11-09 17:07:05 +08:00
PMon Dev 4194c6ee03 test(transport): 修正退避单调断言——jitter 向下取,封顶后允许回退
Design System Guard / guard (push) Failing after 21m51s
原断言要求退避序列全程单调不减,与实现冲突:rust-core/src/transport.rs 的抖动是向下取(delay = capped - offset,offset 最大 30%×cap),封顶后 capped 恒为 60s、样本落在 [cap-span, cap],后一个样本可能小于前一个(实测出现 57414 → 57413)。Rust 侧已有 capped_backoff_dips_within_jitter_band 断言同一语义。改为分档断言:未进入抖动带时要求递增(档位翻倍、抖动最多削 30%,必然成立);已进入 [42000, 60000] 时只要求不跌破下界。修正后 host_ffi_test 连续 20 次 20/20 通过(修正前 20 次全红,验证了断言的边界写法)。
2026-11-08 23:39:18 +08:00
PMon Dev 24746795bf ci(a11y): 管理台 axe 42 格扫描入库并接入护栏(RUN_AXE 可选)
把交付目录的管理台扫描迁入源仓库 tests/admin-axe-audit.mjs(自包含:Node 内置 http 静态服务 + 浏览器自动探测 + CDP 注入 axe-core),矩阵与口径零放宽(3 视口 × 2 皮肤 × 6 页 + E-Ink 6 格 = 42 格,禁用规则 0 条,incomplete 不记 PASS)。run-guards.sh 增第 6 套件,默认不跑以保住 13s 快跑路径,RUN_AXE=1 才执行,未设时汇总表显式标 SKIPPED (RUN_AXE not set) 并打印原因与启用命令。workflow 支持 workflow_dispatch 输入与仓库变量 IW_RUN_AXE 两种启用方式。结果 JSON 与截图只写 /tmp,不入库。本地实跑:默认 PASS 5 / SKIPPED 1 / 13s / exit 0;RUN_AXE=1 后 PASS 6 / 0 / 60s / exit 0(管理台 42/42 格 violations=0、incomplete=12 均为 mobile-375 几何遮挡)。
2026-11-08 23:39:18 +08:00
PMon Dev 38c3d8ffea feat(ui): Flutter 整屏页面级深色化(ThemeExtension<PmonPalette>)
theme.dart 新增 PmonPalette(24 语义色 + copyWith/lerp + light/dark/eink 三实例)与 PmonTextStyles 集中出口;statusStyle / PmonCard 改为接收色板(缺省回退 light 保住旧调用);三主题均挂 extensions。11 个消费文件改从 PmonPalette.of(context) 取色,不再直接引用静态 PmonColors/PmonText;含 const 去化以支持运行时色板(不改颜色/尺寸/布局)。E-Ink 挂 PmonPalette.eink(PmonEink 六色,R=G=B 无彩色、圆角 0)。新增 dark_ui_test.dart(13 项)并登记 test.sh。深色 tertiary 保持 #8A93A3;浅色 18 原色与 Core 45 项未动。验证:Dart 全量 +640、run-guards 5/5、dart analyze 9 issues 0 error。
2026-11-08 22:27:09 +08:00
PMon Dev 3716747f01 fix(a11y): 管理台登录遮罩补 dialog 语义,region 违规 42 格归零
6 个 admin 静态页的 #auth-overlay 补 role="dialog" aria-modal="true" aria-labelledby="auth-title",遮罩内 h1 补 id="auth-title"。依据 axe-core region 规则 regionMatcher 接受 [role=dialog];管理台 42 格 axe 复扫 violations 42→0(126 节点→0),PASS 格 0→42。未改 admin.css / admin.js / tokens.css。
2026-11-08 22:27:09 +08:00
PMon Dev 0d332f6e9a feat(ui): Flutter 正式深色皮肤(ThemeData 层)与显式偏好装配
- theme.dart 新增 PmonDarkColors:转录 CSS dark 块 30 键到 26 个常量
  (含两处反相 text-on-accent #0B0E14 / snackbar-bg #F2F4F9),
  28 个 Web 专属键登记 kDarkExemptTokens 豁免;几何/排印/动效/端侧派生/
  E-Ink 六色不进 Dart,沿用 PmonSpace/PmonRadius/PmonText,不新增刻度
- buildPmonTheme() 重构为 _assemble(palette, brightness),
  buildPmonDarkTheme() 复用同一控件装配,只在 theme.dart 内 new ThemeData;
  浅色分支 fromSeed 逐字段未变,深色分支 copyWith 使 colorScheme.primary
  严格等于 #6E9CFF
- main.dart 装 darkTheme + themeMode(顶层 ValueNotifier,默认 light,
  不用 ThemeMode.system),采集页加最小切换入口
- AppConfig.themeMode 持久化(零依赖 JSON,值域 light|dark,默认不预写键)
- 护栏:theme_guard C-15 改为 C-15'(允许深色但要求 58 键同名、
  不污染 E-Ink、不新增刻度、浅色 18 原色未动);token_parity 新增深色
  parity 组(覆盖 58 / 转录逐项相等 / 反向缺项 / 豁免缺席);
  新增 client/test/dark_theme_test.dart 并登记进 scripts/test.sh

关键约束:深色 tertiary 保持 #8A93A3,显式不同步浅色点修值 #646D7D;
浅色 18 品牌原色与 Core 45 项一个不动;E-Ink 仍纯黑白 / 圆角 0 / 无动画,
看板以 Theme(data: einkTheme()) 局部覆盖,与 themeMode 解耦。

验证:Dart 全量 +627 All tests passed(基线 610,新增 17)、
定向 35/35、run-guards.sh 5 套件 PASS 5 / FAIL 0、dart analyze 9 issues
(2 warning + 7 info,0 error,与基线一致)。

已知边界:本轮仅 ThemeData 层深色皮肤。页面自绘卡片/文字仍直接引用静态
PmonColors.* / PmonText.* / PmonCard.*(11 / 9 / 4 个文件),不走 ThemeData,
深色下整屏仍呈浅色;整屏页面级深色化需改为 Theme.of(context) 查询,
属独立后续阶段。Web localStorage 与 Flutter AppConfig 为两端独立持久化。
2026-11-08 21:16:30 +08:00
PMon Dev 61f044a752 merge(main): 解 2026-09 历史双写冲突,8 文件统一保留 feature/eink 实现
# Conflicts:
#	client/lib/host/lan_service.dart
#	client/lib/host/link.dart
#	client/lib/host/viewer.dart
#	client/lib/main.dart
#	client/lib/ui/transfer_card.dart
#	client/lib/ui/viewer_page.dart
#	client/test/lan_service_test.dart
#	client/test/transfer_card_test.dart
2026-11-08 20:52:12 +08:00
mtfyun 7f8e02d4ad Merge pull request 'V2/init' (#1) from v2/init into main
Reviewed-on: http://123.206.124.151:3000/mtfyun/inkwatch/pulls/1
2026-11-08 20:28:44 +08:00
PMon Dev 5ba022f598 ci(ui): 设计系统双向护栏接入 Gitea Actions
- 新增 .gitea/workflows/design-system-guard.yml:push / pull_request 触发,
  checkout + setup-node(22) + flutter-action(stable),执行 run-guards.sh;
  经 FLUTTER_BIN 把 flutter 绝对路径传给 client/scripts/test.sh
- 新增 tests/run-guards.sh 统一入口:web-token-guard → phase3b-static-guard
  → dockerfile-coverage → schema-consistency → Dart 全量,任一失败即整体失败
- 新增 tests/web-token-guard.js:源仓库内零外部依赖守卫,只读检查
  assets/tokens.css 的 Core 45 项、深色 58 键同名覆写与层叠顺序、
  color-scheme: light / 无 prefers-color-scheme、admin.css 零硬编码色值与
  4 档圆角、admin.js 主题控制器单一出口
- 新增 tests/README.md:用法、环境变量、前提与降级语义

本地实跑:5 套件 PASS 5 / FAIL 0 / SKIPPED 0,13s,整体退出码 0
  (web-token 21/0、static-guard 87/0、dockerfile 53/0、schema 116/0、Dart +610)
降级实跑:SKIP_FLUTTER_TESTS=1 → PASS 4 / FAIL 0 / SKIPPED 1,
  汇总表显式标注「SKIPPED (explicit)」,不静默放行

前提:workflow 仅在 Gitea 实例启用 Actions 时运行;
本地无需 Actions 即可用 bash tests/run-guards.sh 完整复现。
本 commit 只新增 CI 文件,未改任何产品代码。
2026-11-08 20:21:42 +08:00
jkooe 7c9c2f0605 refactor(rename): pmon → inkwatch 数据层迁移(阶段 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` 未编译)。
2026-11-08 20:12:25 +08:00
jkooe 5f419bcb91 refactor(rename): pmon → inkwatch 全量改名(阶段 A:代码层,不碰数据层标识)
## 范围与边界

**本次只改代码层**:客户端包名、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 后一并回归。
2026-11-08 20:07:30 +08:00
jkooe cc0aaffffd fix(pairing): 配对码改由设备本地生成、随连接上报 —— 零鉴权取码端点下线为 410(M4″)
## 修的是什么

`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。
2026-11-08 19:57:57 +08:00
PMon Dev e7481a43d9 feat(ui): 墨观 InkWatch 大前端设计系统落地(跨端令牌 + 深色皮肤 + 管理台/Flutter 同步)
将单端 Flutter 浅色令牌升级为覆盖 Web 桌面 / 移动 PWA / macOS 桌面 /
数据大屏的统一 --iw-* 令牌层,并落地到管理台与 Flutter 客户端。

新增
- server/business/admin/assets/tokens.css:Core 45 项 + 四端派生 +
  深色 58 个同名键覆写 + E-Ink 独立分支;color-scheme: light 锁定,
  深色由 html[data-iw-theme] 显式控制,不跟随系统
- 管理台 6 页引入 tokens.css 与首屏防闪脚本(7 处逐字一致)
- admin.js 主题控制器(inkwatch.theme / storage 跨标签 / E-Ink 互斥)
  与两处 ThemeToggle
- client/test/token_parity_test.dart:CSS :root 与 Dart theme.dart
  双向护栏,webOnly 白名单,深色键不进 parity
- tests/phase3b-static-guard.js:静态护栏(零硬编码色值 / 键名 / 层叠)

收敛
- admin.css 删除旧 :root 18 变量与全部硬编码色值,全量改为 var(--iw-*);
  10px/14px 圆角收敛到 8/16,删除 filter: brightness,
  Toast 文字改用 var(--iw-surface) 以适配深色反相
- Flutter:PmonColors 补 6 个 *-text,statusStyle() 扩为 4 字段,
  PmonText.micro,刻度收敛(6/10/10.5/11.5px 收敛到 8/11px)
- 浅色辅助文字色 #8A93A3 点修为 #646D7D(WCAG AA:白底 5.22:1 /
  page 4.87:1 / muted 4.74:1);--iw-chart-axis 同步副本。
  这是 Core 45 项中唯一被点修的取值,18 个品牌原色一个不动;
  深色 58 键与 E-Ink 分支显式不同步。依据 08 裁决(皇上批准路径 1)

验证
- Dart 610/610、定向护栏 18/18、静态护栏 87/0、Docker 53/0、Schema 116/0
- 管理台 6 页 x 浅/深 目视 217/0,12 张截图
- 设计系统侧:axe 42/42 格 violations=0、真 1920 视口 A-01~A-20 20/20、
  verify 15/15、tsc 0 errors、file:// 直开 exit 0
- 两份 tokens.css sha256 一致,git diff --check 通过

本轮 Flutter 不引入深色 ThemeData;E-Ink 保持纯黑白 / 圆角 0 / 无动画。
2026-11-08 19:47:55 +08:00
jkooe f4e24c2c95 feat(events): 最小埋点 5 事件的闭环 —— 白名单、有界、写失败不阻断(M5)
## 三处前置此前**全部不存在**(我实测,不是文档说)

```
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 核心未构建,本机环境问题,与本次改动无关)。
2026-11-08 19:46:29 +08:00
jkooe b3c16fb1eb feat(plan,history): 档位展示名与升档流程、历史窗口按档位截断、保留期换配置源(M2/M3/M7)
## 三件事

**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。
2026-11-08 19:31:20 +08:00
jkooe 0bc21f29af fix(redis): 多参数 del 在 node-redis v5+ 只删第一个键 —— 配对码反向索引本该失效时留着
## 这是一处既有代码的真 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。
2026-11-08 19:00:45 +08:00
jkooe 29672c98b6 feat(claim): 认领要有「在场证明」—— 码随 hello 上报 + claim 校验 + 双桶限流 + 审计条件化(M4′)
## 修的是什么

改之前 `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(连跑两次一致)。
2026-11-08 19:00:37 +08:00
jkooe 3cc67d3725 fix(test): 符号护栏改三态词法扫描 —— 补上 4 个哑缺口
原判据是「按行数引号奇偶、只扫字面量内」,实测 4 种写法会**静默漏检**
(不是误报,是漏报 —— 不报错、不红、不留痕,只会得到一条绿):
· ''' 跨行块里单独占一行的符号(逐行扫描看不见,而长文案必然跨行)
· Text("…'⚠'…") 双引号外套单引号
· Text('…"⚠"…') 单引号外套双引号
· Text('${x ? '⚠' : ''}') 插值内再嵌字符串
根因:引号奇偶表达不了「两种引号类型 + 跨行状态」。一个奇偶位装不下两种状态。

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

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

同时补:
· 星光平面 emoji 按代理对解码判定(原表只有 BMP,等于只防用过的那几个)
· 「对真文件也响」的自检:扫描本文件必须命中 —— 防止状态机卡死后对一切沉默,
  否则 lib/ 的零命中是假的
· 块注释内外分别断言的用例
2026-11-08 18:46:18 +08:00
jkooe bd39799ab0 fix(client): 收下失败改 error_outline(图标层与颜色层对齐)+ 补符号护栏的行尾注释控制
图标与颜色是两层独立信号:_result 失败分支颜色是 danger(红),
图标却用属琥珀层的 warning_amber —— 两层互相取消,且与既有先例不一致
(pairing_card.dart:377 / viewer_page.dart:2013 都是 error_outline + danger)。
warning_amber 留给 suspect 层(走中继、会消耗流量之类)。

symbol_guard 补两条负控制,把为什么不剥注释钉成可执行断言:
· 文档注释里的 ✅❌★ 表格不误报(不区分注释的实现会在这种行上红一大片)
· 行尾注释不误报(剥 // 到行尾会把 'http://x' 后面的真违规一起吃掉)
2026-11-08 18:36:03 +08:00
jkooe bc6e5f405f test(client): 状态符号护栏按码位判定(含孤立 VS16)
⚠ 在本仓库有两种码位形态:U+26A0+U+FE0F(emoji)与 U+26A0 单独(文字)。
按肉眼删字形会把不可见的 U+FE0F 留在字符串里 —— 源码看不出异常、界面是豆腐块,
而按 emoji 区间(U+2600-27FF)匹配的护栏看不见 U+FE0F,属静默回归。

本护栏同时量两把尺子:基字符与变体选择符;判据只认字符串字面量内的码位
(注释里的 ⚠️ 合法,本仓库有 60+ 处这种提醒,不设豁免清单)。
正控制专测「只删了字形、VS16 留在原地」这一种;负控制测注释与 URL 里的 // 不误判。
2026-11-08 18:31:23 +08:00
jkooe 184f566fe3 fix(client): 状态符号改 Material Icons + 配额卡不再泄漏档位机器键(M6)
一、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 不误报)——
只验证"没抓到"不验证"抓得到",等于恒绿断言。
2026-11-08 18:25:11 +08:00
jkooe 6c402702df feat(permissions): 档位字段接线台账 —— 防「装饰位」的第二份真相
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 已裁定表白名单会通胀,定义册不会
2026-11-08 17:56:10 +08:00
jkooe 0f57186b46 merge: 收编 main(-s ours)—— eink 为唯一主线
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。
2026-11-08 17:29:52 +08:00
PMon Dev 4cca14d1b9 feat(mail): 零依赖 SMTP 传输层,QQ / 163 可直接用
上一轮把 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 个套件全绿
2026-10-30 16:25:33 +08:00
PMon Dev f03bf7e19a fix(setup): 把实例配置真正接上线,并补上「最后管理员」漏掉的两个入口
上一轮交付了实例配置表、向导与管理端,但有五个字段只落库没人读 ——
界面上能改、改完不报错、实际什么都不发生。本轮把它们全部接上,
并修掉一处更危险的同源缺陷。

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

「不许取消最后一个可用管理员」只守了降权。封禁与自助注销都没守:
- 封禁唯一管理员后,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 个套件全绿
2026-10-30 15:31:53 +08:00
PMon Dev a825fc366e feat(setup): 实例初始化配置与 Web 安装向导
新装一台实例"归谁管"以前只能靠 .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 增「首次部署:先过一遍初始化向导」。
2026-10-30 14:58:00 +08:00
PMon Dev 19f8ee181a test: 配额默认开启后三套测试的车没上(红的是前提,不是功能)
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 失败;隐私扫描通过。
2026-10-30 13:42:46 +08:00
PMon Dev 269a1d32b1 fix(admin): 在线数改 SCAN 计数、审计明细列不再被卡片硬裁
三处都是实测截图发现的,不是"看着可能有问题":

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 顶开了的注释错位。
2026-10-30 13:29:13 +08:00
PMon Dev 1a10e4a04a feat(管理端): 从零补一条运营者通路(/admin/ + /api/admin/*)
此前**没有任何运营端**: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
2026-10-30 13:23:10 +08:00
PMon Dev c92d870c8e feat(用户机制): 角色/邀请/自救/配额全部落地(13 项缺口关闭)
此前账号体系只到"你是谁",这一轮补"你能做什么"与"账号出事怎么办":
忘了口令等于报废、授权只能发配对码(等于公开招租)、配额只报数不拦。

服务端
- 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,标签长期漂移)
2026-10-30 12:52:51 +08:00
PMon Dev 930c35957b feat(installer): Windows 上「安装成功但不会立刻启动」必须说出来(平台实话)
## 为什么必须补这句(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,无丢失;此处留痕避免日后考古困惑。
2026-10-28 00:17:50 +08:00
PMon Dev c806c26537 fix(测试): run-all 不再把「环境缺 cargo」伪装成「Rust 单测失败」
## 现象(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()`。
2026-10-28 00:12:52 +08:00
PMon Dev 26b939c239 test(常驻采集): 探针测试里的家目录改用占位名,别把合成路径写成隐私命中
`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-28 00:06:58 +08:00
PMon Dev 4e3b4739d4 fix(常驻采集): 停本界面采集改为「只认在跑的正面证据」,避免 Windows 上双双停摆
## 改判:`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`(把"缺字段"当没证据)→ 「拿到在跑证据 → 停采集」报红
2026-10-27 23:54:46 +08:00
PMon Dev 3af5c592ff fix(常驻采集): 提示文案改指 build_cli.sh —— 原来的命令在 Dart 3.13 上必然失败
## 问题:我们把用户送到了一条死路

`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` 同样要求),
只是叫法过时。属文档口径整理,不在本笔。
2026-10-27 23:52:56 +08:00
PMon Dev d0d2d717a4 docs(installer): 把 log.path 未收敛的 TODO 补成可执行的正解形态
`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。
2026-10-27 23:51:32 +08:00
PMon Dev 5a909f14fd fix(构建): dart compile exe 已被 Dart 3.13 打断,构建方式收敛到 scripts/build_cli.sh 一处
## 根因(不是我们的代码,是 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"的说法是**能力描述**(仍然成立),
未改。
2026-10-27 23:50:15 +08:00
PMon Dev e6d1438864 test(常驻采集): 把"没快照就放行"这条例外钉在日志上,别让它只剩一句注释
## 为什么单为一条日志加测试

`_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` → ⑥ 的源码守卫报红,恢复即绿
2026-10-27 23:45:38 +08:00
PMon Dev 3b5007c1f6 fix(常驻采集): macOS 沙箱下锁对 agent 无效,补一道进程探测,堵住必然双写
## 为什么必须补(不是防御性代码)

`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 一次都不执行」报红
2026-10-27 23:43:56 +08:00
PMon Dev b11def8167 fix(agent): 退出码显式落到进程边界,并补「真实子进程退出码」e2e
## 依据:本 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**
2026-10-27 23:43:06 +08:00
PMon Dev 9db09bb923 fix(常驻采集): 停了本界面采集就必须说出口,别让用户在漏采时看到一张平静的卡
## 这个洞怎么来的

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 实例"。
跨形态靠进程探测、同形态靠锁,各管一段。
2026-10-27 23:40:26 +08:00
PMon Dev d7147e67a7 fix(常驻采集): 探针不可用只报「不知道」,并把采集端实际读到的配置摆出来
## 一、探针不可用 ≠ 没在跑(把上一轮糊回去的洞补上)

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)` 短路 → 「与界面输入不一致」报红,恢复即绿
2026-10-27 23:37:21 +08:00
PMon Dev 24e79b06a6 fix(installer): 进程探针区分「查不了」与「没在跑」,并给 e2e 补 status --json 的路径断言
## 本笔包含什么
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 不再被算进来**,路径作用域生效)
2026-10-27 23:33:52 +08:00
PMon Dev cb82678cdd refactor(常驻采集): 自启 label 收口到唯一真相源(GUI 与安装器问同一个自启项)
`'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
2026-10-27 23:31:57 +08:00
PMon Dev 2574011e5a fix(常驻采集): 过期 CLI 的输出不得冒充状态,改为校验形状并回落下一候选
端到端验收抓到的真缺陷(不是理论问题,本机可复现)。

## 血案形态
开发机上 `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!**
2026-10-27 23:25:43 +08:00
PMon Dev d983cc5d7c fix(路径): 安装/数据路径合并到唯一真相源,并补 installer 干净目录 e2e(P0-3 / E10a)
## 缺陷(合并前四处口径互相矛盾)
- `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` 各有一份(不是路径,未动)。
2026-10-27 23:25:41 +08:00
PMon Dev 166963f994 test(常驻采集): 把「CLI 输出 ↔ GUI 解析器」两端的契约钉在一起
`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`)`。
2026-10-27 23:23:06 +08:00
PMon Dev edaf52b06d feat(常驻采集): 采集页加「常驻采集」卡,把桌面端关窗即停变成可操作入口(P0-5 GUI 侧)
对应架构评审 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` 紧随其提交后单独落一笔。
2026-10-27 23:21:03 +08:00
PMon Dev 1711425b08 feat(能力收口): 中继上限点击前明示 + P2P 下线(隐藏档位,不删代码)
对应架构评审 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:本机中文路径会让它崩)
2026-10-27 23:15:17 +08:00