## 范围与边界 **本次只改代码层**:客户端包名、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 后一并回归。
64 KiB
墨观 客户端(设备端)
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。
常用命令
# 拉依赖(国内必须走镜像,已在 ~/.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 也会报同样的错)。
运行客户端或测试前请清掉:
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(联调用)
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) |
状态 →(文案, 色, 浅底)的唯一映射 |
三条规矩:
- 颜色/间距/圆角/字号只在
theme.dart定义 —— 页面里出现Color(0x…)即判红 (test/theme_guard_test.dart源码结构断言,已做变异测试); - 语义优先于色值:写
InkwatchColors.online而不是Colors.green; - 颜色只用于表意:整屏以中性灰为底,彩色只出现在状态、主操作、告警上。
改版顺手修掉的三个真缺陷 + 两处"界面在说谎"
| # | 问题 | 修法 |
|---|---|---|
| 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)→ 空 |
| 其它 | 暂不接入 —— 宁可显示主机名,也不猜 | — |
两条硬约束(都有测试):
- 必须有超时(
kModelProbeTimeout= 900ms):system_profiler实测 0.15–0.2s, 但它挂在启动路径上,被拖住就等于"点开始采集没反应"; - 绝不在采集循环里调用:这是启动时取一次的元数据。
取不到就退回主机名,绝不编一个假机型 —— 与服务端"读不到报 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(出站)是两项不同的权限,
模板给了前者、没给后者,很容易误以为"已经有网络权限了"。
验证签名里确实带上了:
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 模板。
安装 / 卸载 / 自检
# 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 未加载」判为失败,而是打印出待人工执行的那条命令。
请在自己的终端(非沙箱)执行一次确认:
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:用户从最近任务划掉界面不该让采集停止。
构建与运行
# 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:
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)
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 扫:
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
⚠️ 两个坑(都实际误导过一次判断):
grep -q会提前退出触发 SIGPIPE,在管道里会报成"假错" → 一律grep -cF数命中数。- Dart AOT 的字符串表按编码分两张:纯 ASCII 走 UTF-8(可直接 grep), 含中文的字符串按 UTF-16LE 存 —— 拿
grep -F "正在采集第一帧"查永远是 0, 会被误判成"这个包没含该改动"。查中文要grep -cF配合转码,或用 Python:data.count("正在采集第一帧".encode("utf-16-le"))。- 字节数相同不代表没重编(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 不会因为挂载文件变化而重建一次性容器。
docker compose run --rm migrate # 或 docker compose up -d --force-recreate migrate
四个必须知道的 Android 坑(都踩过)
-
复用缓存引擎时
configureFlutterEngine不会被调用 → 通道必须在provideFlutterEngine里也注册一次。否则开机自启路径(服务先建引擎) 下 Dart 一调通道就MissingPluginException。通道逻辑已抽到InkwatchChannel.kt,服务与界面共用。 -
Platform.localHostname在 Android 上返回"localhost"→ 直接当 device_id 会让所有安卓设备撞成同一个 ID,而 device_id 是全系统主键。 改用ANDROID_ID(按 设备+签名 唯一、免权限),拼机型前缀便于辨识, 如android-sdk-gphone64-arm64-46b450。 -
Android 上
/proc/stat、/sys/class/net、/proc/uptime受 SELinux 限制 → sysinfo 受限时不报错,而是返回 0。若照原样上报,界面会显示"CPU 0%、网络 0Mbps"—— 那是撒谎。已在 Rust 核心加SourceProbe运行期探测:读不到的指标上报null且capabilities置false(查看端据此隐藏卡片,符合方案 7.4)。 同时加了不变量单测:声明不可得的指标必须是 null/空数组,绝不能是 0。 -
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 原生环境,本机暂无设备)
查看端历史回溯—— 已落地(方案 10.3:档位白名单 1h/24h/7d/30d + 服务端降采样 + 缺桶断点;真机实测通过)。月/年档位未做:device_metrics只保留 30 天,做 90d/1y 需先定保留策略- Android 侧查看端 UI 实机验收 —— F50 上已走查主要路径(单位/档位/断点/配对), 仍需覆盖"设备列表多台""服务端离线"等组合态