Files
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

1023 lines
64 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 墨观 客户端(设备端)
macOS + Android 双端,Flutter 单代码库,**一个应用两个角色**(方案 3.1):
采集端(上报本机)与查看端(订阅查看已授权的其他设备)。
**采集/传输/保活/重连策略全部在 Rust 核心**(`../rust-core`),
本工程只做宿主该做的事:socket I/O、平台差异落地、界面。
## 工程结构
```
client/
├── bin/
│ ├── inkwatch_agent.dart # 无界面常驻采集进程(方案 8.3「独立进程」)
│ ├── inkwatch_viewer.dart # ★ 无界面查看端 CLI(真实链路验证 + 可脚本化)
│ └── inkwatch_install.dart # 安装器:注册/卸载/自检/重启自启(非沙箱 CLI)
├── lib/
│ ├── host/ # 宿主层 —— 纯 Dart,不依赖 Flutter
│ │ ├── core_lib.dart # 动态库定位与加载
│ │ ├── ffi_bindings.dart # 38 个 C ABI 符号绑定
│ │ ├── inkwatch_core.dart # 句柄封装(采集器/传输/守护)
│ │ ├── app_config.dart # 配置持久化(零依赖,写容器内 JSON)
│ │ ├── install_paths.dart # 安装路径与 `~` 展开(含一个真实坑的回归注释)
│ │ ├── timing.dart # 时序常量(断点阈值/窗口长度,宿主与界面共用)
│ │ ├── metric_display.dart # 指标显示目录(标签/单位/分组/是否可画,纯 Dart)
│ │ ├── metric_format.dart # ★ 值→文本的**唯一出口**(单位换算只许写这里)
│ │ ├── scaled_unit.dart # 量级自适应(MB→GB→TB,滞回 + 有效数字分档)
│ │ ├── adaptive_unit.dart # 速率自适应(EMA 平滑 + 三区段迟滞,Mbps↔kb/s)
│ │ ├── history_api.dart # 历史回溯 REST(档位白名单 1h/24h/7d/30d)
│ │ ├── pairing_api.dart # 配对/授权 REST(采集端与查看端共用)
│ │ ├── account_api.dart # 账号 REST(注册/登录/我的设备/认领)
│ │ ├── viewer.dart # 查看端宿主层:订阅/快照/断点/能力集/配对 ★核心
│ │ └── link.dart # socket I/O + 驱动 Rust 状态机 ★核心
│ ├── ui/
│ │ ├── metric_catalog.dart # 显示目录的图标映射(唯一 Flutter 强耦合部分)
│ │ ├── viewer_page.dart # 查看端界面(设备切换器/仪表盘/配对/空状态)
│ │ └── viewer_chart.dart # 折线图(断点处断开,不插值)
│ └── main.dart # GUI:双角色外壳(采集 / 查看),查看页懒创建
├── macos/Runner/*.entitlements # ★ 必须含 network.client,见下文
├── scripts/bundle_rust_core.sh # 把核心库注入 .app 包
└── test/
├── host_ffi_test.dart # 28 项 FFI 冒烟(真实加载编译产物)
├── viewer_test.dart # 48 项查看端**逻辑**(离线,注入假 socket/策略/REST)
├── viewer_page_test.dart # 17 项查看端**界面渲染**(真实构建 widget 树)
├── viewer_fakes.dart # 两套查看端测试共用的假件
├── pairing_card_test.dart # 6 项「谁能看我」卡(发码/失败不静默/清单/解除)
├── account_test.dart # 21 项账号(REST 客户端 + 账号卡界面)
└── install_paths_test.dart # 9 项路径展开回归(锁 `~` 展开与 join 陷阱)
```
**为什么宿主层是纯 Dart**:方案 8.3 要求采集核心作为**独立进程**运行、由守护看护。
若宿主层 import Flutter,就只能以 GUI 进程存在(无法 `dart compile exe` 出常驻小进程,
也无法在无图形会话的机器上跑)。故只依赖 `dart:*` 与 `dart:async`,状态以 Stream 外发。
## 环境要求
| 组件 | 版本 | 本机安装位置 |
|---|---|---|
| Flutter SDK | 3.47.4(**arm64 版**,非 x64) | `~/development/flutter` |
| Dart | 3.13.3(随 Flutter) | 同上 |
| JDK | 21 | `/opt/homebrew/opt/openjdk@21` |
| Android SDK | platform-tools 37 / android-36 / build-tools 36 / emulator / NDK 30 | `~/Library/Android/sdk` |
| Android Studio | 2026.1(Quail 2 Patch 1) | `/Applications/Android Studio.app` |
| Rust 核心 | `cargo build --release` 产物 | `../rust-core/target/release/` |
> ⚠️ **务必用 arm64 版 Flutter**。官方发布清单里 macOS 有 x64 与 arm64 两个包,
> 文件名只差一个 `_arm64`;装了 x64 版会出现"dart 是 x86_64、dylib 是 arm64"的
> 架构错配(`flutter test` 报 `incompatible architecture`)。arm64 包名为
> `flutter_macos_arm64_<版本>-stable.zip`。
## 常用命令
```bash
# 拉依赖(国内必须走镜像,已在 ~/.zshrc 导出)
flutter pub get
# 静态分析(注意:flutter analyze 走 LSP,在工作区路径含中文时会崩;
# 本项目用 dart analyze,不走 LSP)
dart analyze
# 全部客户端测试(117 项)—— 必须走这个入口,它负责清代理变量
bash scripts/test.sh
# FFI 冒烟测试(真实加载 libinkwatch_core,共 28 项)
flutter test
# 构建 macOS 客户端 + 注入核心库
# 注意:bundle 脚本会自动**重签**(且改完必须重签 —— 只 cp 不签会让 app 起不来,
# 现象是 exit 133 + 零日志,见下文「macOS 启动与取证三条」)
flutter build macos --release
bash scripts/bundle_rust_core.sh
# 跑无界面采集端(联调)
dart run bin/inkwatch_agent.dart --server http://127.0.0.1:3000 --device-id my-mac
dart run bin/inkwatch_agent.dart --duration 15 # 跑 15 秒后退出
dart run bin/inkwatch_agent.dart --print-plan # 只打印自启注册计划
# 编译成独立可执行文件(供 LaunchAgent / systemd 调用)
# ⚠️ 构建命令只有一处:scripts/build_cli.sh(Dart 3.13 起 `dart compile exe`
# 遇到带 build hooks 的包会直接失败,见脚本文件头)
bash scripts/build_cli.sh
```
### ⚠️ 联调必须清代理变量
本机 shell 注入了 `HTTP_PROXY` 等代理变量,Dart 的 WebSocket 客户端会把
**localhost 也走代理**,导致 socket.io 握手失败
(现象:`Invalid WebSocket upgrade request`;`flutter test` 也会报同样的错)。
运行客户端或测试前请清掉:
```bash
env -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY -u http_proxy -u https_proxy -u all_proxy \
NO_PROXY=localhost,127.0.0.1,::1 no_proxy=localhost,127.0.0.1,::1 \
dart run bin/inkwatch_agent.dart --duration 15
```
## 查看端(订阅别人的设备)
### 双角色 = 两条连接(不是设计洁癖,是网关的硬约束)
网关的 `session.role` 是**单值**:`onHello` 置 `agent`、`onSubscribe` 置 `viewer`,
而断开清理按 role 分支(`role === 'agent'` 才清在线态,`role === 'viewer'` 才清订阅)。
若在采集连接上再发 `viewer:subscribe`,role 被改写成 viewer →
**断开时在线态永远清不掉**,那台设备会永久显示"在线"(幽灵设备)。
所以查看端**独占一条 socket**(`ViewerSession` 自己建),采集端行为完全不受影响,
两个角色可以各自开关。切到「查看」页签也不会中断采集 —— 为此 `main.dart` 用
`IndexedStack` 保活采集页状态,并让查看页**懒创建**(不进该页签就不建连接)。
### 查看端刻意不发 `agent:heartbeat`
心跳会经网关 `onHeartbeat → touchPresence(deviceId)` 把本机标成"在线设备"。
但用户可能只是想看别人的设备、本机根本没在采集 —— 那会凭空多出一台
状态灯绿、却没有任何指标的幽灵设备。查看端连接的保活由 engine.io 自身的
ping/pong 承担。`tests/viewer-e2e-test.js` 用 `presence:viewer-cli` 是否存在钉住了这条决策。
### 协议没有 unsubscribe,所以切设备要重连
表 5.2 的上行事件只有 `viewer:subscribe`。网关按 `session.subscribed` **累积**订阅、
只在断开时清理;`subs:{device}` 集合也只有在断开时才移除本账号。若切设备时只再发一次
subscribe,旧设备的帧会一直推过来(移动端流量翻倍),`subs` 集合也会持续膨胀。
故切换设备时重建一次连接(代价一次 RTT)。
### 断点判定与"不插值"
帧序列里每帧带 `gapBefore`:与前一帧的**服务器时间戳**之差超过 2.5 秒即为断点
(阈值取 2.5 倍采样间隔:正常抖动实测可达 1.3–1.8s,卡到 2 倍会把抖动误画成断点)。
折线图据此把序列切成多段、各自成线,段间天然断开,并在断点处画竖虚线。
时间戳**回退**(换会话/换设备)同样算断点 —— 绝不把两个会话连成一条线。
### 能力集驱动隐藏
优先用服务端随快照下发的 `capabilities`;缺失时从帧反推(某键在所有帧里恒为 null)。
两条路径在界面上**措辞不同**("依据:服务端下发的能力集" vs "依据:帧中恒为 null,此处为推定")
—— 推定路径区分不了"平台永久不支持"与"本次没取到",不标注就等于编造结论。
### 指标显示目录的单一真相源
标签/单位/分组只在 `lib/host/metric_display.dart` 定义一次,采集页与查看页共用;
图标映射在 `lib/ui/metric_catalog.dart`(IconData 属 Material,不能进宿主层)。
三者(目录 / 图标 / JS 权威清单)的一致性由
`../tests/schema-consistency-test.js` 第 11 段**跨语言**强制校验:
键集合双向相等、单位逐项相等、图标无缺无多、两页不得再出现写死的标签字面量。
### ★ 值 → 文本:唯一出口 `lib/host/metric_format.dart`
**真机事故**:采集页显示 `磁盘可用 239 GB`,同一条帧在查看页显示 `84710 MB`。
根因不是漏写换算,而是"值→文本"被写了**三份、各缺一块**:
| 位置 | 速率 | 量级 | 时长 |
|---|---|---|---|
| 采集页 `main.dart` | ✅ | ✅ | ✅ |
| 查看页指标卡片 | ✅ | ❌ | ❌ |
| 查看页坐标轴 | ❌ | ✅ | ✅ |
三份各配自己的测试 → 三份测试全绿而两个页面读数不同(本仓库第 6 次"同一份清单写两处")。
现在只有 `MetricFormatter` 一处实现,三个调用点全部转调它:
| 方法 | 用于 | 特性 |
|---|---|---|
| `update()` / `updateAll()` | 每来一帧调一次 | **必须在流回调里**,进 build 会把滞回/EMA 推快 |
| `format(key, spec, raw)` | 指标卡片 | 带滞回状态(单位稳定,不逐帧翻动) |
| `formatStable(key, spec, v, axisRef:)` | 坐标轴 / tooltip | 无状态;`axisRef` 让**整根轴统一单位** |
| `reset()` | 停止采集 / 换设备 | 档位回到基准 |
两条容易搞错的语义:
- **卡片读平滑值,轴与 tooltip 读真实值**。速率类为抗毛刺做了 EMA,但拿平滑值渲染
"某一时刻的采样"就是编造数据。两条路径共用同一套选档阈值,故单位不会互相矛盾。
- **轴内单位必须统一**:`formatStable` 不传 `axisRef` 时,轴上会同时出现 `0 MB` 与
`3.11 TB`(差 10⁶,看着像两个量)。
**护栏**:`test/metric_format_test.dart` 第五段剥掉注释后扫描 `main.dart` / `viewer_page.dart`,
出现 `ScaledUnit(` / `AdaptiveRateUnit(` / `scaleBase` / `adaptiveRate` / `formatDuration(`
即判红。(剥注释是刻意的:本项目要求把事故写进注释,护栏把注释也算进来就等于逼人删解释。)
### ★ 默认折线指标的唯一解析:`ViewerPage._chartKey`
**真机事故**:切「1 小时」档,标题写 `CPU 使用率 · 1 小时`,界面却弹
「指标 `uptime_s` 没有独立时序列」。根因同上 —— 默认指标被解析两次:图表用
`_orderKeys(...).first`(显示目录序 → `cpu_usage`),历史查询用 `_st.availableKeys.first`
(能力集原始序 → `uptime_s`),而且默认落到了 `chartable: false` 的指标上。
现在 `_chartKey` 是图表 / 历史查询 / 卡片高亮**共用的唯一解析**,并跳过不可画的指标
(`cpu_cores` 是数组、`uptime_s` 是单调量,且 `device_metrics` 里没有这两列)。
### 单位与频率的可调项
- **单位自适应**(`scaled_unit.dart`):1000 进制(全项目十进制,别改 1024),
升档 ≥1000、降档 <0.9 的**滞回**防边界抖动;有效数字 <10 两位 / <100 一位 / 否则整数;
零单独处理(`0 TB` 而非 `0.00 TB`)。时长另走人话化(`225024 s` → `2 天 14 小时`)。
- **采集频率**:采集页 0.5/1/2/5 秒可选,**人工优先** —— `samplingIntervalManual` 标记后,
服务端下发的 `interval_ms` 不再覆盖用户设定(否则重连就把设定打回默认)。
### ★ 配置变更通知:共写一份 config.json 的两个页面怎么互相知道
采集页与查看页共写同一个 `config.json`("只写自己负责的字段"那条纪律)。
但**还有第二个问题必须回答:「别人改了,我怎么知道?」**
主壳是 `IndexedStack`(查看页只创建一次、长期存活),`ViewerPage._boot()` 在 `initState`
里读一次盘就再也不读 —— 根本没有"重新进入"这个时机。
**真机事故(2026-09-15)**:装上新版(旧配置仍在)→ 应用启动时查看页拿到**旧局域网地址 +
未登录 + 旧匿名账号** → 之后在采集页把地址改成公网并登录认领 → 回查看页**纹丝不动**。
用户看到的现象是「**采集端能连腾讯云服务器,查看端却一直 timeout**」,会去怀疑服务端。
修法是给"配置变了"一个唯一出口:
| 部件 | 作用 |
|---|---|
| `ConfigStore.updates`(`save()` 成功后广播新配置,`revision` 自增) | 变更的**唯一出口**(纯 Dart broadcast stream,不引 Flutter) |
| `sessionAffectingChanges(a, b)`(纯函数) | 判定**要不要重建会话**:只认 `serverUrl` / `businessUrl` / `accountId` / `accountToken` / `deviceId` |
| `ViewerPage` 订阅广播 | 会话相关字段变了 → 清掉旧设备/旧账号显示状态 → 停旧会话 → 按新配置重建 |
| `main.dart`(采集页)订阅广播 | 只同步**账号显示**;**刻意不动地址输入框**(那是正在跑的链路,换地址该由用户显式点开始/停止) |
⚠️ 判据写宽了会形成**重连风暴**(采样间隔、`lastViewedDeviceId` 等本页自己写的字段一落盘
就重连,链路反复闪烁);写窄了就漏掉真变更(就是上面那起事故)。
故 `sessionAffectingChanges` 是**唯一**判定处,`test/config_change_test.dart` 两个方向都钉住
(含"采样间隔变化**不**重连"这类反向断言)。
真机实测(换地址 → 无需重启应用,双向都跟随):
```
[viewer] 配置已变更(服务器/账号)→ 重建会话
[viewer] 与网关断开:io client disconnect
[viewer] 启动查看会话:网关 http://203.0.113.10/ · 业务 http://203.0.113.10 · 未登录(兼容模式)
[viewer] 已连接网关
```
### 本地缓存("启动即见")
`$数据目录/viewer_cache.json` 存上次会话的 ≤60 帧,冷启动先渲染它、联网后被服务端快照
覆盖,期间界面标注"以上为上次会话的本地缓存"。缓存与配置同目录(复用
`ConfigStore.dir`,含 Android 的 `getFilesDir()` 注入分支)。
### 配置写入纪律(避免丢失更新)
查看页写 `account_id`、采集页写 `server_url/device_id/auto_start`,同处一个 JSON。
两个页都采用 **读回磁盘 → 只改自己负责的字段 → 保存**:
直接整份覆盖会导致"先在查看页配对成功、再回采集页点开始"把配对身份抹掉,
而现象极难复现(要按特定顺序点)。
### ★ 覆盖安装残留:配置必须过校验(`host/config_migration.dart`)
产品负责人定位到:**覆盖安装**(在原应用上更新、保留旧数据)后滑动卡顿,
**清除全部应用数据后卡顿消失**,全新机全新装也不卡。
覆盖安装保留的正是私有目录里的两个文件:`config.json` 与 `viewer_cache.json`。
而 `config.json` 是**上一个版本写下的外部输入** —— 此前加载路径对它是**无条件信任**的
(`fromJson` 只做类型转换,不做任何校验),于是差异的本质是:
| | 值从哪来 |
|---|---|
| 全新安装 / 清数据 | **代码里的安全默认值**(间隔 1000ms) |
| 覆盖安装 | **磁盘上的任意值,没有任何护栏** |
危害已实测 —— 同机、同包,只改 config 里**一个字段**,
以**服务端落库帧数**为准(不用客户端日志,`[perf]` 只在 ≥8ms 时才打,当不了计数口径):
| config 的 `sampling_interval_ms` | 实际帧率 | UI 线程被同步 FFI 采样占用 |
|---|---|---|
| 1000(默认) | 1.1 帧/秒 | 0.9% —— 不卡 |
| 50 | 21.4 帧/秒 | 17.1% —— 明显顿挫 |
| 5 | **132.6 帧/秒** | **106% —— 饱和卡死** |
根因是采样整条链路跑在 **Dart 主 isolate = UI 线程**上(平台通道 + 同步 FFI 采集,
单次 6–10ms),所以"间隔"直接等于"每秒占用 UI 线程几次 × 8ms"。
而 `sampling_interval_manual == true` 时**服务端下发也纠正不了它**("人工优先"的既定语义),
病态值会一直生效到用户手动改或清数据。
**处置:迁移,不是清除。** 清数据能"解决",代价是丢掉
`account_id` / `account_token` / `device_token` / `device_id` —— 用户要重新登录、
重新认领、历史指标断档。这些恰恰是**必须保留**的。所以:
| 规则 | 理由 |
|---|---|
| `config.json` 加 `config_version`;`load()` 是配置**唯一入口**,规范化与迁移都在这里 | 没有版本号就无法区分"字段本来没有"与"旧版本用别的语义写的" |
| 采样间隔**夹到方案规定的 0.5–5 秒**,越界回落 1000ms | 唯一能压满 UI 线程的字段,必须拦 |
| 地址只在**明显非法**(解析不出/无 host/非 http(s))时回落编译期默认值 | `io.io('')` 是真连不出去的坏值;回落是无损的 |
| `device_id` 只拦**结构上不可能**的值(含空格/中文/超长) | 它会污染服务端主键;合法值一律不动 |
| **凭据(account_id / 令牌 / 邮箱)一律不校验** | 见下 |
| 纠正结果**写回磁盘**(一次性迁移)+ 界面出一张**告知卡** | 不许静默改用户数据 |
> **凭据为什么不校验**(写这个模块时被自己误伤换来的纪律):
> 初版按"形状"校验凭据(长度下限 16),结果把一条既有测试里的 13 字符假令牌清空了。
> 顺着误伤想清楚:凭据的"坏"只会变成**一次普通的鉴权失败**(服务端会明确拒绝),
> 而**误清有效凭据的代价是让用户重新登录 + 重新认领** ——
> 那正是"清应用数据"最贵的地方,不该在规范化里重演一遍。
> 故只拦"会导致病态行为"的字段,让服务端去裁决凭据有效与否(那是它唯一的职责)。
纵深防御:夹取同时放在**真正建 Timer 的那一层**(`link.dart` 的 `_startSampler`),
并在 `setSamplingInterval` 里把**实际生效值**回填状态 ——
免得界面显示用户填的 5ms、实际跑的是 500ms(那又是"界面在说谎")。
**修复前后(端到端实测,同一台 F50、同一条注入路径)**:
| 注入 `sampling_interval_ms` | 修复前 1.0.7 | 修复后 1.0.8 |
|---|---|---|
| 5 | 132.6 帧/秒 | **1.11 帧/秒** |
应用侧留痕(`adb logcat -s flutter`):
```
[墨观] 本地配置已修正 2 处: 采样间隔 5ms 超出方案允许的 500–5000 ms,
已按 1000 ms 采集(原值会让采样占满界面线程) | 本地配置已从 v0 迁移到 v2
[墨观][boot] sampling_interval_ms=1000 manual=true
```
即:**病态残留值不再能致卡,而账号 / 令牌 / 设备标识全部原样保留** ——
不需要"清除应用数据"这个土办法。
### ★ device_id 必须与显示名解耦(`host/device_id.dart`)
device_id 曾经是 `android-<机型slug>-<ANDROID_ID后6位>`。
为修"查看端把型号编号 `MODEL-A1` 当设备名显示",我们改了**机型读取路径** ——
于是**同一台设备换了 device_id**。生产库实测(同一台手机成了两台设备):
```
android-oldmodel-x-abc123 (03:11–09:14,272 帧)
android-newmodel-y-abc123(09:14 起)
```
而 device_id 是设备 / 授权 / 指标三张表的主键,换 id 等于换一台设备:
服务端把新 id 当全新设备(无 owner、无 grants)→ **已有授权全部作废**;
旧 id 的历史指标成了另一台设备的 → 查看端曲线在这里断掉。
病根与「单一真相源」同源:**同一条平台通道(机型)同时喂了"显示名"与"主键"**。
| 用途 | 来源 | 可变性 |
|---|---|---|
| `device_id`(主键) | **仅** ANDROID_ID / 主机名 | **永不变** |
| `device_name`(显示) | 机型(`ro.product.marketname` 等) | 随时可变 |
⚠️ 已落盘的 device_id **不做迁移**:再改一次等于再断一次授权。
本改动只保证"新生成的 id 不含机型",并由 `_restore()` 的既有语义
(配置里有值就直接用)保证它一旦写下就固定。
### ★ 同一台机不得出现两条:设备身份归并(服务端)
光"不再漂移"不够 —— 已经漂移出来的记录还在。产品负责人截图里的现象是
**同一台小米占了两条**(新 id 在线、旧 id 永远离线、曲线断在切换处),
"可查看 5 台"里有一台是重复的。
判据的唯一真相源 = **`server/shared/device-identity.js`**(纯函数,有单测):
| # | 判据 | 说明 |
|---|---|---|
| ① | `hardware_id` 完全相同 | 最强,且**与 device_id 的派生规则无关**。客户端 1.0.9 起在 hello/claim 上报(Android = `ANDROID_ID` 全长,桌面端 = 主机名)。**不是凭据**:可猜、不参与鉴权 |
| ② | `device_tag`(id 的稳定后缀)相同 | 为**存量行**兜底(它们没有 hardware_id)。后缀须为 **≥6 位十六进制**才认 —— 否则 `…-2.local` 这类主机名会被误判成同一台 |
| ③ | **歧义就不动** | ②匹配到多于一个候选 → 一个都不并。宁可让用户看到两条(可人工处理),也不要合并错 |
- 归并**只在同一 owner 名下**做 ⇒ 判据失手的最坏结果只是并了自己名下另一台设备。
- `POST /api/devices/claim` 带 `hardware_id` 时**自动归并**,响应里回 `merged: [...]`;
客户端把它显示出来(**不许静默改用户的设备列表** —— 用户会发现"少了一台")。
归并是**无损**的:授权与历史都还在,所以自动做 + 如实告知,而不是弹窗询问。
- `POST /api/devices/merge` 是**人工裁决出口**(歧义情形用):要求登录 + 两台都归本人。
- 归并动作(`repo.mergeDeviceInto`,单事务):拷 grants → 改指 device_tokens
(采集端不必重新认领)→ 搬 device_metrics → 删旧行。
**不搬 audit_log**(审计记的是"当时"的 id,改了就是篡改历史)。
有 `MAX_MERGE_FRAMES` 上限,超限**拒绝并报错**,不静默截断
(截断会造成"一半历史在新、一半在旧",比不合并更难查)。
**生产上的实际效果**(产品负责人那台,2026-09-15 实测):
```
归并前:5 台 含 android-oldmodel-x-abc123(272 帧)与 android-newmodel-y-abc123(103 帧)
归并后:4 台 android-newmodel-y-abc123 = 375 帧,03:11 → 09:58 连续(断点 57 处消失)
审计:device_merged {from, to, frames: 272, grants: 0, tokens: 1, reason: "manual"}
```
### 账号与"我的设备"(方案 6.4 第一步)
| 位置 | 作用 |
|---|---|
| 采集页 / 查看页的 **账号卡** | 注册 / 登录 / 登出(`ui/account_card.dart`,两页同一份实现) |
| 查看页设备列表 | 关系徽标:**我的**(免配对)/ **他人授权**(对方可随时收回) |
| 采集页「谁能看我」卡 | 「认领到当前账号」——把本机挂到账号名下,之后自己的查看端免配对可见 |
| `host/account_api.dart` | 账号 REST(注册/登录/登出/我的设备/认领),令牌只走 Authorization 头 |
- 登录后:`GET /api/accounts/me/devices`(含**我名下**设备);
未登录:仍走旧路径(只含别人授权给我的设备),界面上明说这一点。
- **设备令牌**:认领成功后自动为这台设备签发 `pm_dev_` 令牌(`device_token` 字段),
采集端在下一次 hello 时带上 —— 服务端校验它绑定的 device_id 与声明一致,
别人拿你的 device_id 冒充上报会被直接拒绝。签发/轮换接口只对**归属者本人**开放。
- 令牌存在配置文件的 `account_token` / `device_token` 字段(应用沙箱内);**登出时先断开查看连接再清令牌**
——权限判定发生在订阅当时,不先断开的话用户以为退了、其实还在收数据。
- 两台设备/两个页签共享同一份配置:登录入口在哪一页都行,另一页在**应用回到前台**时
会重读账号字段(`_reloadAccountFromDisk`)。
### 采集端:谁能看我(方案 6.3 的另一半)
配对流程有两半,分处两个页签,别只做一半:
| 步骤 | 在哪 | 实现 |
|---|---|---|
| ① 被查看设备生成并展示配对码 | **采集页**「谁能看我」卡 | `ui/pairing_card.dart` → `PairingApi.issueCode` |
| ② 查看端输入配对码 | 查看页 配对卡 | `ui/viewer_page.dart` → `PairingApi.verifyCode` |
| ③ 建立授权关系并落库 | 服务端 | `server/business/index.js`(见顶层 README 的安全章节) |
| ④ 被查看设备随时解除 | **采集页**「谁能看我」卡 | `PairingApi.revoke`,注明"即时生效" |
界面上刻意写死的三句话(都是安全相关,不是装饰):配对码**只能用一次**、
**5 分钟有效**、解除**即时生效**。解除失败必须显示原因——静默会让用户以为已经解除。
### 查看端 CLI(联调用)
```bash
cd client
dart run bin/inkwatch_viewer.dart --account acct_demo --list # 列我被授权的设备
dart run bin/inkwatch_viewer.dart --account acct_demo --device <id> \
--pair 123456 --seconds 8 --json # 配对+订阅+观察,末尾打印 JSON
```
退出码:`0` 收到数据 / `2` 订阅成功但窗口内无数据(设备离线,非链路故障)/ `3` 配对或授权失败 / `4` 参数或环境错误。
## 视觉规范(浅色极简,2026-09-15 改版)
### 唯一真相源:`lib/ui/theme.dart`
改版前 UI 约 3500 行里散着 **70+ 处 `Color(0xFF…)`**,光灰色就有五六种近似值,
卡片内边距 9/12/14/16、说明文字 11/11.5/12 各写各的。后果不是"难看",
而是**同一类元素在不同卡片里长得不一样**,且没人能说清"标准"是什么 —— 因为从来没有标准。
这与本仓库反复踩的"同一份清单写两处"是同一个病,故按同样的手法收拾:
| 令牌 | 内容 |
|---|---|
| `InkwatchColors` | 中性四色(page/surface/line/surfaceMuted)+ 文字三级 + 主色 + **状态语义色** |
| `InkwatchSpace` / `InkwatchRadius` | 间距 4/8/12/16/20/24;圆角 8/12/16/pill(**刻度有限**,免得同一决策有三种写法) |
| `InkwatchText` | title / section / body / bodyMuted / caption / **metric(等宽数字)** |
| `InkwatchCard` | standard(白面 + 发丝线,**不用阴影**)/ notice / selected |
| `buildInkwatchTheme()` | 唯一的 `ThemeData` 装配点(含 AppBar / 输入框 / 菜单 / SnackBar / NavigationBar) |
| `statusStyle(key)` | 状态 →(文案, 色, 浅底)的唯一映射 |
**三条规矩**:
1. 颜色/间距/圆角/字号只在 `theme.dart` 定义 —— 页面里出现 `Color(0x…)` 即判红
(`test/theme_guard_test.dart` 源码结构断言,已做变异测试);
2. **语义优先于色值**:写 `InkwatchColors.online` 而不是 `Colors.green`;
3. **颜色只用于表意**:整屏以中性灰为底,彩色只出现在状态、主操作、告警上。
### 改版顺手修掉的三个真缺陷 + 两处"界面在说谎"
| # | 问题 | 修法 |
|---|---|---|
| 1 | **标题被系统状态栏压住**(深色自绘 header 没吃 SafeArea,真机上时间与"墨观 采集端"叠字) | 改用 `Scaffold.appBar`,状态栏内边距交给框架 |
| 2 | **"账号"两处显示不同值**(header pill 是匿名 `acct_xxx`,账号卡是邮箱) | header 里那个 pill 直接删掉;AppBar 只放**链路状态**(一眼有用的那件事) |
| 3 | **URL pill 排成锯齿**(四个长度不一的 pill 换行后左边界参差) | 压成一行 caption(`网关 … · 业务 … · 可查看 N 台`),诊断够用且不抢视线 |
| 4 | 绿色徽标表示"我的设备" —— 而本项目 **绿=在线**,用户会读成"这台在线" | 归属用主色(我的)/ 警示色(他人授权) |
| 5 | 查看端 AppBar 显示「链路 疑似离线」而连接其实正常(查看端只在对方上报时才有入站,半开检测被误触发) | 徽标只陈述"到网关的连接","对方多久没上报"由设备行与末帧/离线横幅负责 |
### 设备机型:`lib/platform/device_model.dart`(一处定义 + 优雅降级)
原先 `device_name` 直接用 `Platform.localHostname`:Android 返回 **`localhost`**
(查看端列表里所有安卓机同名,认不出是哪台),macOS 返回**带用户名的主机名**(长且含隐私)。
机型才是人认设备的方式,故收敛成一个采集入口:
| 平台 | 首选 | 降级 |
|---|---|---|
| Android | `Build.MODEL`(平台通道,如 `F50`) | 空 → 调用方退回主机名 |
| macOS | `system_profiler` 的 `Model Name` + `Chip` → `MacBook Pro · M3 Max` | `sysctl -n hw.model`(`Mac15,8`)→ 空 |
| 其它 | 暂不接入 —— **宁可显示主机名,也不猜** | — |
两条硬约束(都有测试):
1. **必须有超时**(`kModelProbeTimeout` = 900ms):`system_profiler` 实测 0.15–0.2s,
但它挂在**启动路径**上,被拖住就等于"点开始采集没反应";
2. **绝不在采集循环里调用**:这是启动时取一次的元数据。
取不到就退回主机名,**绝不编一个假机型** —— 与服务端"读不到报 null、不报 0"是同一条诚实边界。
### 设备机型的三级降级(安卓 `InkwatchChannel.kt` 的 `deviceModelName()`)
`Build.MODEL` 在国内 ROM 上常常是**型号编号**(小米系 `MODEL-A1`、`MODEL-B2`),
用户看到编号根本认不出是哪台;而 ZTE F50 这类 `MODEL` 本身就是名字。
故按三级降级,**判定规则明确**(不是猜):
| 级 | 取值 | 例 |
|---|---|---|
| ① | `ro.product.marketname`(部分 ROM 填营销名;反射读系统属性,**全程 try/catch**) | `Redmi Note 14 Pro+` |
| ② | 编号 + 品牌前缀(判定:长度≥6 且以数字开头且无空格) | `Redmi MODEL-A1` |
| ③ | 本就是名字的型号原样用 | `F50` |
macOS 侧另有一路(`system_profiler` → `sysctl hw.model` → 主机名),见
`lib/platform/device_model.dart`。
### 采样必须走空闲队列(滑动卡顿的真正成因)
真机反馈"上下滑动异常卡顿"。量化后确认**不是掉帧**(本机两页各 10+ 次快速滑动,
零帧超过 24ms),而是:
```
[perf] 采样耗时 9–10ms(采集 6–7ms + 平台计数器 1ms)
```
采样**整条链路跑在 Dart 主 isolate = Flutter UI 线程**上,其中"采集"是**同步 FFI** 进 Rust
(sysinfo 读 /proc、statvfs、挂载表)。每秒一次、**占掉 60fps 单帧预算(16.7ms)的六成** ——
正好落在滑动的那一帧上,就是可感的顿挫。掉帧阈值抓不到它,手感抓得到。
修法:`dispatchSample()` 把采样交给宿主的**空闲调度器**(Flutter 侧
`SchedulerBinding.scheduleTask(..., Priority.idle)`)→ **滑动期间自动让路**,滑完立刻补上。
实测:连续滑动 4 秒期间**零采样**,滑动后 15 秒内收到 14 帧(≈1/秒,采集未受影响)。
同轮另修三处"滑动时重绘/重排":
- 折线图 `duration: Duration.zero`(fl_chart 默认给每次数据变化跑隐式动画,而数据每秒更新);
- 折线图加 `RepaintBoundary`(全页最贵的绘制,没它时滚动每帧重画);
- 仪表盘状态行 `Wrap` → **定高 Row + 等宽数字**(数字每秒变宽曾在窄屏卡在换行临界,整卡高度跳)。
### 怎么在真机上量 Flutter 的卡顿(两个坑)
| 量法 | 结果 |
|---|---|
| `dumpsys gfxinfo <包名>` | **恒为 0 帧** —— 它统计 Android HWUI,而 Flutter 是自绘引擎,不是一个渲染栈 |
| `dumpsys SurfaceFlinger --latency <层>` | Android 13 上对 Flutter 的 SurfaceView/BLAST 层只回一行刷新周期 |
| ✅ 应用内 `SchedulerBinding.addTimingsCallback` | release 同样有效 → `lib/platform/frame_jank_log.dart`(`--dart-define=INKWATCH_LOG_JANK=true`) |
### 信息密度:三条原则(产品负责人反馈"过于密集"后立的规矩)
| 原则 | 落地 |
|---|---|
| **正常项不喊,异常项才显形** | 采集页统计从"五个等重大数字块"改为一行 caption;`待补传/超窗丢失/重连` 只在 > 0 时着色出现 |
| **同一件事不必说两遍** | 状态卡不再重复 `device_id`(配置面板里本就有);仪表盘去掉"窗口 60 帧"(与折线标题"最近 60 秒"重复);设备行去掉冗余的眼睛图标(选中态已有主色边框+浅底) |
| **列表扫读不显示秒** | 设备行的"最后上报"用 `HH:mm`;秒级精度留给图表与末帧 |
另外两处结构调整:
- **首行让给机型名**:设备行原来是"名称·平台 + 徽标 + 眼睛"挤一行,名称被挤成 `MacBook ...`;
现在首行整行给机型名(`FittedBox.scaleDown` 自适应,宁可小不丢字),关系徽标移到第二行。
- **承诺跟着对象走**:`需对方授权 · 服务器时间轴 · 断点不插值` 从页面顶部独立一行移进**设备卡脚注**
—— 它解释的是这份列表,贴在旁边才成立。
> 教训:测试里钉住的安全承诺文案(如「还没有其他人获得查看授权」「只能用一次」)
> **不为省字而改** —— 测试是真相源;要压缩密度就压缩测试没管的那些解释性文字。
### 信息架构调整(首屏主角归位)
- **查看页**:原来首屏一半是"登录说明",现在**设备列表是主角**(登录/配对卡下移)——
用户来这一页是为了看设备,不是为了看登录状态;每行设备已自带「我的 / 他人授权」标注,
列表来源不需要再靠上面的卡片解释。
- **采集页**:`显示「—」的指标` 这类**解释性卡片后置**,首屏留给"在采什么"(指标网格);
能力项(指标 17 项 / 后台常驻 / 守护检查)与口号从深色头部降级为一行 caption。
- **操作按钮跟着作用对象走**:`强制重连 / 停止采集` 进状态卡,`刷新设备 / 停止查看` 进设备卡
(原先都堆在深色 header 里,离它作用的对象很远)。
- 指标网格列数阈值按"卡片放得下两位数值带单位"改(`>300 → 2 列`):
原阈值 480 会让手机逻辑宽度(~309dp)永远退化成单列,一屏只看得到两张卡。
### 加载态:骨架屏 + 三条"不闪不跳"的规矩
产品负责人反馈「刚进入软件,显示的采集界面是灰色的,应该是在加载数据中,但是给人的体验感很差」。
旧版加载态是**一排灰色 `—`**,两个毛病:没说什么在等(用户读成"是不是坏了/没权限"),
而且形状与真实内容不一致(数据到位时整页重排,视觉上又是一次"跳")。
组件在 `lib/ui/skeleton.dart`(`SkeletonBox` / `SkeletonMetricCard` / `SkeletonMetricGrid` /
`LoadingHint`),规矩三条:
| 规矩 | 为什么 |
|---|---|
| **骨架与真实网格同列数、同高度**(都用 `mainAxisExtent: 84`,单列 66) | 高度按 `childAspectRatio` 算会随屏宽变化 → 数据到达时卡片高度与位置全变。真机上 341dp 宽时它还会算出过矮的卡片,`RenderFlex overflowed by 5.1 pixels` |
| **必须配一句说明**(`LoadingHint`),只给灰块等于没说在等什么 | 三种等待原因不同 → 三句话不同:`正在读取本机配置…` / `正在启动采集(连接网关、初始化采集核心)…` / `正在采集第一帧…` |
| **不做呼吸/微光动画** | 周期性无限动画会持续触发重绘(本项目刚在治滑动卡顿),且它会让测试必须用 `pumpAndSettle`(本项目纪律禁止) |
**四种屏态**(`main.dart` 的 `build()`,顺序即语义):
```
_agent != null → 仪表盘
: _restoring → 骨架「正在读取本机配置…」 ← 还不知道该显示面板还是仪表盘,就不显示表单
: _starting → 骨架「正在启动采集…」
: _configPanel() ← 关掉"自动开始"的用户要能进这里
```
**首屏之前不跑非关键平台通道**(真实缺陷,两个后果都很实际):
- `_loadAndroidGuidance()` 要跑多次 Dart ↔ Kotlin 往返,却同时留在 `_restore()` 的启动路径上
**和**首帧回调里 —— 白跑一遍,而且它只影响"保活引导文案"(与能不能采无关),却堵在首屏前面。
现在唯一调用点是 `_start()` 成功后的 `addPostFrameCallback` 且 `unawaited`。
- `ConfigStore.setPlatformDir` 曾经在 `_restore()` 里也有一份,而查看页首次打开也会读配置:
谁先谁后不固定 → 后到的那个落到 `systemTemp`,表现为"账号标识每次重启都变、登录丢了"。
现在只在 `main()` 里、`runApp` 之前调一次。
两条都由 `test/startup_path_test.dart`(8 项)**源码结构断言**钉住(并做过变异测试:
退回旧写法即报红)。此类"功能都对、只是慢/顺序错"的残留行为测试抓不到,只能结构断言拦。
> 顺带一个**护栏自身的坑**:不能用 `contains('_loadAndroidGuidance')` 判"有没有调它" ——
> 注释里会合法地提到它("不要在启动路径上调它"),护栏会被自己的说明文字绊倒。
> 认 `await ` / `unawaited(` **调用前缀**才可靠。
## 职责边界(不要越界改)
| 事项 | 该改哪里 |
|---|---|
| 心跳间隔、退避上限、补传窗口、半开阈值 | `rust-core/src/transport.rs` |
| 采集哪些指标、单位换算 | `rust-core/src/collector.rs` |
| 自启产物内容(plist/systemd/reg/manifest) | `rust-core/src/keepalive.rs` |
| 守护退避与"崩溃 vs 用户退出"判定 | `rust-core/src/keepalive.rs` |
| **socket 连接 / TLS / 令牌注入** | 本工程 `lib/host/link.dart` |
| 平台 UI 载体(托盘 / 前台服务) | 本工程(待补) |
客户端里**不要**再写一份退避或心跳 Interval —— 那是 Rust 的职责,
两边都做会互相打架(Socket.io 自带重连已在本工程显式关闭,原因见 `link.dart` 注释)。
## 关于 on_ack 的取值策略(重要)
服务端当前**不发送**指标确认帧(方案 13.2 只要求"服务端按 seq 去重",未要求回 ack)。
故本工程采用:
- **连接存活期间**:emit 成功即视为送达(Socket.io 基于 TCP,连接内保证送达与重传)
→ 立即 `on_ack`,帧移出待确认队列
- **连接已断期间**:不 `on_ack` → 帧留在 Rust 队列里,重连后 `flushPending()` 补传
(服务端按 seq 去重,重发不会产生重复行)
这样无需服务端改协议即可保证"断线期间不丢帧"。若日后网关补上 ack 帧,
把 `link.dart` 里的 `on_ack` 调用点挪到收到 ack 时触发即可。
## macOS 沙箱权限(发布前必查)
Flutter 生成的 macOS 工程默认 entitlements **不含出站网络权限**,而本项目要主动连网关。
缺了它的表现极具误导性:
```
SocketException: Connection failed (OS Error: Operation not permitted, errno = 1)
```
`errno 1` 是 **EPERM(被系统拒绝)**,不是 `61`(Connection refused,端口没人监听)——
看到它别去查服务端,是沙箱没放行。
`macos/Runner/` 两个文件都必须含 **`com.apple.security.network.client`**:
| 文件 | 必需项 | 说明 |
|---|---|---|
| `Release.entitlements` | `app-sandbox` + **`network.client`** | 模板默认只有 `app-sandbox` → 发布版必定连不上 |
| `DebugProfile.entitlements` | `app-sandbox` + `cs.allow-jit` + `network.server` + **`network.client`** | 模板自带 `network.server`,那是**入站**(热重载用),不能替代出站 |
注意 `network.server`(入站)与 `network.client`(出站)是两项不同的权限,
模板给了前者、没给后者,很容易误以为"已经有网络权限了"。
验证签名里确实带上了:
```bash
codesign -d --entitlements - build/macos/Build/Products/Release/inkwatch_client.app \
| grep -oE 'network\.[a-z]+|app-sandbox' | sort -u
# 期望输出: app-sandbox network.client
```
### 沙箱下的文件写入
`app-sandbox` 下进程的 `HOME` 指向应用容器
(`~/Library/Containers/com.inkwatch.eink/Data`),故配置读写用
`$HOME/Library/Application Support/inkwatch_client/config.json` 即天然落在容器内,**无需额外 entitlement**。
> ⚠️ 但**写 `~/Library/LaunchAgents/` 不在容器内**,沙箱应用无法直接落盘自启 plist。
> 自启注册应由**非沙箱的 CLI 采集端**(`bin/inkwatch_agent.dart` 编译出的可执行文件)
> 或安装脚本完成,GUI 应用只负责显示状态。这是后续接保活时的设计约束。
### ★ macOS 启动与取证三条(2026-09-17 全部实测踩过)
#### ① 注入核心库后**必须重签**,否则应用根本起不来
`flutter build macos --release` 会签好整个 `.app`(含 `Frameworks/` 下嵌套代码的**封印**)。
而 `bundle_rust_core.sh` 往里 `cp` 一个 dylib,封印立刻与磁盘不一致:
```
$ codesign -v --verbose=2 inkwatch_client.app
inkwatch_client.app: nested code is modified or invalid
file modified: .../Contents/Frameworks/libinkwatch_core.dylib
```
后果是**进程在沙箱初始化阶段就被 SIGTRAP 杀掉**(`exit 133`),崩溃栈落在
`libsecinit_appsandbox → libSystem_initializer` —— 也就是**还没跑到 Dart 一行代码**:
```
~/Library/Logs/DiagnosticReports/inkwatch_client-*.ips
exception: EXC_BREAKPOINT / SIGTRAP
_libsecinit_appsandbox.cold.9 ← _libsecinit_initializer ← dyld4::runAllInitializers
```
极具误导性:看着像"应用自己崩了",而且 **stdout/stderr 一个字都没有**。
脚本现已**自动重签 + 验签**(失败即非零退出,绝不打印成功)——
因为旧版会老老实实打印 `✓ 已注入`,**假成功比失败更难查**。
> ⚠️ **验签必须带 `--deep --strict`**。2026-09-17 实测:对同一份失效产物,
> `codesign -v --verbose=2` 报的是 **"valid on disk / satisfies its Designated Requirement"(漏报)**,
> 而 `codesign --verify --deep --strict` 才报
> `invalid signature (code or signature have been modified) / In subcomponent: …libinkwatch_core.dylib`。
> `-v` 默认不递归进嵌套代码 —— **用宽松验签去守"嵌套代码有没有被改坏",等于没守**。
> 脚本已改用最严判据。
#### ② 沙箱应用必须经 LaunchServices 启动,别直接 exec 二进制
```
$ ./inkwatch_client.app/Contents/MacOS/inkwatch_client # ✗ exit 133,零输出
$ open inkwatch_client.app # ✓
```
直接执行带 `app-sandbox` 的应用会因沙箱上下文不完整在 `libsecinit` 崩 —— 与 ① 的表现
**一模一样**(同是 exit 133 + 零日志,但 `.ips` 顶部帧不同)。排障时先分清是哪一种:
看 `.ips` 里是不是只有 `libsecinit`(那是启动方式 / 签名问题),别急着怀疑业务代码。
#### 两种 exit 133 的判别表(2026-09-17 两次崩溃实测,均已确证)
两者都表现为 **exit 133 + 零日志**,但失败机理与发生阶段完全不同 —— 看 `.ips` 三个字段即可分辨:
| | ① 启动方式错(直接 exec 二进制) | ② 签名封印失效(注入后未重签) |
|---|---|---|
| `exception` | `EXC_BREAKPOINT` **SIGTRAP** | `EXC_BAD_ACCESS` / **SIGKILL (Code Signature Invalid)** |
| `termination` | `SIGNAL` / `Trace/BPT trap: 5` | **`CODESIGNING`** / `Invalid Page` |
| `asiSignatures` | **`SYSCALL_SET_USERLAND_PROFILE`** | `null` |
| 栈顶 | `_libsecinit_appsandbox` ← `libSystem_initializer` | dyld **`JustInTimeLoader::makeDiskLoader`** |
| 发生阶段 | 沙箱初始化(**已过签名校验**) | **映射磁盘镜像**(比前者更早,还没到 secinit) |
| `usedImages` 里有 `libinkwatch_core.dylib` 吗 | 没有(还没轮到 Dart 去 dlopen) | 没有(正因为它加载失败才崩的) |
| 解法 | 用 `open` 启动 | 跑 `scripts/bundle_rust_core.sh`(重签) |
> 判据要记牢的是**这两个字段**:`asiSignatures` 有值是"启动方式"问题,
> `termination.namespace == CODESIGNING` 是"签名"问题。二者都不该让你去翻业务代码。
#### ③ 看 GUI 的日志:`open --stdout` 不产文件,用 `startup_log.jsonl`
macOS GUI 形态下 Dart 的 `print` 拿不到(`open` 不接终端;本机 `open --stdout` 也不产文件;
`log show --predicate 'process == "inkwatch_client"'` 是空的,因为 `print` 不走 os_log)。
故启动关键事件**默认落盘** `$数据目录/startup_log.jsonl`(`host/startup_log.dart`):
```
$ tail -3 ~/Library/Containers/com.inkwatch.eink/Data/Library/Application\ Support/inkwatch_client/startup_log.jsonl
{"t":"…","e":"boot","f":{"platform":"macos","dir":"…"}}
{"t":"…","e":"restore_done","f":{"server_url":"…","auto_start":true,"interval_ms":5000,…}}
{"t":"…","e":"started","f":{"server_url":"…","device_id":"…","interval_ms":5000}}
```
它专治**「起而不采」**:进程活着、窗口正常、服务端 0 帧,界面只是静静停在配置面板
(`auto_start=true` 却没生效)。这种"静默未生效"过去**什么都留不下** ——
现在是 `auto_start_no_effect` 一条事件 + 界面一条 10 秒提示。
凭据一律不入盘(键名含 token/secret/password/email 的值只记"非空 + 长度"),文件有界(64KB,截断保留尾部)。
## 保活(macOS:交给 launchd,不自研守护)
方案第 8 章要求「崩溃后 5 秒内重新拉起、用户主动退出不重启、崩溃循环有退避」。
**macOS 上不需要自研守护进程** —— launchd 本身就是守护,且能把其中两条直接落到系统机制层:
| 要求 | 落地方式 |
|---|---|
| 用户主动退出不重启 | plist 的 `KeepAlive.SuccessfulExit = false`:只有**非零退出**才重启 |
| 崩溃后尽快拉起 | `ThrottleInterval = 1`(launchd 默认 10 秒,会突破 5 秒验收标准) |
| 登录/重启后自动恢复 | `RunAtLoad = true` |
| 崩溃循环有界 | launchd 自身节流 + 退避策略在 `rust-core` 的 `GuardianScheduler` |
plist 的**内容**由 Rust 核心 `inkwatch_keepalive_plan_autostart()` 生成,
安装器只负责落盘与执行 —— 不要在 Dart 侧再写一份 plist 模板。
### 安装 / 卸载 / 自检
```bash
# 1) 编译两个独立可执行文件(非沙箱,才能写 LaunchAgents 与调 launchctl)
# 构建命令只此一处,别在这里再抄一遍(Dart 3.13 起 `dart compile exe` 会因
# build hooks 失败;脚本会同时产出 build/inkwatch-agent 与 build/inkwatch-install)
bash scripts/build_cli.sh
# 2) 注册自启(落位到 ~/Library/Application Support/inkwatch_client/bin,写 plist,bootstrap)
./build/inkwatch-install install --server http://127.0.0.1:3000 --device-id my-mac
# 3) 自检:平台/落位/架构/配置/plist 内容/launchd 态/实跑采集端
./build/inkwatch-install verify
./build/inkwatch-install status # 注册态 + 进程 + 配置 + 最近日志
./build/inkwatch-install restart # kickstart(验证保活用)
./build/inkwatch-install uninstall # 停止并移除自启
```
自启项的命令行极简(`<exe> --collector`),网关地址与设备标识**从配置读**
(`~/Library/Application Support/inkwatch_client/config.json`),改配置即可,不必改注册项。
### ⚠️ `launchctl bootstrap` 在受限环境不可用
本机(WorkBuddy 沙箱内)实测:`launchctl bootstrap gui/$(id -u) <plist>` 报
`Bootstrap failed: 5: Input/output error` —— **连一个最小 plist(`/bin/sleep`)也报同样错**,
说明是环境无法访问用户 launchd 的 gui 域,**不是 plist 有问题**。
故 `verify` 不把「launchd 未加载」判为失败,而是打印出待人工执行的那条命令。
请在**自己的终端**(非沙箱)执行一次确认:
```bash
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.inkwatch.collector.plist
launchctl print gui/$(id -u)/com.inkwatch.collector | grep -E "state|pid"
```
### 沙箱与自启的分工(设计约束)
macOS 沙箱应用**写不了 `~/Library/LaunchAgents/`**(该目录不在应用容器内)。
所以:**GUI 应用只显示状态,自启注册一律由非沙箱的 CLI 完成**。
两个进程的配置也各在各自视野里(GUI 在容器内,CLI 在真实家目录),互不干扰。
## Android 端
### 架构:采集跑在「前台服务持有的 FlutterEngine」里
```
InkwatchCollectorService(前台服务,常驻通知)
└─ 创建 FlutterEngine → 执行 Dart main() → 放进 FlutterEngineCache
│
MainActivity ──provideFlutterEngine()─────┘ 复用同一个引擎
```
**为什么必须共用一个引擎**:采集逻辑与状态都在 Dart 侧。若服务另起引擎跑采集、
Activity 又用默认引擎显示界面,就会**两个采集器同时上报**,同一 device_id 下产生重复帧。
共用后是一个 isolate、一个采集器,切回 App 直接看到后台采集器的实时数据。
服务 `stopWithTask=false`:用户从最近任务划掉界面不该让采集停止。
### 构建与运行
```bash
# 1) 交叉编译 Rust 核心到 jniLibs(脚本已固化三个易错点,见文件头注释)
bash scripts/build_android_core.sh # 默认 arm64-v8a
# 2) 构建并安装
flutter build apk --debug # 需 JAVA_HOME / ANDROID_HOME
adb install -r -t build/app/outputs/flutter-apk/app-debug.apk
# 3) 模拟器里连宿主机网关:用 adb reverse(比 10.0.2.2 更省事)
adb reverse tcp:3000 tcp:3000
```
| 组件 | 版本 |
|---|---|
| 模拟器 AVD | `inkwatch_arm64`(Android 16 / API 36 / google_apis / arm64-v8a) |
| NDK | 30.0.16248370 |
| 前台服务类型 | `dataSync`(应用内启动)/ `specialUse`(开机自启) |
创建 AVD:
```bash
avdmanager create avd -n inkwatch_arm64 -k "system-images;android-36;google_apis;arm64-v8a"
emulator -avd inkwatch_arm64 -no-snapshot-load -no-audio -no-boot-anim
```
### 出发布包(release)
```bash
cd inkwatch/client
bash scripts/build_android_core.sh # 若 rust-core 有改动
flutter build apk --release --target-platform android-arm64 \
--dart-define=INKWATCH_DEFAULT_SERVER=http://203.0.113.10 # ← 编译期默认服务器地址
cp build/app/outputs/flutter-apk/app-release.apk \
../dist/inkwatch-arm64-$(grep -E '^version:' pubspec.yaml | cut -d' ' -f2 | cut -d+ -f1).apk
# 交付包统一叫 inkwatch-arm64-<版本>.apk(项目对外名是"墨观 InkWatch";
# 但**应用包名与 FFI 符号仍是 inkwatch** —— 显示名与内部标识是两件事,
# 内部标识(含卷名/包名/FFI 符号)永不随显示名改)
```
| 要点 | 说明 |
|---|---|
| **必须带 `--target-platform android-arm64`** | 不带会打出三 ABI 胖包(53MB),且只有 arm64 有 `libinkwatch_core.so` → 另两个 ABI 装上去直接崩。带上约 19MB |
| **交付包不带 `--dart-define=INKWATCH_LOG_JANK=true`** | 那是卡顿取证的探针,只在本地排查时开 |
| **默认服务器地址是编译期常量** | 已装旧版的机器升级后**仍沿用存盘配置**(`server_url` 优先于编译期默认值)→ 要切服务器得在界面手改,或 `adb shell pm clear <包名>` |
| **版本号要跟着改** | `pubspec.yaml` 的 `version: X.Y.Z+N`。**同版本号不同内容是最坑的**:用户装了哪个说不清,且 Android 会因 versionCode 相同而拒绝覆盖安装 |
**怎么验证"编译期默认值真的进包了"** —— 解开 APK 取 `lib/arm64-v8a/libapp.so` 用 `strings` 扫:
```bash
unzip -o build/app/outputs/flutter-apk/app-release.apk lib/arm64-v8a/libapp.so -d /tmp/chk
grep -cF "203.0.113.10" /tmp/chk/lib/arm64-v8a/libapp.so # 用 -cF,别用 -qF
```
> ⚠️ 两个坑(都实际误导过一次判断):
> 1. **`grep -q` 会提前退出触发 SIGPIPE**,在管道里会报成"假错" → 一律 `grep -cF` 数命中数。
> 2. **Dart AOT 的字符串表按编码分两张**:纯 ASCII 走 UTF-8(可直接 grep),
> **含中文的字符串按 UTF-16LE 存** —— 拿 `grep -F "正在采集第一帧"` 查永远是 0,
> 会被误判成"这个包没含该改动"。查中文要 `grep -cF` 配合转码,或用 Python:
> `data.count("正在采集第一帧".encode("utf-16-le"))`。
> 3. **字节数相同不代表没重编**(AOT 页对齐填充)→ 比 `shasum -a 256`,或看 `libapp.so` 内容。
> ## ⚠️ 你正在 `feature/eink` 分支上
>
> 本分支承载**水墨屏专属版本**,主线(`main`)**不含**以下任何代码:
> 水墨屏看板、水墨屏令牌、`einkMode` 配置、常亮与方向锁定。
>
> 两条线的规矩:
> · 分支上的改动**不得**回灌主线;主线修 bug 后可以 `git merge main` 进本分支(本分支只多不少)。
> · 新增水墨屏能力请只在本分支做;若某个改动对通用版也有价值(例如去无限动画),
> 要在主线单独提一次,而不是把整批水墨屏改动合过去。
>
> 出包:`inkwatch-eink-arm64-<版本>.apk`(与主线的 `inkwatch-android-arm64-*.apk` 区分开)。
### ★ 水墨屏看板模式(`ui/eink_board.dart`)—— iReader Neo 2 副屏
产品负责人要把一台 **iReader Neo 2**(6″ Carta 1300 · 1072×1448 · 300 PPI ·
**黑白 256 灰阶** · MT8175B 四核 2GHz + **2GB RAM** · **Android 10 (API 29)** ·
**零传感器**)当**副屏**常驻看一台设备(参数为实测值,非资料推测)。
> 装包路径(实测可行):电脑下载 apk → 拷进设备的 `iReader/APK` 文件夹 →
> 拔线 → 打开设备的「应用市场」→ 右上角菜单 → 安装。Neo2 在"支持任意安装第三方软件"的机型名单内。
**墨水屏与彩色屏的取向是相反的**,所以这不是"换个主题",而是三条硬约束:
| 约束 | 做法 | 为什么 |
|---|---|---|
| **不得有任何无限动画** | `InkwatchEinkMode.enabled` 全局开关;转圈→静止方块、呼吸→静止图标、`NoSplash` 去掉水波纹 | 墨水屏每一次重绘都是**真实的一次屏刷新**;无限动画 = 持续刷新 + 残影累积 + 耗电 |
| **刷新节流** | 数据每秒到,但只在 `einkRefreshSec`(默认 10s)到点才重建;另设「刷新」按钮 | 副屏看的是趋势与异常,10 秒粒度够;1 秒一刷会把屏刷花 |
| **状态不靠颜色** | `statusGlyph()`:●在线 / ◐疑似 / ○离线 / ◌未知 | 256 灰阶下 `#16A34A`(绿) 与 `#D97706`(橙) 相对亮度接近(0.42 vs 0.45)→ 灰阶化后**分不出来**,界面就在说谎 |
其余为墨水屏而做的取舍:
- **6 大格、一屏放下、永不滚动**。选的指标来自指标目录里 `primary: true`(唯一真相源,
与采集页同源,不另立"核心指标"清单);该设备声明不可得的项**跳过并用次要指标补齐** ——
安卓设备的 primary 里 `net_rx_mbps`/`net_tx_mbps` 恒不可得,不补齐就会有两格永远是 `—`。
- **格高由可用空间驱动**(`Expanded` 等分),不是由内容决定。
第一版用 `Table`,真机上第 3 行被底部栏压住了 —— `Table` 行高由内容决定,内容一高就整体溢出。
改为空间驱动后,任何 density / 字号都不会溢出(5 种极端尺寸有回归测试)。
- **值用 `FittedBox(scaleDown)`**:6″ 屏两列每格只有 ~250dp,`3.10 TB` 固定字号必然溢出。
- **三档明度**(纯黑 / 深灰 / 纯白)+ **黑描边**分区,不用灰底 —— 墨水屏上灰底要整块重绘且会残留。
- **常亮**(`FLAG_KEEP_SCREEN_ON`,走平台通道):副屏的意义就是一直亮着;墨水屏静止不耗电,熄屏才让它没用。
- **进看板自动选一台设备**:看板是全屏的、里面没有设备列表,不自动挑就会变成
"进去看到空白 → 退出 → 选设备 → 再进"。
- **设备切换不走节流**:节流针对"数值连续变化",而"换了哪台设备"是**离散事件** ——
点了却等 10 秒才换画面,用户会以为点错了。
**实测(F50 代跑)**:
```
6 格真实数据:CPU 35.3% / 内存 67.6% / 已用 88.6 GB / 磁盘 3.10 TB / 下行 1.42 Mb/s / 上行 310 kb/s
节流:相隔 4 秒连拍 3 张,后两张 md5 完全相同 —— 屏幕一个像素都没变
(数据是每秒到达的,没节流的话每张都会不同)
截图:dist/水墨屏走查/
```
### ★ 数据库迁移:唯一落点是 `deploy/migrate.sql`(每次启动都跑)
`init.sql` 挂在 `/docker-entrypoint-initdb.d/` —— **那个目录只在数据目录为空时执行**,
所以写在它末尾的迁移语句**在已有库上从来没跑过**,全靠人手敲 SQL。
漏敲的后果不是"少个字段",而是**静默的链路中断**:新增 `devices.hardware_id` 后
`registerDevice` 要写一个不存在的列 → SQL 报错 → 网关 catch 掉只记一句日志
→ **设备与指标不再落库,而 `/health` 全绿**。
| 路径 | 走哪份定义 |
|---|---|
| 全新库 | `init.sql` 末尾 `\i /migrate.sql` |
| 已有库 | compose 的 **`migrate` 一次性服务**(postgres healthy 后执行,跑完即退,`ON_ERROR_STOP=1`) |
两条路径**共用同一份 `migrate.sql`**(单一真相源),语句全部幂等。
`tests/schema-consistency-test.js` 额外断言三件事 —— init.sql 引用了 migrate.sql、
migrate.sql 里没有非幂等的 `ADD COLUMN`、compose 里存在 migrate 服务 ——
防止有人只改一处而两条路径脱钩。
⚠️ 改完 `migrate.sql` 后要**显式**跑一次:compose 不会因为挂载文件变化而重建一次性容器。
```bash
docker compose run --rm migrate # 或 docker compose up -d --force-recreate migrate
```
### 四个必须知道的 Android 坑(都踩过)
1. **复用缓存引擎时 `configureFlutterEngine` 不会被调用**
→ 通道必须在 `provideFlutterEngine` 里也注册一次。否则开机自启路径(服务先建引擎)
下 Dart 一调通道就 `MissingPluginException`。通道逻辑已抽到 `InkwatchChannel.kt`,服务与界面共用。
2. **`Platform.localHostname` 在 Android 上返回 `"localhost"`**
→ 直接当 device_id 会让**所有安卓设备撞成同一个 ID**,而 device_id 是全系统主键。
改用 `ANDROID_ID`(按 设备+签名 唯一、免权限),拼机型前缀便于辨识,
如 `android-sdk-gphone64-arm64-46b450`。
3. **Android 上 `/proc/stat`、`/sys/class/net`、`/proc/uptime` 受 SELinux 限制**
→ sysinfo 受限时**不报错,而是返回 0**。若照原样上报,界面会显示"CPU 0%、网络 0Mbps"——
那是撒谎。已在 Rust 核心加 `SourceProbe` 运行期探测:读不到的指标上报 `null`
且 `capabilities` 置 `false`(查看端据此隐藏卡片,符合方案 7.4)。
同时加了不变量单测:声明不可得的指标必须是 null/空数组,**绝不能是 0**。
4. **Android 13+ 通知是运行时权限**;Android 15+ **禁止**从 `BOOT_COMPLETED` 拉起
`dataSync` 类型前台服务 → 开机自启改用 `specialUse` 类型。
国内 ROM 另有「自启动管理」默认拦截,需引导用户放行(文案来自 Rust 核心的 `runtime_guidance`)。
### Gradle 网络(国内)
- Gradle 跑在 JVM 里,**不认 `HTTP_PROXY` 环境变量** → 直连 services.gradle.org 基本下不动。
已把 `distributionUrl` 指向腾讯云镜像(`android/gradle/wrapper/gradle-wrapper.properties`)。
- Flutter 引擎产物走 `FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn`。
- 若 `~/.gradle/gradle.properties` 里有不可解析的代理(如 `host.docker.internal`,
那是容器内地址),宿主机构建会全挂 → 需把国内域名加入 `systemProp.http.nonProxyHosts`。
## 已验证(本机实测)
| 项 | 结果 |
|---|---|
| 静态分析 | `No issues found!` |
| 客户端测试(FFI 21 + 路径 9) | **全部通过** |
| macOS 客户端构建 | `inkwatch_client.app` 45.7MB,注入核心库 **+ 重签**后独立启动成功(重签已由 `bundle_rust_core.sh` 自动完成;不重签会 exit 133 + 零日志,见上文「macOS 启动与取证三条」) |
| **macOS 实机端到端**(2026-09-17) | 真实 GUI 采集端 → 本机网关 → PG **持续落库**;界面在线态、指标网格(CPU/内存/已用/磁盘/上下行,单位自适应到 GB/TB/kb/s)正常;launchd 常驻采集端在网关恢复后**自动重连并继续上报** |
| **启动事件日志** | `startup_log.jsonl` 落盘 `boot → restore_done → started` 三段(凭据不入盘、64KB 有界),专治「起而不采」取证(11 项测试) |
| 沙箱内真实上报 | GUI 客户端连上网关,**23 帧落库、指标列全非空、能力集 17 项** |
| 自动恢复 | 重启客户端后按本地配置自动开始采集 |
| 安装形态独立运行 | 从 `cwd=/`、无任何环境变量启动采集端,**11 帧落库** |
| 安装器自检 | `inkwatch-install verify` **全绿**(launchd 项如实标注环境限制) |
| **Android 端到端** | 模拟器(Android 16/arm64)**41 帧落库**;内存/磁盘有值,CPU/网络/uptime 如实为 NULL |
| **Android 前台服务** | `isForeground=true, types=0x1`(DATA_SYNC) + 常驻通知 |
| **Android 断线补传** | 日志出现「补传 N 帧(断线期间缓存)」→ Rust 补传队列在 Android 上生效 |
| Android 能力集 | 如实声明 12 项可得、CPU/网络/uptime/gpu/温度 5 项不可得 |
| 跨端一致性 | macOS 与 Android 同时在线,服务端正确区分 platform |
| **客户端 Dart 测试** | `bash scripts/test.sh` **344/344**(查看端 49+17+12、配对卡 6、账号 21、自适应单位 17、**格式化唯一出口 15**、**历史 15**、**视觉令牌护栏 6**、**骨架屏 5**、**启动路径护栏 8**、**配置规范化护栏 19**、**启动诊断 12**、**启动事件日志 11**、**设备归并告知 6**、**水墨屏看板 40**、**空闲采样 3**、**机型降级 12**、FFI 28…) |
| **查看端端到端** | 真实采集端 + 真实配对 + 真实 CLI:快照 4 帧 → 恢复后**断点=1**、能力集隐藏 2 项、撤销后列表即刻不可见(21/21) |
| **Android 真机走查**(F50 / Android 13) | 单位自适应:`84.7 GB` / `131 GB` / `3.11 TB` / `2 天 14 小时` / `365 kb/s`;折线档位:实时 60 秒 → 1 小时(`数据库降采样 · 每 60 秒取平均 · 覆盖 08:04:00 → 09:04:00`);采集频率卡可选 0.5/1/2/5 秒 |
| 查看端静态分析 | `dart analyze lib bin test` **No issues found!** |
## 待补
- [ ] Android 端在**真机**上验证(模拟器上 `uptime_s` 等受限项与真机可能不同)
- [ ] Android release 签名与多渠道 AAB 构建
- [ ] Windows / Linux 客户端的守护进程(无 launchd,需自研;应做成 Rust bin 以守住 <5MB)
- [ ] 上行 msgpack(需 Dart 侧 MessagePack 编解码器;网关侧解包已就绪)
- [ ] iOS(需 Xcode 原生环境,本机暂无设备)
- [x] ~~查看端**历史回溯**~~ —— 已落地(方案 10.3:档位白名单 1h/24h/7d/30d +
服务端降采样 + 缺桶断点;真机实测通过)。**月/年档位未做**:`device_metrics` 只保留
30 天,做 90d/1y 需先定保留策略
- [ ] Android 侧查看端 UI 实机验收 —— F50 上已走查主要路径(单位/档位/断点/配对),
仍需覆盖"设备列表多台""服务端离线"等组合态