Files
PMon Dev 8026e82a05
Design System Guard / guard (push) Failing after 10m46s
fix(ci): Android 出包回本机——共享 runner 只有 3.6G,跑不动这套工具链
Preflight 打印的宿主实测值:MemTotal=3654MB、4 核、overlay 40G(已用 42%)。

两次实测结论(不只是 Gradle 堆的问题):
- run 16(Gradle 默认 -Xmx8G):Gradle 一起来整机饿死,35 分钟后判 failure;
  Gitea :3000 TCP 能握手但 HTTP 一律 8s 超时,SSH 卡 banner,需控制台重启。
- run 19(已限 -Xmx1536m):连「解压 1.58GB Flutter 归档」这步就把 3.6G 吃穿,
  同样失联。说明峰值不在 Gradle,而是整套工具链(Flutter 归档 + NDK + JDK)。

改法:
① on: 只留 workflow_dispatch,摘掉 push(paths)——在换独立 runner 之前,
   一次改 rust-core 就能把 Gitea 再压死一次。
② Preflight 阈值 3500MB → 6000MB,可用内存 1500MB → 3000MB;
   低于即明确拒绝(失败关闭),并打印本机出包命令。
③ 新增 client/scripts/build_apk.sh:出包的正式落点。
   前置检查(flutter/NDK/key.properties)→ 交叉编译核心库 → flutter build apk
   → 校验 APK 必须含 libinkwatch_core.so 且不含 libpmon_core.so → 打印 sha256。
   本机 M3 Max 实测 assembleDebug 1m39s,比 CI 那条路快一个数量级。

workflow 保留为「换到 ≥8G / ≥4 核独立 runner 后可直接用」的完整配方。
2026-11-10 16:54:06 +08:00
..

墨观 客户端(设备端)

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) 状态 →(文案, 色, 浅底)的唯一映射

三条规矩:

  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(出站)是两项不同的权限, 模板给了前者、没给后者,很容易误以为"已经有网络权限了"。

验证签名里确实带上了:

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

⚠️ 两个坑(都实际误导过一次判断):

  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 不会因为挂载文件变化而重建一次性容器。

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 原生环境,本机暂无设备)
  • 查看端历史回溯 —— 已落地(方案 10.3:档位白名单 1h/24h/7d/30d + 服务端降采样 + 缺桶断点;真机实测通过)。月/年档位未做:device_metrics 只保留 30 天,做 90d/1y 需先定保留策略
  • Android 侧查看端 UI 实机验收 —— F50 上已走查主要路径(单位/档位/断点/配对), 仍需覆盖"设备列表多台""服务端离线"等组合态