## 范围与边界 **本次只改代码层**:客户端包名、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 后一并回归。
1023 lines
64 KiB
Markdown
1023 lines
64 KiB
Markdown
# 墨观 客户端(设备端)
|
||
|
||
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 上已走查主要路径(单位/档位/断点/配对),
|
||
仍需覆盖"设备列表多台""服务端离线"等组合态
|