Compare commits

150 Commits
Author SHA1 Message Date
PMon Dev 8026e82a05 fix(ci): Android 出包回本机——共享 runner 只有 3.6G,跑不动这套工具链
Design System Guard / guard (push) Failing after 10m46s
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
PMon Dev f6e896d27a ci(android): Verify APK contents 增加导出符号断言,期望值从 ffi.rs 现取
Android APK (arm64, CI-verified) / apk (push) Failing after 37m5s
Design System Guard / guard (push) Failing after 7m23s
只验「文件名叫 libinkwatch_core.so」不够:strip 或编译配置出错时文件名照样对,Dart 侧 dlopen 后却找不到任何 inkwatch_* 符号(装到机器才崩)。新增:从 APK 解出核心库 → 读动态符号表 → 与 rust-core/src/ffi.rs 的 pub extern C fn inkwatch_ 声明数比对,不足即红。

期望值**不写死**:按本仓「可导出量不写副本,只写指针 + 现取命令」的纪律,从权威源现取(当前 39)。工具优先用 NDK 的 llvm-nm(Linux runner 路径),缺失时回落到 nm -D --defined-only。

本地实跑(M3 Max,16:21 的 release APK):ffi.rs 声明 39、llvm-nm 数出 39、nm -D 回落 39 → PASS;YAML 解析 OK(12 steps);bash tests/run-guards.sh → 6 套件 PASS 5 / FAIL 0 / SKIPPED 1,exit=0。
2026-11-10 16:39:53 +08:00
PMon Dev 90a16357d1 fix(build): 导出符号计数按产物格式选工具,别再对 ELF 报 0
build_android_core.sh 的 count_exported() 原先按宿主 OS 分支:Darwin 用 nm -gU(Mach-O 参数)读 Android 的 ELF,返回 no symbols,明明有 39 个 inkwatch_* 导出符号却报 0(假阴性,2026-11-10 本地实测)。产物格式由目标平台决定,与宿主无关,统一改用 nm -D --defined-only 读动态符号表,macOS 与 GNU/Linux 的 nm 都支持;顺带把跨平台注释补成两次实测的成因。

验证(M3 Max / Flutter 3.47.4 / JDK21 / NDK 28.2.13676358):
- 修复前 bash client/scripts/build_android_core.sh arm64-v8a → 导出符号 0 个
- 修复后同命令 → 导出符号 39 个(nm -D 与 NDK llvm-nm 双向复核一致)
- 完整 release 构建 67s,APK 20.2MB,内含 lib/arm64-v8a/libinkwatch_core.so 且无 libpmon_core.so(与 CI 的 Verify APK contents 断言一致)
- bash tests/run-guards.sh → 6 套件 PASS 5 / FAIL 0 / SKIPPED 1,exit=0
2026-11-10 16:26:06 +08:00
PMon Dev c1a4ccbfa3 fix(ci): Android job 加资源前置检查,并把 Gradle 堆从 8G 压到 1.5G
事故(2026-11-10 run 16):flutter build apk 起来后整机被压住,
Gitea 的 3000 端口 TCP 能握手但 HTTP 无响应(curl 8s 超时、HTTP=000),
SSH 卡在 banner exchange —— 服务没崩,是被饿死。必须从控制台重启。

根因:client/android/gradle.properties 里的
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G 是给开发机写的,
runner 与 Gitea 同机同内存,直接吃穿。

改法:
① Preflight 步骤读 /proc/meminfo(容器里反映宿主),MemTotal<3500MB
   或 MemAvailable<1500MB 就**明确拒绝执行**(失败关闭),不冒险跑。
② Build APK 步骤用 GRADLE_OPTS 覆盖:Xmx1536m、Metaspace 384m、
   kotlin daemon 512m、workers=2、parallel=false。

这条不改仓库的 gradle.properties —— 本地构建快了不必为 CI 让步。
2026-11-10 16:23:49 +08:00
PMon Dev 1c41ec8574 ci(probe): 收敛一次性环境探针,结论已固化进 android-build.yml
Design System Guard / guard (push) Successful in 4m35s
e2848ca 加的 probe-android-env.yml 只挂 workflow_dispatch,用于回答「CI 上能否构建 Android APK」。结论已在 25861a5 的 android-build.yml 环境事实段逐条落地:dl.google.com 200/171ms 可直下、maven.google.com 超时故走阿里云镜像、mirrors.cloud.tencent 提供 gradle 分发、rsproxy 走 rustup/crates、apt-get update 实测不可靠故不用 apt、repo.maven.apache.org 仅兜底。正式流水线已用 workflow_dispatch + push(paths) 触发。

探针留在仓库会被 Gitea 列进 Actions 且可手动误触发(内含 apt-get update,实测四分钟无输出),故删除;需要复测环境时可从 git 历史取回:
  git show e2848ca:.gitea/workflows/probe-android-env.yml

验证:bash tests/run-guards.sh → 6 套件 PASS 5 / FAIL 0 / SKIPPED 1,整体 exit=0。
2026-11-10 16:13:35 +08:00
PMon Dev afa20b431a fix(ci): 去掉 init.gradle 的项目级仓库注入——它违反 PREFER_SETTINGS
Android APK (arm64, CI-verified) / apk (push) Failing after 34m52s
Design System Guard / guard (push) Successful in 4m31s
run 14 在 flutter build apk 阶段红:
  Error resolving plugin [id: 'dev.flutter.flutter-plugin-loader', version: '1.0.0']
  > Build was configured to prefer settings repositories over project repositories
    but repository 'maven' was added by settings file 'settings.gradle.kts'

Flutter/AGP 把仓库模式设成 PREFER_SETTINGS,只认 settings 那份;
而 init.gradle 里 allprojects { repositories.maven {…} } 是往**项目级**加,
直接违规。删掉该步骤,镜像只保留在 settings.gradle.kts。

对照证据:本机 ~/.gradle/init.gradle 用的是
settingsEvaluated { dependencyResolutionManagement.repositories }(settings 级
追加),本机 assembleDebug 成功;CI 那份是 project 级,故红。

本地验证:./gradlew :app:assembleDebug → BUILD SUCCESSFUL in 1m39s(119 tasks)。
2026-11-10 16:01:20 +08:00
PMon Dev 25861a5c0c ci(android): 新增 APK 构建流水线,并修核心库脚本的跨平台断点
Android APK (arm64, CI-verified) / apk (push) Failing after 7m55s
Design System Guard / guard (push) Successful in 5m49s
起因:本地 jniLibs 里躺着改名前的 libpmon_core.so(10-30 产物),
而 Dart 侧只认 libinkwatch_core.so —— 改完名没重编安卓库这条断链
一直没人发现(jniLibs 未入库,本机构建会带错库)。

新增 .gitea/workflows/android-build.yml:
  - JDK 21(华为云直下,不用 apt —— 探针里 apt-get update 4 分钟无输出)
  - Android SDK platform 36 / build-tools 36 / NDK 28.2.13676358
    (版本取自 FlutterExtension.kt,与 Flutter 3.47.9 对齐)
  - Rust + cargo-ndk + aarch64-linux-android(rsproxy)
  - 每次生成一次性 keystore(仓库要求 release 必须有 key.properties,
    缺了会明确失败,拒绝回落 debug 签名);job 末尾连同失败情况一并删除
  - Verify APK contents:断言包内有 lib/arm64-v8a/libinkwatch_core.so、
    且不含 libpmon_core.so,否则红

build_android_core.sh 三处跨平台修:
  - NDK 定位增加 ANDROID_SDK_ROOT / ANDROID_HOME 回退(原先写死 macOS 路径)
  - stat -f %z 在 Linux 上会失败并中断脚本 → 改用兼容取法
  - nm -D --defined-only 是 GNU 参数,Darwin 用 nm -gU
  - 编译前删掉旧名 libpmon_core.so,防止它被打进 APK

settings.gradle.kts 把阿里云镜像排在 google() 之前:探针实测
maven.google.com 在 runner 上连接超时,不换镜像每个插件都要先超时一轮。
2026-11-10 15:50:20 +08:00
PMon Dev a9bdee363d ci: 把 Flutter 安装抽成共用脚本,guard 改为调用它
两个 workflow 都要装 Flutter,各写一份必然漂移。抽到
scripts/ci/install-flutter.sh,版本/镜像/解压参数都在一处。
参数化 FLUTTER_VERSION / FLUTTER_DEST / FLUTTER_STORAGE_BASE_URL。
2026-11-10 15:50:19 +08:00
PMon Dev e2848ca672 ci(probe): 加一次性环境探针,为 Android APK 能否在 CI 构建取证
Design System Guard / guard (push) Successful in 5m40s
runner 到 github.com 已确认不通,而 Android 构建链依赖 dl.google.com /
maven.google.com / gradle 分发 / apt / crates,这些通不通决定方案。
探针只挂 workflow_dispatch,不参与 push 触发;全部失败也 exit 0,
结论看日志里的 OK/FAIL 表。
2026-11-10 15:22:42 +08:00
PMon Dev 71efe04e32 fix(ci): 补上干净 clone 缺的两块——pg 依赖与 Rust 核心库
Design System Guard / guard (push) Successful in 5m33s
run 9 已跑通环境,暴露两处真实缺口:
① schema-consistency-test.js → server/shared/repository.js → require('pg'),
   干净 clone 无 node_modules,报 Cannot find module 'pg'。
   补 npm ci(server,走 npmmirror)。
② clock_test / host_ffi_test 在 loading 阶段要 libinkwatch_core.so,
   找不到即整份失败。补 rustup(rsproxy 镜像)+ cargo build --release,
   产物落在 rust-core/target/release,正是 core_lib.dart 的候选路径。
2026-11-09 22:17:46 +08:00
PMon Dev f3ec3093c4 test(device-model): ①②④ 组加 macOS 平台守卫
这三组的前提是 resolveDeviceModel 的 macOS 两级取法,而该函数在
Linux/Windows 走到末尾直接 return ''。在 Gitea 的 ubuntu runner 上
表现为 7 条红,看着像解析写坏了,实则平台不符。

改法:main() 里算一个 macOnly skip reason,三组显式 skip 并写明原因。
框架会计入 skipped,不伪装成通过;本机(macOS)skip 为 null,12 条照跑全绿。
2026-11-09 22:17:46 +08:00
PMon Dev 5ba371380f fix(ci): Flutter 归档解压加 --no-same-owner,并登记 safe.directory
Design System Guard / guard (push) Failing after 5m3s
run 8 实测:1.58GB 归档 3 分钟下完、解压成功,最后卡在
  fatal: detected dubious ownership in repository at '/root/development/flutter'
flutter 工具链启动时会调 git 取版本,被该保护拦下 → exit 128。
根因是归档内 uid 非 0,root 身份解压仍按原 uid 设属主。
解压加 --no-same-owner,并额外 git config --global safe.directory 兜底。
2026-11-09 22:09:48 +08:00
PMon Dev 30280f3b75 fix(ci): 去掉 setup-node——它的 mirror 参数不管版本清单,照样打 github
Design System Guard / guard (push) Failing after 4m7s
run 7 实测:mirror 已正确注入 env,但 setup-node 在解析 '22' 这条
范围版本时仍去 github.com/actions/node-versions 拉清单,两轮
socket hang up + 一次 ETIMEDOUT,5 分钟仍未进入下载。

本 job 的四个护栏脚本只 require fs/path/crypto,容器自带的
node 20(runner 把 ubuntu-latest 映射到 node:20-bookworm)足够。
改为 Report toolchain 步骤:只打印版本并在 node 缺失时明确报错。
job 上限从 40 分钟收到 30 分钟。
2026-11-09 22:04:29 +08:00
PMon Dev 21dae59276 fix(ci): 修掉 Gitea 工作流 6 连红的两处根因
Design System Guard / guard (push) Canceled after 5m38s
① subosito/flutter-action@v2 的 setup.sh 硬依赖 jq,runner 的
   node:20-bookworm 容器没装 jq,首行即 'jq not found' 退出——
   这是 6 次运行全红的直接原因(日志 13:50:44)。
② 该 runner 到 github.com 直连超时(ETIMEDOUT 20.205.243.166:443),
   setup-node 靠 3 轮重试 + fallback 烧掉 8 分钟才装上 Node;
   flutter-action 默认 git clone github.com/flutter/flutter 必挂。

改法:不再用 flutter-action,直接取 storage.flutter-io.cn 的
stable 归档(1.58GB,版本钉 3.47.9 / Dart 3.13.7,满足 ^3.13.3);
Node 走 npmmirror;Flutter/PUB 均设中国镜像;job 加 40 分钟上限。
node:20-bookworm 自带 curl/tar/xz,故无需 apt 装 jq。
2026-11-09 21:58:47 +08:00
PMon Dev 49dacb57a6 merge(v2/init): 接入层归宿主,并同步护栏到新模板
Design System Guard / guard (push) Failing after 9m13s
合并 088c9b3(移除 nginx 容器、常驻 5→4)到 main。三路合并零文本冲突,但 088c9b3 漏改了引用被重命名文件的地方,故在本合并提交里一并适配:
1) tests/phase3b-static-guard.js:265 的断言原本读 deploy/nginx.conf,该文件已 rename 成 deploy/nginx.host.conf.example;不改会让 read() 返回空串、断言必然 FAIL,而该护栏被 run-guards.sh / Gitea Actions 收录。新模板 108-109 行确有 location ^~ /admin/ 与 proxy_pass,换名后断言成立。
2) deploy/nginx.ssl.conf.example 的使用说明仍写 cp nginx.conf 与 docker compose up -d nginx(两者都已不存在),改为时代注记,点明本文件只是 HTTPS 片段、缺 /admin/ /setup /relay 段、不可整份照抄(纯注释,不改配置行为)。

验证:bash tests/run-guards.sh → 6 套件 PASS 5 / FAIL 0 / SKIPPED 1(axe 可选未启用);phase3b 94/0、dockerfile-coverage 60/0、schema 116/0、Dart 674 全通过,整体退出码 0。
2026-11-09 21:42:30 +08:00
PMon Dev 48fef36b82 fix(a11y): 管理台登录框补持久 label,并把这个 axe 盲区做成静态断言
Design System Guard / guard (push) Failing after 1h2m42s
问题
  6 个管理台页面的登录遮罩里,三个输入框(auth-email / auth-pw / auth-input)
  只有 placeholder、没有 label。axe 的 `label` 规则把「非空 placeholder」也算作
  可访问名,所以 42 格全矩阵扫描对该缺陷是**零违规**的 —— 它是扫描器的盲区。
  而 placeholder 一输入就消失,读屏用户随即失去字段名(WCAG 3.3.2 / 1.3.1)。

改动
  · server/business/admin/{index,accounts,devices,settings,audit,metrics}.html
    三个输入框各补一个 <label class="iw-sr-only" for="…">:
      auth-email → 「管理员邮箱」
      auth-pw    → 「口令」
      auth-input → 「应急令牌 ADMIN_TOKEN」
    用既有的 .iw-sr-only(admin.css:74 已在)而非 aria-label:label 是真的
    标注关系,aria-label 只是替换名称,且 label 将来可见化不用再改结构。
    视觉零变化(.iw-sr-only 是 position:absolute + clip 的离屏类,不参与流布局)。
    用一次脚本落地,6 页登录框内部结构逐字一致 —— 非交互式改 6 份 HTML
    最容易犯的错就是漏改一页,所以下面把这条也固化成断言。

  · tests/phase3b-static-guard.js 新增 B2 节(净增 7 条断言,87 → 94)
      - 6 页 ×「三个输入框都有非空 label[for]」
      - 1 条「6 页登录框内部结构 SHA256 全等」—— 直接对应上面那个漏改风险
    块提取用 #auth-overlay → </form>,label 文本去标签去空白后判非空。
    文件头的覆盖清单同步补上 B2 一行。

验证
  · RUN_AXE=1 bash tests/run-guards.sh
      web-token-guard.js          PASS  21/0
      phase3b-static-guard.js     PASS  94/0   ← 由 87 增至 94
      dockerfile-coverage-test.js PASS  53/0
      schema-consistency-test.js  PASS 116/0
      client Dart 全量            PASS 674 项
      admin-axe-audit.mjs         PASS  42/42 格 viol=0(inc=12 有台账)
    6 套件 PASS 6 / FAIL 0 / SKIPPED 0
  · 反证:git stash 掉本次改动后同一脚本为 86 条,恢复后 93 条,净增 7 与预期一致
    (护栏自己的汇总行会计入,故与报告里的 94 相差 1)。
2026-11-09 17:34:45 +08:00
PMon Dev 1d76c3b8ac feat(design-system): 合入 V2 线并统一命名为 Inkwatch,补齐深色主题与状态符号图标化
Design System Guard / guard (push) Failing after 7m11s
本提交收口本轮四件事。三者改动在同一批文件里交织(改名触及 theme.dart、
深色化也在 theme.dart),按文件无法干净切分,故合为一个可审计的提交,
下面按事由分述。

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

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

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

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

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

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

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

已知边界:本轮仅 ThemeData 层深色皮肤。页面自绘卡片/文字仍直接引用静态
PmonColors.* / PmonText.* / PmonCard.*(11 / 9 / 4 个文件),不走 ThemeData,
深色下整屏仍呈浅色;整屏页面级深色化需改为 Theme.of(context) 查询,
属独立后续阶段。Web localStorage 与 Flutter AppConfig 为两端独立持久化。
2026-11-08 21:16:30 +08:00
PMon Dev 61f044a752 merge(main): 解 2026-09 历史双写冲突,8 文件统一保留 feature/eink 实现
# Conflicts:
#	client/lib/host/lan_service.dart
#	client/lib/host/link.dart
#	client/lib/host/viewer.dart
#	client/lib/main.dart
#	client/lib/ui/transfer_card.dart
#	client/lib/ui/viewer_page.dart
#	client/test/lan_service_test.dart
#	client/test/transfer_card_test.dart
2026-11-08 20:52:12 +08:00
jkooe 088c9b34a0 refactor(deploy): 移除 nginx 容器,接入层改由宿主承担(常驻 5 → 4)
## 结果

四容器编排从 6 个 service 降到 5 个:`postgres` / `redis` / `business` / `gateway`
常驻 + `migrate` 一次性。**常驻容器 5 → 4**,实测直连 `127.0.0.1:4000|3000/health`
均 200。

## 为什么现在做

`docker-compose.prod.yml` 里早就写着"生产不启动自带 nginx 容器,接入层交给 1Panel
OpenResty",只是用 `profiles: ["never"]` 停用的。也就是说**生产本来就没这个容器**,
默认文件里留着它反而让两条路径分叉:本地开发跑一套、生产跑另一套,`nginx.conf`
只服务前者。现在两套文件对齐,反代只在宿主一处。

## 三个必须处理的连带问题(都不是删几行就完事)

**① `&logging` 锚点原本挂在 nginx service 里**,被另外 5 个 service 用 `*logging`
引用。直接删块会让 Compose 报 YAML 锚点未定义,而**报错点在引用的那几行、不在
被删的地方**。已把它提到顶层 `x-logging:` 扩展字段(Compose 支持),并把这个坑
写进注释。

**② prod 覆盖里那段 `nginx: profiles: ["never"]` 会变成悬空引用**(服务已不存在),
一并删除并改写注释。

**③ `watchdog.sh` 探的 `/healthz` 原本是 nginx 容器提供的虚拟端点。** 删掉后它会
恒报 `entry:404` —— 一个"服务其实好好的但监控一直叫"的假故障。已把探针语义改成
"接入层活着",并在 404 时把最常见成因写进告警文本(宿主反代没配 /healthz)。

## 宿主反代模板

`deploy/nginx.host.conf.example`(替代原来的 `deploy/nginx.conf`),关键差异:
upstream 从 `gateway:3000` / `business:4000` 改为 `127.0.0.1:3000` / `127.0.0.1:4000`
—— 容器服务名**在宿主上根本不解析**,照抄旧配置会 `host not found in upstream`。

四条承载性指令原样保留,且写清了缺每一条的**症状**(都不是 502):
- `proxy_http_version 1.1` + `Upgrade`/`Connection` → 缺了 WebSocket 握手失败
- `proxy_read_timeout 600s` → 缺了长连接 60 秒被无声掐断
- `proxy_buffering off` → 缺了画面不实时(数据被攒住)
- `/relay/` 的 `proxy_request_buffering off` → 缺了"文件不落盘"变成假话

配置已用真 `nginx -t` 校验(`nginx:1.27-alpine`,`syntax is ok / test is successful`),
不是靠肉眼或 grep 判断。

## 护栏:把"搬到宿主"这件事本身变成可验证的

`tests/dockerfile-coverage-test.js` 新增 7 条:模板存在、四条承载性指令各一条、
upstream 必须是回环而非 Docker 服务名、以及一条**负向**断言 —— 四容器编排里不许
再冒出 `nginx:` 服务(防"顺手加回来"造成两处真相)。

不加这段的话,宿主模板被删或被简化**不会有任何测试变红**,而症状是"页面能开但画面
不刷新",从现象很难倒推回配置。

测试:`dockerfile-coverage` **60/0**、`claim-proof` **57/0**。

## README

现状描述(目录树 / 端口表 / 起栈步骤)已改。**历史段落不改写**:
- "5 容器 healthy、经 nginx :80 入口"那句加"时代注记",说明那是当时形态;
- "坑 B:nginx 强依赖证书"整段加抬头说明容器已移除,但**过程与教训原样保留** ——
  那两条坑(硬引用可选文件导致 clone 必崩、Compose ports 是拼接语义)在宿主反代上
  一样成立,只是换了文件。
2026-11-08 20:29:43 +08:00
mtfyun 7f8e02d4ad Merge pull request 'V2/init' (#1) from v2/init into main
Reviewed-on: http://123.206.124.151:3000/mtfyun/inkwatch/pulls/1
2026-11-08 20:28:44 +08:00
PMon Dev 5ba022f598 ci(ui): 设计系统双向护栏接入 Gitea Actions
- 新增 .gitea/workflows/design-system-guard.yml:push / pull_request 触发,
  checkout + setup-node(22) + flutter-action(stable),执行 run-guards.sh;
  经 FLUTTER_BIN 把 flutter 绝对路径传给 client/scripts/test.sh
- 新增 tests/run-guards.sh 统一入口:web-token-guard → phase3b-static-guard
  → dockerfile-coverage → schema-consistency → Dart 全量,任一失败即整体失败
- 新增 tests/web-token-guard.js:源仓库内零外部依赖守卫,只读检查
  assets/tokens.css 的 Core 45 项、深色 58 键同名覆写与层叠顺序、
  color-scheme: light / 无 prefers-color-scheme、admin.css 零硬编码色值与
  4 档圆角、admin.js 主题控制器单一出口
- 新增 tests/README.md:用法、环境变量、前提与降级语义

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

前提:workflow 仅在 Gitea 实例启用 Actions 时运行;
本地无需 Actions 即可用 bash tests/run-guards.sh 完整复现。
本 commit 只新增 CI 文件,未改任何产品代码。
2026-11-08 20:21:42 +08:00
jkooe 7c9c2f0605 refactor(rename): pmon → inkwatch 数据层迁移(阶段 B:库名/角色/卷名/连接串)
## 做了什么(顺序有意义)

1. **备份**:`pg_dump -Fc` → 152MB dump,落在仓库外
   `.backup-rename-20261108/`,并留了改名前后的表行数文件。
2. **停应用**(nginx/business/gateway),保留 PG/Redis。
3. `ALTER DATABASE pmon RENAME TO inkwatch` + `ALTER ROLE pmon RENAME TO inkwatch`
   + `ALTER ROLE inkwatch WITH PASSWORD 'inkwatch_dev_only'`。
4. **复制卷**(不是改名):`pmon_pg_data → inkwatch_pg_data`(3.807GB)、
   Redis、nginx 日志三个卷。**旧卷全部保留**,作回滚点。
5. 改 compose 的卷名与环境变量默认值、`deploy/` 下全部脚本/配置、测试默认连接串、
   `.env.example`、`docs/`。
6. 用新 compose 起栈,删旧容器(`pmon-*` 共 6 个,含 9 天前的 migrate 容器)。

## 数据校验:这是本次唯一不能靠"应该没事"的部分

第一次我拿 `pg_stat_user_tables.n_live_tup` 当基线,**那是错的** —— 它是估算值,
实测 `devices` 估 58、实际 14559,`device_metrics` 估 0、实际 257 万。用它做校验
等于没有校验。

改成的办法:把 dump **恢复到一个临时库**,然后两张库**逐表精确 `count(*)` 对比**
(排除 `_hyper_*` chunk,父表计数已覆盖)。结果:

```
唯一差异:device_metrics  当前 2572652 / 备份 2572456  → 当前多 196 行
```

多出来而不是少了 —— 因为 dump 是 20:04:57 取的,而停服在 20:07:53,期间设备仍在
正常上报。最新数据时间戳 `12:07:53 UTC` 佐证了这一点。**196 行是正常写入,不是差异。**
逐表其它 40+ 张表行数完全一致。

校验库随后删除,容器内临时 dump 也删了。

## 两个踩到的坑(都已记录在流程里)

**① `docker exec` 不加 `-i` 时 stdin 不转发。** 我第一次用 heredoc 把四条 SQL 喂给
`psql`,结果**一条都没执行、也不报错** —— 看起来像"执行了但没生效"。改成 `-c` 逐条。

**② `ALTER ROLE` 不能重命名当前会话用户**(`session user cannot be renamed`)。
解法是建一个临时超级用户来执行,用完立刻删(已验证 `pg_roles` 里查不到它)。
注意 `DROP ROLE` 同样不能删当前用户,必须换回原身份再删。

## 有意没做的事

- **旧卷没删**:`pmon_pg_data`(3.807GB) / `pmon_redis_data` / `pmon_nginx_logs` 都留着。
  这是唯一的整体回滚点。确认新栈稳定运行一段时间后,可自行
  `docker volume rm pmon_pg_data pmon_redis_data pmon_nginx_logs`。
- **`pmon_dbg1` / `pmon_claim_verify` 两个遗留库没动**:它们是历史测试/调试残留,
  不是产品库。要清可以直接 `DROP DATABASE`,但删数据这种事我不代做决定。

## 验证

新栈 5 个容器全 `healthy`,`/health` business 与 gateway 均 **200**;
库内 `users=128 devices=14559`,与迁移前一致。

服务端套件(全部自起源码实例或纯离线):
认领 **57/0**、埋点 **29/0**、配对码来源 **16/0**、权限 **60/0**、schema **116/0**
—— 与改名前的数字**完全一致**。

客户端上一提交已验证 **595 通过 / 2 失败**(两条为 `libinkwatch_core.dylib` 未编译)。
2026-11-08 20:12:25 +08:00
jkooe 5f419bcb91 refactor(rename): pmon → inkwatch 全量改名(阶段 A:代码层,不碰数据层标识)
## 范围与边界

**本次只改代码层**:客户端包名、Android/iOS/macOS 标识、环境变量前缀、Rust crate 名、
类名/文件名、以及全部文案与注释。**数据层标识一处未动**(卷名 `pmon_pg_data`、
`POSTGRES_USER/DB`、连接串默认值),那些在阶段 B 单独做迁移,理由见下。

统计:**114 个文件、1687 处**替换 + **8 个文件/目录重命名**。

## 改了什么

| 类别 | 旧 | 新 |
|---|---|---|
| Dart 包名 | `pmon_client` | `inkwatch_client`(41 个文件的 import 同步) |
| Android namespace | `com.pmon.pmon_client` | `com.inkwatch.eink` |
| Android applicationId | `com.inkwatch.eink` | **保持不变** —— 它本来就已是新名 |
| Kotlin 包目录 | `kotlin/com/pmon/pmon_client/` | `kotlin/com/inkwatch/eink/` |
| macOS bundle | `com.pmon.pmonClient` | `com.inkwatch.eink` |
| 环境变量 | `PMON_*`(12 个) | `INKWATCH_*` |
| Rust crate | `pmon-core` / `pmon_core` | `inkwatch-core` / `inkwatch_core` |
| 动态库 | `libpmon_core.dylib` | `libinkwatch_core.dylib` |
| 类/文件 | `PmonApp` `PmonColors` `PmonChannel` `PmonCollectorService` `pmon_agent.dart` … | `Inkwatch*` / `inkwatch_*.dart` |

## 三处**必须单独说**的判断

**① applicationId 刻意没动。** `build.gradle.kts:49` 有既有说明:水墨屏专属版用
`com.inkwatch.eink`,与主干通用版**刻意不同**,为的是两版本能在同一台手机上共存
(versionCode 各自递增,同包名会互相覆盖且降级被拒装)。它已经是新名,动它反而
破坏这个设计。我改的是 `namespace`(内部分包名,只影响编译期),并把它统一到同一个
`com.inkwatch.eink`。

**② 局域网 P2P 线协议前缀一起改了**(`PMON-PUNCH:` / `PMON-PONG:` / `PMON-FRAME:` →
`INKWATCH-*`)。这**不是纯文案**:两端必须同时升级,否则新旧版本在同一局域网里互不
认。判断依据是这三个前缀只用于同一用户的两台设备直连,且当前尚未对外发布;若以后
要支持跨版本,正确做法是加协议版本协商而不是回退旧前缀。

**③ 数据层标识一处未动。** `deploy/` 下 28 个文件仍是 `pmon`,因为卷名/库名/库用户
的改动会直接导致连不上库或指向新空卷(`docker-compose.all.yml:26-29` 里写着:
改名会让 compose 去找 `inkwatch_pg_data` 这套新空卷,等于把生产清库)。实测当前
数据是 **2.5GB、128 个账号、58 台设备**,不是测试库。

## 安全网

动手前做了完整备份:
`.backup-rename-20261108/pmon_before_rename.dump`(`pg_dump -Fc`,152MB),
并记录了迁移前各表行数基线,供阶段 B 逐表比对。

## 验证

客户端全量 **595 通过 / 2 失败** —— 与改名前的数字**完全一致**,两条仍为
`libinkwatch_core.dylib` 未编译(Rust 核心未构建,环境缺口)。**数字一致**是这次
改名的核心判据:一个纯机械改名必须不改变任何测试结果,若通过数变了就说明改坏了东西。

服务端代码只动了注释与环境变量名,逻辑零改动,阶段 B 后一并回归。
2026-11-08 20:07:30 +08:00
jkooe cc0aaffffd fix(pairing): 配对码改由设备本地生成、随连接上报 —— 零鉴权取码端点下线为 410(M4″)
## 修的是什么

`POST /api/pairing/code {device_id}` 原先**零鉴权地生成码并在响应体里返回**:

```
POST /api/pairing/code  {"device_id":"<任何存在的设备>"}
→ 200 {"code":"483920","expires_at":...}
```

只要知道一个存在的 `device_id`,**任何人不登录、不持有任何凭据**,就能拿到该设备
的配对码,再去查看端输入,换到该设备的查看授权。比认领洞更浅:认领洞还要先登录。

根因不是"端点忘了加鉴权",是**码的来源错了**。服务端凭什么相信"请求方就是那台
设备"?`device_id` 是可猜的公开标识;`device_token` 只发给已归属设备(新设备会
成环,M4′ 里已经推演过)。唯一不可伪造的是**那条已建立的连接本身**。

## 改成什么

码由设备本地生成(`client/lib/host/pairing_code.dart`),随 `agent:hello` 的
`payload.claim_code` 上报,网关在这一刻登记为配对码(`PairingStore.registerDeviceCode`),
**断连即删**(`forgetDeviceCode`),TTL 300s 只是崩溃兜底。

于是码的语义变成真话:**"设备此刻在这条连接上活着"**。屏幕上那个码因此永远是服务端
认可的那一个,不存在"屏幕显示 A、服务端认 B"。

**配对码与认领码是同一个字符串**:用户只有一个六格输入框的心智,两条链路各发一个码
只会让人念错。

`POST /api/pairing/code` 保留为 **410 `E_ENDPOINT_GONE`** 而不是删掉:404 会让人以为
路径拼错或版本不对,410 明确说"它被有意移除了,码从哪来"。

## 客户端

`pairing_api.issueCode` 改为**幂等只读**:返回单例里的当前码,不发网络。别把它改成
"每次调用都换一个码" —— 换了却不同时重发 hello,屏幕就会显示一个服务端从未登记过的
值,用户念它必然失败,而且看起来像服务端坏了。

## 顺带补上 M4′ 在客户端的缺口

上一轮 M4′ 只做了服务端:`grep claim_code client/lib` 命中 **0**。也就是服务端能力建好
了、测试全绿,而**真实客户端从不上报码** —— 生产上认领只能走 `isNew` 路径。同一个
"必须有真实产生方"的坑在 M5 里刚踩过一次(表恒空)。本次 `link.dart` 的 hello 已带
`claim_code`,两条链路同时有了产生方。

## ⚠️ 一个比原洞更大的发现:84 项配对回归网跑的是 9 天前的镜像

`tests/pairing-security-test.js` 默认打 `http://127.0.0.1:4000`,那是 docker-compose
的实例。实测 `pmon-business-1` / `pmon-gateway-1` 镜像构建于 **2026-10-30**,而本轮
改动在 **2026-11-08** —— 早 9 天。所以那些"84 项全绿"只证明旧镜像行为没变,对仓库里的
代码一个字都没说。

所以本套件(`pairing-code-origin-test.js`)**自起源码实例**(business + gateway,
随机端口,源码直跑),并在 `run-all.sh` 里那一段加了大字警告:在其余套件改成自起
之前,**不要把它们的绿当作本仓代码的回归证据**。

## 测试

`tests/pairing-code-origin-test.js` **16 项全绿**,已接入 `run-all.sh`。16 条里 13 条是
"拿不到"型(410 / 无码返回 / 猜码被拒 / 跨设备被拒 / 断连失效),3 条是"该拿到的人
拿得到"(hello 登记、verify 成功、一次性消费)—— 只验前者的话,一个把配对整体做死的
实现也能全绿。

客户端:`pairing_card_test` 6/6、全量 **595 通过 / 2 失败**(两条仍是 `libpmon_core.dylib`
未编译)。两条断言按行为变更改写而不是删除:
- "发码失败不静默" → 改为"**一次请求都不发**"(失败路径已不存在,但新风险更大:
  任何一次发出的取码请求都意味着码又能被第三方索取);
- "服务端返回 123456" → 改为"卡片显示的码必须与上报给服务端的是同一个"。

第一次跑本套件时 E 段红了两条,原因是我自己的夹具没关掉前一条连接 —— 网关判定
"这台设备还有别的活连接",正确行为就是不删码。已修,并在测试里写清这个坑。

回归:认领 57/0、埋点 29/0、权限 60/0、schema 116/0。
2026-11-08 19:57:57 +08:00
PMon Dev e7481a43d9 feat(ui): 墨观 InkWatch 大前端设计系统落地(跨端令牌 + 深色皮肤 + 管理台/Flutter 同步)
将单端 Flutter 浅色令牌升级为覆盖 Web 桌面 / 移动 PWA / macOS 桌面 /
数据大屏的统一 --iw-* 令牌层,并落地到管理台与 Flutter 客户端。

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

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

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

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

```
shared/events.js                     → 不存在
deploy/migrate.sql  CREATE TABLE events → 0 命中
server/ 下 "api/events"               → 0 命中
```

所以在这之前任何"事件名单差集为空"的断言都是**恒绿的空断言**:文件不存在 → 读到空
集合 → 差集为空,而"文件不存在"与"文件里没有多余事件"在输出上是同一个东西:空。

## 服务端

- `shared/events.js`:5 个名字的**唯一机器副本**(权威在 SPEC §6.1.1)。净化做的是
  **形状白名单**而不是字段名白名单 —— SPEC 只锁了事件名、没定义逐事件 props,此时
  在这里发明一套字段名等于实现方单方面定契约,然后让客户端照着写。形状约束
  (键名 `^[a-z][a-z0-9_]{0,31}$` / 值只接受标量 / ≤12 键 / ≤2KB / 敏感键名直接丢)
  能挡住"埋点变成第二张用户表"这条路上最现实的风险,且不假装知道产品要什么。
- `POST /api/events`:未知名与非法条目**丢弃并计 dropped**(AC-20,不是 400)、
  超 20 条只受理前 20(AC-21)、**写库失败仍 202**(AC-22)。认证可选但
  **带废令牌一律 401**(降级成匿名等于"发个废令牌就关认证");
  `body.account_id` 自报**被忽略** —— 否则谁都能往别人账号的漏斗里灌数,而漏斗是
  拿来算率的,被灌了就没有意义。
- 管理端 `/api/admin/events/summary`:只出按事件名的计数与首末时间。它的判据不在
  "能查出来",而在**查不出来** —— 这个端点没有任何办法取回某一条事件的内容。

## 客户端:必须有真实产生方,否则这张表永远是空的

这是本轮最容易"看起来交付了"的地方:端点全绿、表建好了,而**没有任何上报** →
表恒空、漏斗没有分子分母,而所有测试照样通过。**"以空为通过"的形态里这一种最难发现,
因为每一层单独看都对。**

`client/lib/host/event_reporter.dart` + `main.dart` 两个真实触发点(`app_open` 在配置
就绪、首帧将出时;`first_frame_shown` 用 `addPostFrameCallback`)。三条纪律:
不阻断业务(失败吞掉)、不重试(补报时序已经错了)、有界(丢最旧的)。

`app_open` **不能**放在 `main()` 最前面 —— 那时还不知道业务地址(`businessUrl` 来自
用户配置)。所以它落在"配置已就绪、首帧将出"这一刻;语义仍是"打开了应用",且这一批
能真的发出去。

## 建表只有一个落点

`events` 只写进 `deploy/migrate.sql`,`init.sql` 用 `\i /migrate.sql` 引用它 ——
这是本仓库既有约定(新表进 migrate,init 引用)。我最初给执行方的判据是"两个文件都
必须有"——那会把一份定义复制成两份,是错的,按约定改正。

`account_id` 用 `BIGINT REFERENCES users(id) ON DELETE SET NULL` 而不是 SPEC §6.1
原写的 `TEXT`:`users.id` 是 BIGSERIAL,TEXT 会让"按账号算率"每次都 `::bigint` 强转,
转不动时(LEGACY 自报串)只能静默留 NULL,而 NULL 与"匿名上报"在表里长得一模一样。
`ON DELETE SET NULL` 让注销后**保留计数、解除关联**(CASCADE 会让历史漏斗在注销那一刻
缩水,分母变了率就不可比)。

## 测试

`tests/events-test.js` **29 项全绿**,已接入 `run-all.sh`。关键一条是 **A7「表里真的
长出了行」**——没有它,前面每条都能在一个"什么都没落库"的实现上全绿。
AC-22 不是写注释:把 `events` 表临时改名造一次真实写失败,断言仍 202 **且**
`accepted=0` **且** 带 `write_failed` **且** 恢复后行数未变(只断 202 不够 ——
任何实现都会 202,因为 catch 住了)。

客户端 `event_reporter_test.dart` 6 项全绿,其中一条钉住"先抽干再发":发送期间
`track` 进来的新事件属于下一批,不被这一批的失败连坐(否则是"失败一次丢两批")。

回归:permissions 60/0、account 75/0、schema 116/0、admin-api 80/0、认领 57/0、
档位与历史 99/0;客户端全量 **595 通过 / 2 失败**,两条失败均为
`libpmon_core.dylib` 未编译(Rust 核心未构建,本机环境问题,与本次改动无关)。
2026-11-08 19:46:29 +08:00
jkooe b3c16fb1eb feat(plan,history): 档位展示名与升档流程、历史窗口按档位截断、保留期换配置源(M2/M3/M7)
## 三件事

**M2 升档流程**:`/api/accounts/me` 下发顶层 `plan_name`(客户端只认它、缺失即显示
「未知档位」,此前服务端零下发 → 界面被迫显示机器键 `free`);新增
`GET /api/plans`(**台账驱动投影**:`PLAN_WIRING_STATE` 里 `unwired` 的字段一律不出现在
响应里)、`POST /api/plans/requests`、`GET /api/plans/requests/mine`;管理端三条
审批路由。`PLAN_TABLE.audit` 加档位判定 → 免费档 403 `E_PLAN_REQUIRED`(矛盾 17 裁决甲),
判定排在 `authzDevice` **之后**:先问"你有没有权看这台设备",再问"你的档位够不够",
否则会把"别人的设备"答成"你档位不够"。

**M3 历史窗口**:`/history` 下发 `allowed_windows: [{key,label}]`(**对象数组**,四个中文
标签只有这一条传输路径;写成裸键等于标签无来源);按档位截断并如实标注
`truncated` / `requested_window`,`bucket_sec` 跟**生效**窗口走而不是请求窗口;
400 文案改为固定文案(不回声 `req.query.window`、不念全量白名单)。
`team.history_days` 90→30。

**M7 保留期**:`retention_days` 改为读实例配置,不再有字面量。

## 一个真错误:`ok:true` 但档位没变

审批对**已驳回**的申请原来也返回 `ok:true, idempotent:true` —— 管理端看到成功回执,
而档位一点没变,且没有任何东西会告诉操作者他做错了。**回执与事实不一致比违反字面更坏**:
它没有下一次修正机会。现在区分两种"非 pending":

- `approved → approve` 是**真幂等**(同一结果,200,不重复写审计);
- `rejected → approve` 与 `approved → reject` 是**非法状态转移**,409 `E_REQUEST_NOT_PENDING`。

路由层原来把 `!r.ok` 一律映射成 404("这条申请不存在"),现在 404 与 409 分开:
否则操作者会以为挑错了行,而真实原因是那条申请已被驳回。

## 审计事件名:改的是 AC,不是代码

SPEC AC-09 原写 `plan_request_approved`,而 `API-CONTRACT` §8 与 `openapi.yaml` 写
`admin_plan_request_approved`。裁定采用带前缀的一侧,**依据不是"另一份文档那么写"**,
而是代码里已有 **12 个** `admin_*` 审计事件(`admin_plan_changed` / `admin_kicked` /
`admin_export` …):前缀让"管理端动作"这个集合可以按前缀一次扫出来;靠逐个记名字,
这个集合只能靠枚举 + 记忆,而枚举必漏。SPEC 已同步订正。

留一句方法论在那两个常量旁边:**差集只告诉你两边不一样,不告诉你哪边对。**

## 顺手清掉一份档位清单副本

`admin.js` 的 `setPlan` 硬编码 `['free','pro','team']`,改为 `Object.keys(PLAN_TABLE)`。
副本的害处不是"写错了",而是**它能跑**:将来加一档时这份列表不跟着变,新档位永远改不到,
而且不报错,只是校验说"档位不合法"。

## 测试

新增 `tests/plan-history-tier-test.js` **99 项**,已接入 `run-all.sh`。含三集同集断言
(响应键 / 台账非 unwired 键 / OpenAPI 属性集)与 **5 组负向对照**(多返回 export /
漏 audit / 台账种假字段 / OpenAPI 少写 / 多写),负向对照必须能红才有意义。

回归:permissions 60/0、admin-api 80/0、user-lifecycle 68/0、schema 一致性 116/0、
认领在场证明 57/0、配对安全 84/0。
2026-11-08 19:31:20 +08:00
jkooe 0bc21f29af fix(redis): 多参数 del 在 node-redis v5+ 只删第一个键 —— 配对码反向索引本该失效时留着
## 这是一处既有代码的真 bug,不是新引入的

`verify()` 里码损坏时的清理写成 `del(key, pairingDeviceKey(deviceId))`。本类由
`server/business` 实例化,那里是 node-redis v5+ 语义:

    del('a','b')   → 只删 'a','b' 原样留着且**不报错**
    del(['a','b']) → 两个都删

实测(2026-11-08,`server/business/node_modules/redis`):
`del('k1','k2','k3')` 之后 `exists` 得到 `[0,1,1]`;数组形式得到 `[0,0,0]`。

## 为什么留着比删掉更坏

`pairing:device:<id>` 是"这台设备当前的码"的反向索引。它在本该失效时留着,就指向
一个已经删掉的码 —— 而 `issue()` 正是靠它找回旧码去删。留下的后果不是报错,而是
下一轮发码时删掉一个不存在的键、旧索引继续指向空气。

## 症状为什么难查

写入方(网关)用的是 node-redis **v4**,同一份调用在那边是"删全部"。所以这是
"同一行代码在两个大版本里语义不同、编译期不报、运行时也不报"的一类问题,
唯一症状是"清理没生效"。它不是本轮新写的代码带进来的,是随着 M4′ 的认领码
同类实现被对照着发现 —— 新代码里那处(`clearClaimFailures`)已在前一提交修掉。

回归:配对安全 84/0、认领在场证明 57/0、协议 34/0。
2026-11-08 19:00:45 +08:00
jkooe 29672c98b6 feat(claim): 认领要有「在场证明」—— 码随 hello 上报 + claim 校验 + 双桶限流 + 审计条件化(M4′)
## 修的是什么

改之前 `gateway.js` 把 `msg.payload.owner_id || null` 直接交给 `registerDevice`,
而 payload 是攻击者可任意填的 —— **任何未登录的人**发一条 `agent:hello` 自报
owner_id 就能凭空占有一台设备,连 claim 都不用走。这比认领洞更浅:认领洞还要登录。

## 四件事(顺序有意义)

1. **前提**:`registerDevice` 的 owner 实参改成**字面量 null**。不改这行,后面三件
   都只是摩擦。归属只能由已认证的 claim 建立。
2. **码**:设备端本地生成 6 位数字,随 `agent:hello` 的 `payload.claim_code` 上报,
   网关写 Redis(`claimcode:<id>`,TTL 300s,**断连即删** —— 码的语义是"设备此刻
   在这条连接上活着",TTL 只是崩溃兜底)。不给码单开 HTTP 端点:端点本身没有设备
   凭据,要求 device_token 会成环(令牌只发已归属设备),不要求则谁都能替任意
   device_id 发码 —— 那是自证,不是在场证明。
3. **验**:claim 对**已存在**的设备行必须有码且匹配;失败只有**一个** error 码一条
   文案(缺失/不匹配/过期/次数用尽四态合并,细分等于告诉攻击者爆破进度)。
   验码排在配额与归属之前:排在后面会泄露"这台设备有没有主人"。
4. **审计条件化**:`auditDeviceClaimed` 用 `INSERT … SELECT … WHERE EXISTS
   (… owner_id IS NOT NULL)`。原先是无条件 INSERT —— 会把没发生过的认领记成已认领,
   方向是以假为真,比漏记更坏且事后无法从表里分辨。

## 顺手修掉的一个真 bug:多参数 `del` 在 node-redis v5+ 只删第一个键

`clearClaimFailures` 写成 `del(k1,k2,k3,k4)`,实测只删掉 k1,其余**原样留着且不报错**
(网关用 v4,同一写法在那边是"删全部")。症状是"清零没生效":用户在一次成功认领后
仍被上一次的冷却拦住。实测证据:

    del('a','b','c')   → after [0,1,1]
    del(['a','b','c']) → after [0,0,0]

`tests/claim-proof-test.js` 的 B4 段以 `[429,429,…]` 抓到它。同一写法在
`shared/pairing.js` 的 verify 分支里还有一处(**既有代码**,单独一提交修)。

## 码格式不另写一份

`\d{6}` 与位宽改用 `shared/pairing.js` 的 `PAIRING_CODE_RE` / `PAIRING_CODE_LENGTH`。
本轮新代码一度在 business 里硬编码正则,被 `pairing-security-test` 的既有断言当场抓到
(那条断言本来就是为此存在的)。

## 测试

`tests/claim-proof-test.js` 57 项全绿,已接入 `run-all.sh`。断言的是**结果状态**
(查库 / 查 Redis)而不是"请求返回成功":旧实现里"静默忽略"与"静默接受"对客户端
都是成功的,只有查状态才分得开。每条"空集 / 恒为 NULL"的断言都配了必须变红的正控制
(claim 后必须看到非 NULL / 植入一条有主无审计的行必须数出 1 / 扫描器指向旧写法必须命中)。

套件默认连**独占 Redis db 14** 并在收尾 FLUSHDB:失败计数键名不含随机后缀,
不隔离会导致"连跑第二次开局就在冷却中"(实测第二次 41 通过 / 16 失败)。

回归:配对安全 84/0、协议 34/0、权限矩阵 53/0、schema 一致性 116/0、认领 57/0(连跑两次一致)。
2026-11-08 19:00:37 +08:00
jkooe 3cc67d3725 fix(test): 符号护栏改三态词法扫描 —— 补上 4 个哑缺口
原判据是「按行数引号奇偶、只扫字面量内」,实测 4 种写法会**静默漏检**
(不是误报,是漏报 —— 不报错、不红、不留痕,只会得到一条绿):
· ''' 跨行块里单独占一行的符号(逐行扫描看不见,而长文案必然跨行)
· Text("…'⚠'…") 双引号外套单引号
· Text('…"⚠"…') 单引号外套双引号
· Text('${x ? '⚠' : ''}') 插值内再嵌字符串
根因:引号奇偶表达不了「两种引号类型 + 跨行状态」。一个奇偶位装不下两种状态。

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

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

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

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

本护栏同时量两把尺子:基字符与变体选择符;判据只认字符串字面量内的码位
(注释里的 ⚠️ 合法,本仓库有 60+ 处这种提醒,不设豁免清单)。
正控制专测「只删了字形、VS16 留在原地」这一种;负控制测注释与 URL 里的 // 不误判。
2026-11-08 18:31:23 +08:00
jkooe 184f566fe3 fix(client): 状态符号改 Material Icons + 配额卡不再泄漏档位机器键(M6)
一、6 处 Unicode 状态符号清零
· link.dart 日志前缀改 [WARN](纯 ASCII,可 grep、跨终端不乱码)
· transfer_card 3 处、viewer_page 1 处用户可见符号改 Material Icons(size 16)
  ✓ → Icons.check_circle,⚠ → Icons.warning_amber,全仓语义与图标一一对应
· EinkBoard.diagnostics 由 (String, String) 扩为 (String, String, IconData?):
  图标单列槽位,而不是把符号拼回文本("走内网"的判定仍只在 _lanSummary 算一次)

二、配额卡档位机器键泄漏(用户可见 P0)
原来渲染「… · 档位 free · 我的设备 2/3 台」,free/pro/team 是机器键。
拆成两行:
· 我的设备 {n}/{m} 台(达上限加「 · 已达上限」),任一数字缺失则整行不渲染
· {plan_name} · 查看三档权益,服务端没给就是「未知档位」
不回落机器键、不造第四个词、不按配额反推档位(free_device_limit 支持实例级覆盖)。

三、让"缺失"可表达
· deviceQuota / ownedDeviceCount 改 int?,删掉解析层 `?? 3` / `?? 0`
  (否则配额永远是 3,上面"缺失则不渲染"根本不触发)
· plan 去默认值 'free';新增 planName(顶层 plan_name,不是 plan.name,无默认值)
· me() 里 `?? info.xxx` 保留并注明区别:那是"另一处也带了同名值",不是编造

四、防复发护栏 test/plan_text_guard_test.dart
扫 lib/ 三类形态:${…plan…} 插值、「· 档位」、Text( 之后**跨行**的 .plan。
每条都配正控制(喂真违规样本,断言确实命中)与负控制(planName 不误报)——
只验证"没抓到"不验证"抓得到",等于恒绿断言。
2026-11-08 18:25:11 +08:00
jkooe 6c402702df feat(permissions): 档位字段接线台账 —— 防「装饰位」的第二份真相
PLAN_TABLE 的每个字段都是卖点承诺,但 export / audit 两个字段活了
一个版本周期都没接线:export 连端点都没有,audit 只按角色判。
它们不报错、不红、没有任何信号 —— 因为从来没人做过「字段有没有消费者」
这个差集。

本提交加 PLAN_WIRING_STATE 台账 + 双向差集断言:
每个字段要么有消费者(cites 指向的字符串必须在源码里真实存在),
要么登记为未接线并写明裁定出处。加新字段不登记即红。

同时修准一处注释性谎言:permissions.js 文件头原文声称发配对码需要
device_token 作为在场证明,而实测 POST /api/pairing/code 不验任何设备
凭据。注释改成如实描述,并注明 M4′ 只修认领码、配对码同款洞本期不修。
注释性谎言比代码 bug 更难发现 —— 它会让下一个读代码的人停止审查这条链路。

对应:矛盾 17 / PRD §5.6.1 / OPEN-DECISIONS 已裁定表白名单会通胀,定义册不会
2026-11-08 17:56:10 +08:00
jkooe 0f57186b46 merge: 收编 main(-s ours)—— eink 为唯一主线
main 自合并基点 723852e 起 5 个提交、11 个文件(均在 client/),
与 eink 的对应实现是平行重做,且全部被 eink 逐文件超越:
- client/lib/ui/viewer_page.dart   eink 2229 行 / main 1517 行
- client/lib/host/viewer.dart      eink 1719 行 / main 1436 行
- client/lib/main.dart             eink 1559 行 / main 1368 行
- client/lib/host/lan_service.dart eink 483 行 / main 687 行(main 把 LAN 逻辑并进单文件,eink 拆到 lib/link/** 六文件)
- client/lib/ui/transfer_card.dart eink 668 行 / main 525 行

main 唯一独占的资产是 8 个 docs/ 文件(含 V4.0 方案 1038 行),
已抢救并将在下一提交落到 docs/legacy-v4/。

采用 -s ours 而非逐文件 cherry-pick:目的是让 main 成为祖先,
此后任何分支从 main 拉出都不会再撞上这 11 个文件的假冲突。
逐文件复核记录见 docs/decisions/ADR-005.md。
2026-11-08 17:29:52 +08:00
PMon Dev 4cca14d1b9 feat(mail): 零依赖 SMTP 传输层,QQ / 163 可直接用
上一轮把 mail_mode 接通了,但 smtp 分支是 `return delivered:false` ——
也就是说生产上"验证邮箱 / 找回口令"等于不可用。这轮把它补成真的。

## 为什么自己写,不装 nodemailer

需要的只有 EHLO / STARTTLS / AUTH / MAIL / RCPT / DATA,是 RFC 5321 里最稳定的
一层;而为它引入一棵依赖树,等于把**后端进程的出网权限**交给一堆第三方代码,
而这个服务处理的是口令与重置令牌。零依赖(net/tls/crypto)约 600 行,可控。

## 三条硬约束

1. 失败必须说出来:任何一步不是预期码都抛 SmtpError,带 reason / stage /
   permanent(4xx 可重试、5xx 不可)。绝不"看起来发了"。
2. 口令不许明文上网:配了 SMTP_USER/PASS 而服务器不给 STARTTLS → 直接拒发
   (starttls_unavailable),不降级成明文 AUTH。
3. 口令不进异常/日志:错误消息只由「阶段 + 服务器回复」拼成,AUTH 命令行
   (含 base64 口令)永不入日志。测试里有专门一条断言异常不含口令。

## 协议细节里真会咬人的

- 多行回复(250- / 250 ):把半截当完整 → 后续命令与残行错位、莫名 500
- STARTTLS 后必须重新 EHLO:服务商故意只在密文通道公布 AUTH
- DATA 点填充:正文里独占一行的 `.` 会被当结束标记,**报文被截断且服务器回 250**
- 编码:纯 ASCII 走 7bit(可读),非 ASCII 走 base64(不赌 8BITMIME)
- 头折行:200 字中文主题 base64 后 800 字符,会顶到报文行上限
- 头注入:主题里一个 CRLF 就能凭空多出 Bcc:
- 交出 socket 给 TLS 后仍要兜 error 监听,否则未捕获异常带走进程

## QQ / 163 的三处不一样(PROVIDERS 表,只对识别到的主机生效)

- 465 是**隐式 TLS**,不是 587;未显式配置按预设取 465+secure
- **发件人必须等于登录账号**,否则 553 "Mail from must equal authorized user"。
  mail_from 是独立配置项,极易填成 noreply@example.com → 现在在**连之前**拦住
  (smtp_from_mismatch),文案直接说该改成哪个值
- 口令是**授权码**不是登录密码;535 不含这条信息 → 认证失败时追加提示
  (腾讯企业邮不追加,它用真密码,加了就是误导)
- 顺带堵纯配置矛盾:465 + SMTP_SECURE=0 会连上后一直等不到问候、15 秒超时,
  现在 normalize 阶段直接报"去掉 SMTP_SECURE 或改 587"

## 真实服务器只读探测(不带任何凭据)

smtp.qq.com:465 → 350ms,AUTH LOGIN PLAIN XOAUTH XOAUTH2,证书过系统 CA
smtp.163.com:465 → 314ms,AUTH LOGIN PLAIN XOAUTH2
说明 465 可达、多行解析在真实回复上正确、两家都公布 AUTH LOGIN。
真实投递需账号本人的授权码,用 tests/smtp-live-test.js(未配凭据时跳过)。

## 测试

- tests/smtp-test.js(92 项):自带假 SMTP 服务器,覆盖 STARTTLS 与 465 隐式 TLS,
  证书用 openssl 现场生成(含 IP SAN)。断言落在信封、报文头、编码、点填充,
  以及 **AUTH 是在明文还是密文通道上发的**
- 五组变异全部转红:去掉 From 校验 / 去掉 465 守卫 / dotStuff 恒等 /
  去掉服务商预设 / 失败也返回 delivered:true
- 教训:断言"连之前就拦住"的用例不能用 sendMail —— 防线一旦被删,测试会真的
  去连 smtp.qq.com 试 AUTH(收到 535),把离线断言变成依赖外网。改用纯函数
  normalize() 后同样抓得到变异,且永不联网
- tests/smtp-live-test.js:有凭据就真发一封(默认发给账号自己),没凭据 [skip]

## 其它

- 启动自检把"用哪个中继、什么形态、有没有明显配错"打在日志里
- 向导页与设置页去掉"smtp 未接入"的旧文案(现在真的会发)
- .env.example / 两份 compose 补 SMTP_*;示例邮箱用占位符,不写成像真人地址
- bash tests/run-all.sh → 39 个套件全绿
2026-10-30 16:25:33 +08:00
PMon Dev f03bf7e19a fix(setup): 把实例配置真正接上线,并补上「最后管理员」漏掉的两个入口
上一轮交付了实例配置表、向导与管理端,但有五个字段只落库没人读 ——
界面上能改、改完不报错、实际什么都不发生。本轮把它们全部接上,
并修掉一处更危险的同源缺陷。

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

「不许取消最后一个可用管理员」只守了降权。封禁与自助注销都没守:
- 封禁唯一管理员后,available() 只查 `is_admin`(不看 disabled_at /
  deleted_at)→ 管理端继续挂登录页,却没人能登进去;没设 ADMIN_TOKEN
  就只能改库救。
- 管理员注销自己同理:账号进冷静期,is_admin 还在。

修法:把「可用管理员」口径收敛到 admin-queries.js 一处
(ACTIVE_ADMIN_WHERE / hasActiveAdmin / isLastActiveAdmin),
available()、降权、封禁、注销四个使用点全部走它。

## 五个字段接线

- mailer:从模块级 `process.env.MAIL_DELIVERY`(启动定死)改为宿主注入
  getter,每次发送重新解析 → 管理端改完立刻生效。
  顺带消除双名:ENV_MAP 写 MAIL_MODE、mailer 读 MAIL_DELIVERY。旧名保留
  (仅当 MAIL_MODE 未设时生效并警告),否则老部署会静默退回 console。
- 库默认 mail_mode 从"实际行为是 outbox"纠正为 console:outbox 会把重置/
  验证令牌明文写进服务器磁盘,这个便利不该是默认值。测试改为显式声明。
- public_url / instance_name:邮件落款(唯一真实消费点),解决"收件人分不清
  是哪个自建实例发的"。
- free_device_limit:**最严重的一个**。界面写"免费 3 台",配额却一直取
  PLAN_TABLE 常量,改成 10 也不生效。新增 planFor() 只覆盖 free 档
  (pro/team 是产品定义,不接受运维改),规则只在 permissions.js 一处。
- audit_retention_days:新增 shared/audit-prune.js,每 6 小时按批清理
  (ctid 分页 5000/批,make_interval 参数化),留空=永久,删完写一条
  audit_pruned 留痕。留痕本身也会被同样对待,符合规则。

## 顺手挖出的三处漂移

- idx_audit_created 只在 init.sql 有、migrate.sql 没有(按 created_at 删除
  会全表扫)。已补。
- 两份 compose 透传不一致:RELAY_MAX_BYTES 单容器有、四容器无;
  FREE_DEVICE_LIMIT 两边都没有(.env 设了进不了容器)。已补齐。
- user-lifecycle-test 断言配额 3,却依赖开发库里当时的值(被上一轮冒烟改成 5
  → 假红)。已显式声明 FREE_DEVICE_LIMIT=3,并把开发库复位。

## 新增守门:tests/instance-config-wiring-test.js(56 项,纯静态)

每个 instance_config 字段必须在**真正的代码**里被读一次(admin/assets 与
setup/ 不算 —— 出现字段名是展示/提交,不是消费);每个 env 名必须在
.env.example 与两份编排里;扫描器自己也要自证(编个不存在的字段名必须报红)。
它第一次跑就抓到 free_device_limit 的 0 消费方 —— 立刻证明不是空转。

## 界面如实标注

smtp 从未实现,向导页原写"smtp(真实发信,需 .env 配 SMTP)"是谎言,改为
"暂未接入传输层(选了也发不出信)";设置页对 outbox 标"令牌明文落盘"、
对 smtp 标"传输层未接入"。

## 验证

- setup-test:97 → 126 项(新增三入口、发信真实行为、保留期清理与留痕)
- 变异测试 4 组全部转红:封禁闸、注销闸、保留期留空照删、mailer 忽略库配置
- bash tests/run-all.sh → 37 个套件全绿
2026-10-30 15:31:53 +08:00
PMon Dev a825fc366e feat(setup): 实例初始化配置与 Web 安装向导
新装一台实例"归谁管"以前只能靠 .env 里手敲 ADMIN_TOKEN:运维要记住随机串、
没有管理员概念、改任何业务配置都得改文件再重启。本轮把这件事收敛成
「浏览器里走一遍向导 + 配置落库 + 管理端在线可改」。

数据库(deploy/migrate.sql,两条路径共用)
- instance_config 单行表(CHECK id = 1):实例名、对外地址、自助注册、
  强制登录、设备令牌、配额、中继上限、邮件、审计保留期
- users.is_admin 列 + 部分索引
- bootstrap_state 三态 pending / adopted / ready,由「库里有没有用户」裁决 ——
  存量库升级不能断服,故不是布尔

向导(server/business/setup.js + setup/index.html)
四道护栏是前置条件而非加固项:
  1. INSTANCE_SETUP_TOKEN 未设或 <32 字符 → 404(不是 401,不对外宣布它存在)
  2. 只认 Authorization: Bearer;失败闸门在令牌校验**之前**(否则猜对就绕过限流)
  3. 只能成功一次,由数据库裁决:UPDATE ... WHERE bootstrap_state <> 'ready'
  4. 完成后永久 404,判据在库里(内存变量会让重启后复活)

鉴权(server/business/admin.js + admin-queries)
- 管理端双通道:ADMIN_TOKEN(应急/自动化)或库里 is_admin 的会话;
  审计记 operator,能区分"用管理员账号改的"和"用令牌改的"
- 新增 /api/admin/admins、账号提权/降权(不许取消最后一个可用管理员 → 409)
- 新增 /api/admin/instance-config:被 env 锁定的字段拒绝修改(409 E_ENV_LOCKED),
  不静默成功

配置层次
- 库是真相源,env 是显式覆盖层;GET 返回每个字段的来源(db/env/default)
- 密钥类配置一律不入库(库会被备份/导出/管理端读出)
- 改完同进程立刻生效(onChanged),不等 3 秒轮询

网关(server/gateway)
- **修复半个开关**:业务端的 REQUIRE_AUTH / REQUIRE_DEVICE_TOKEN 搬进库后,
  网关若仍只读 env,管理端点开"强制登录"只会关住业务端,订阅通道照样放行匿名。
  改由 index.js 每 3 秒把「库 + env 覆盖」推入 gateway.setSwitches(),
  网关自己不碰数据库(热路径只读内存字段);/health 报生效值而非 env 字面值
- 单测用假 this 直调原型方法时 this.sw 为空 → 用冻结单例兜底,避免
  TypeError 被误报成"会话索引坏了"

测试
- 新增 tests/setup-test.js(97 项):四道闸、参数矩阵、成功路径、
  并发(用行锁把两个请求压进临界区,让"输家 409"成为必然而非运气)、
  存量库升级(只提权不改口令)
- 它自带 4 个临时库并跑真实 init.sql/migrate.sql —— 顺带把"全新库首次部署"
  这条历史上出过建表顺序坑的路径也覆盖了
- dockerfile-coverage:开关扫描补上声明式 ENV_MAP(动态下标扫不到,
  覆盖曾从 5 条悄悄掉到 2 条),并按字段名判定真正的消费方

验证:bash tests/run-all.sh → 36 个套件全部通过;六组变异测试逐一证明
关键护栏"删掉即红"。README 增「首次部署:先过一遍初始化向导」。
2026-10-30 14:58:00 +08:00
PMon Dev 19f8ee181a test: 配额默认开启后三套测试的车没上(红的是前提,不是功能)
ENFORCE_DEVICE_QUOTA 默认翻成 1 之后,account/device-merge/auth-mode 三套
清一色报 E_QUOTA_EXCEEDED。根因不是配额坏了:这三套的造数方式是"注册 →
认领 2~8 台设备",而「未验证邮箱只能绑 1 台」比档位上限更早生效。

按"谁能表达意图"分工,没有改产品规则:
- account / device-merge:造数前 markEmailVerified(归并另抬到 team 档,它要
  造 6~8 台)。配额会把断言挡在更前面 —— 本该 409 的"认领别人的设备"先返回 402。
- auth-mode [D]:对照组原拿宿主机 :4000 顶替,隐含"它一定没开配额"。改为自己
  拉一个 ENFORCE_DEVICE_QUOTA=0 的 :4012,并断言它对 /me 报 quota_enforced=false。

顺带校正两处标签漂移(结构性守卫 48→50、镜像覆盖性 45→49),重算全量为
35 套件 / 1965 项 / 0 失败,README 同步。

全量实跑:35 通过 / 0 失败;隐私扫描通过。
2026-10-30 13:42:46 +08:00
PMon Dev 269a1d32b1 fix(admin): 在线数改 SCAN 计数、审计明细列不再被卡片硬裁
三处都是实测截图发现的,不是"看着可能有问题":

1. 总览「当前在线」用 LIMIT 500 采样算法。库里 1.3 万台设备时取到的是物理
   顺序最前一批,真正在线的可能一台都不在里面,页面显示"0",而 0 会被读成
   确定结论(旁边"采样 500 台"的小字没人看)。改为 SCAN 数 `presence:*`
   键个数,与设备表规模无关;Redis 不可用仍返回 null 而非 0。

2. 审计「明细」列的 JSON 没有断行机会(无空格),`white-space: normal`
   找不到断点 → 整串作为不可断词把表格撑宽,再被 `.card{overflow:hidden}`
   从右边缘硬裁,既无省略号也看不出被截了。改为包一层带 max-width 的 div
   (`table-layout:auto` 下 td 的 max-width 不可靠)+ 省略号 + title 悬停全文。

3. 存储表的行数把估算值和精确 count 混在一列,分不清真假。估算值灰化,
   精确值带「精确」徽标,表头去掉"(估算)"。

顺带修 countPresence 插进来时把 mountAdmin 的 JSDoc 顶开了的注释错位。
2026-10-30 13:29:13 +08:00
PMon Dev 1a10e4a04a feat(管理端): 从零补一条运营者通路(/admin/ + /api/admin/*)
此前**没有任何运营端**:38 个端点全是用户视角(每条都带 WHERE owner_id=我),
看全站账号靠 psql、改档位靠手写 UPDATE、看在线上靠 docker logs。
运营动作既没有界面,也没有留痕。

身份模型
- ADMIN_TOKEN 环境变量(不加 users.is_admin:新库上"谁来设第一个管理员"
  要么手改 SQL,要么留一个"首次访问即管理员"的接口 = 经典接管漏洞)
- ADMIN_NAME 只作审计标签,不参与鉴权

四条失败关闭
- 没设口令 → 404(401 等于宣布"这里有管理台")
- 短于 32 字符 → 同样 404(ADMIN_TOKEN=123456 比没有更危险)
- 口令错 → 401,不回显任何片段
- 口令走 ?token= → 401(它会进 nginx 日志、浏览器历史、Referer)
- 比较用定长摘要 + timingSafeEqual;10 次/分钟/IP → 429,锁定期间正确口令同样被挡

交付
- server/business/admin.js:18 个端点 + CSV 导出(BOM + 公式注入防护)
- server/shared/admin-queries.js:运营者查询(分页/统计/存储/降采样)
- server/business/admin/:5 页 + assets,零依赖无 CDN,手写 SVG 图表
- server/shared/presence.js:在线判定抽出来,业务端与管理端共用(不写第二份)
- 写动作五个(改档位/封禁/强制验证/踢会话/解除归属),每个都落 admin_* 审计;
  CSV 导出同样记审计(带走的是全站邮箱)

三个真缺陷
- GET /api/audit **匿名可读全站审计**(实测 HTTP 200)→ 删除,改 /api/admin/audit
- 存储统计报错 99.9%:TimescaleDB 数据在 chunk 里,父表 32KB vs 实际 2802MB;
  超表 reltuples 也不可信(2802MB 报 0 行)→ 探扩展后累加 chunk + 精确 count(3s 上限)
- COPY server/business/*.js 不含子目录 → admin/ 漏拷时"镜像能建、容器健康、
  /api/admin/* 全通,只有 /admin/ 404";覆盖性测试按 require 图查,静态文件是盲区
  → 新增「静态资源目录必须被 COPY」段,并修正 expandGlob 对目录的处理

护栏
- tests/admin-api-test.js 80 项(9 个隔离实例,每个口令形态独占一个进程)
- dockerfile-coverage 45 → 49;全量 35 套件 / 1901 项 / 0 失败 / 0 跳过
- 变异自证:短口令当真→红;未配置改 401→红;CSV 去防护→红(直接打出裸公式行);
  删 COPY 那行→7 个文件点名报出

顺带修
- .env.example 的 ENFORCE_DEVICE_QUOTA 原写 0,与"代码默认开启"相反 ——
  照模板部署会把配额静默关掉,改 1
2026-10-30 13:23:10 +08:00
PMon Dev c92d870c8e feat(用户机制): 角色/邀请/自救/配额全部落地(13 项缺口关闭)
此前账号体系只到"你是谁",这一轮补"你能做什么"与"账号出事怎么办":
忘了口令等于报废、授权只能发配对码(等于公开招租)、配额只报数不拦。

服务端
- shared/permissions.js:13 能力 × 3 角色 + PLAN_TABLE + authorize(),
  canViewDevice 降为 authorize(...,'VIEW_SNAPSHOT') 薄封装(单一真相源)
- auth_tokens 一表统管 verify/reset/invite,只存 sha256,原子消费
- 新端点:会话列表/踢人、改密、验证与找回、注销(30 天冷静期)、
  成员 CRUD + 角色、邀请接受、转移归属、解除归属、审计
- 配额 ENFORCE_DEVICE_QUOTA 改默认开启(唯一破坏性变更);用尽返回 402;
  未验证邮箱收紧到 1 台
- server/shared/mailer.js:outbox/console/smtp(smtp 未实现,如实回 delivered:false);
  令牌绝不进 HTTP 响应

客户端
- role_catalog.dart(Dart 侧镜像,跨语言一致性由测试锁定)
- device_members_card.dart + account_card 增验证状态/改密/注销/找回
- ViewerDevice.role 透传;成员卡只对"当前查看的那台 + 已登录 + 有角色"出现

护栏
- permissions-test 29 / user-lifecycle-test 66 / schema-consistency 第 14·15 段
- account_security_test 13 / viewer_page_test ⑩ 3
- 全量回归 34 套件 / 1838 项 / 0 失败

顺带修(同一类"环境冒充缺陷")
- user-lifecycle-test 改为自起临时实例(随机端口 + 自己的 outbox + 放宽限流)
- persistence-restart 端口不再写死 3100(被同机另一项目占用→假红)
- auth-mode-test 走真实邮箱验证,配额期待从 403 改为 402
- run-all/README 的套件计数与实跑对齐(Dart 416→598,标签长期漂移)
2026-10-30 12:52:51 +08:00
PMon Dev 930c35957b feat(installer): Windows 上「安装成功但不会立刻启动」必须说出来(平台实话)
## 为什么必须补这句(team-lead 拍板采纳)
三个平台的 `install_commands`(真相源 `rust-core/src/keepalive.rs`)语义不同:

| 平台 | 安装命令 | 装完是否立即在跑 |
|---|---|---|
| macOS | `launchctl bootstrap` + plist `RunAtLoad=true` | 是 |
| Linux | `systemctl --user enable --now`(`--now`) | 是 |
| Windows | `reg add`(HKCU Run 项) + `schtasks /SC ONLOGON` | **否,只在下次登录时起** |

于是 Windows 上「install 返回 0」**只承诺"已注册",从不承诺"已在跑"**。
可上游(GUI 常驻卡)曾把它读成"已启动",进而停掉自己的采集 ——
两个都没在采、界面还很平静。这行字把「启动」从**承诺**降级成**事实描述**。

## 改动(`client/bin/pmon_install.dart`)
1. 新增 `launchSemanticsNoteFor(PmonPlatform)`(平台作参数的纯函数):
   · Windows → `仅在下次登录时启动`
   · 其它   → `安装命令本应已使进程立即启动,但本平台无法探测其是否在运行`
2. `install` **成功路径**打印 `说明:已注册 · <上面那句>`。
   位置刻意放在 `hardFailures` 判定**之后**:安装失败时不该说"已注册"。
   触发条件是"本平台有没有探针"(平台属性),与是否 `--no-start` 无关。
3. `status --json` 的 `autostart` 增 `note` 字段(探针可用时为 null;
   不可用时为该句)—— 因为 Windows 上这行字是**唯一**的状态说明,
   GUI 读的是 JSON,不能只留在人话输出里。人话 `status` 也打印同一句。

⚠️ **这是"平台 → 启动语义"知识的第二份**(第一份在 Rust 的 `install_commands`)。
不能改成嗅探命令字符串(本项目明令禁止判 `cmd.contains('bootout')` 那类)。
正解与 `stderr_log_path` 同款:让 `AutoStartPlan` 返回 `starts_immediately: bool`,
Dart 只读不算。**本轮未做**(要改 Rust 结构 + FFI 出口),已写进注释,
并集中在**这一个函数**里,不允许出现第三份。

## 测试
- 新增 `client/test/installer_launch_note_test.dart`(5 项):本机是 macOS,
  跑不到 Windows 分支,故把平台做成参数逐条断言 —— 与 `app_paths_test.dart` 同一手法。
  含反面对照(Windows 那句不得出现"已启动";macOS/Linux 那句不得出现"登录"),
  以及两条**形状护栏**:读 `keepalive.rs` 断言 Windows 分支确实有 `/SC ONLOGON`
  且没有 `systemctl`/`launchctl`、Linux 分支确实有 `enable --now`。
  (写这条时踩了一次"抓错对象":`Platform::Windows =>` 在 `as_str`/`autostart_mechanism`
  里也出现,第一版抓到显示名导致假红,已限定在 `plan_autostart` 内、
  边界同时认 `=> {` 与 `=> Ok(AutoStartPlan {`。)
- `installer-clean-e2e.js` 38 项(+1):JSON `autostart.note` **两条分支都断言** ——
  探针可用必须为 null(不给假说明),不可用时必须非空且含"启动"。
- 变异自证:临时让 `probeAgentProcess()` 恒返回 unavailable →
  `status --json` 得到 `probe_available=false` + `note='安装命令本应已使进程立即启动…'`,
  人话 `status` 出现 `说明: …`,e2e 走"非空"分支仍全绿;恢复后回到 null 分支。
  ("install 真正成功 + 探针不可用"这个组合在本机**无法构造**:`launchctl bootstrap`
  在此环境恒失败(I/O error 5),install 永远走不到成功分支 —— 如实说明,
  不假装验过。install 侧与人话/JSON 侧调用的是同一个函数,字符串由单测覆盖。)

## 顺带修掉一个我自己引入的偶发失败(必须修掉,不能留着)
`cli-exit-code-e2e.js` 原用 `127.0.0.1:3107` 当"打不通的网关",
但实测**8 次里有 2 次 3107 上真的有人在听**(relay 往返成功、`relay_check` 退出 0),
"打不通 ⇒ 退出码非零"这条被静默反转 —— 它先是在 run-all 里红了一次我才发现。
改为**端口 1**(特权端口,正常环境永不监听),并在注释里写明"别换成看起来空闲的端口"。
换端口后连跑 10 次 0 失败。

## 验证
- `dart analyze lib bin`:**No issues found!**
- `flutter test`:**582/0**(含本笔新增 5 项)
- `cargo test --release`:**61/0**
- `bash tests/run-all.sh`:**32 套件 31 通过 / 1 失败**(唯一红仍是隐私扫描,未碰)
- `node tests/installer-clean-e2e.js`:**38/0/1 跳过**
- `node tests/cli-exit-code-e2e.js`:**11/0**(连跑 10 次稳定)
- `guard-structure-test.js`:**44/0**

## 归属备案
`tests/run-all.sh` 里我把套件计数 37→38 的改动,被他人提交
`205e862` 一并带走了(同文件、`git add` 粒度拆不开,与我上次遇到的情况对称)。
内容已在 HEAD,无丢失;此处留痕避免日后考古困惑。
2026-10-28 00:17:50 +08:00
PMon Dev c806c26537 fix(测试): run-all 不再把「环境缺 cargo」伪装成「Rust 单测失败」
## 现象(2026-10-27 实测)

`$HOME/.cargo/bin` 不在 PATH 时(非登录 shell / CI / cron 都是这样):

```
tests/run-all.sh: line 52: cargo: command not found
 ◉ FAIL  Rust 核心单测(59 项)
 套件结果: 30 通过 / 2 失败
```

而单独 `cargo test --release` 是 **61/0**。也就是说脚本把**环境缺失**报成了一个
**测试失败**,汇总里那条红指向 Rust 代码 —— 排查方向被彻底带偏。这与之前修过的
"护栏假装在工作"是同一族:**不能让环境问题冒充被测对象的问题**。

## 改法(两处 + 一个计数)

1. 脚本开头主动补 PATH:`$HOME/.cargo/bin/cargo` 存在就 `export PATH`,
   否则退回 `command -v cargo`(允许自定义安装位置)
2. 仍找不到 → 新增 `skip()`,打印「⚠ 环境缺 cargo($HOME/.cargo/bin 不在 PATH?)
   ——这是环境问题,不是 Rust 代码的问题」,标 **◉ SKIP**,**不计入失败**
3. `skip()` 与 PASS/FAIL 三态分明:算 FAIL 会让人查被测对象,算 PASS 会把覆盖
   缺口藏起来 —— 两者都是说谎。汇总行同步变成 `N 通过 / M 失败 / K 跳过`

## 验证

| 场景 | 结果 |
|---|---|
| PATH 含 cargo | 31 通过 / 1 失败(唯一红=隐私扫描,已知)|
| **PATH 不含 cargo,但 `$HOME/.cargo/bin/cargo` 存在** | **自愈生效** → Rust 套件 PASS → 31 通过 / 1 失败(改前是 30/2 假红)|
| 两者都没有(模拟 CI 无 Rust 工具链) | `◉ SKIP` + 原因说明,**失败计数仍为 0**(脚本不因此判失败)|

`bash -n` 语法检查通过。第三种场景用假 HOME + 精简 PATH 单验过检测段与 `skip()`。
2026-10-28 00:12:52 +08:00
PMon Dev 26b939c239 test(常驻采集): 探针测试里的家目录改用占位名,别把合成路径写成隐私命中
`tests/privacy-scan.js` 把 `/Users/<非白名单名>` 判成「高置信个人数据」,而我
在正则转义那条用例里用了 `/Users/me/Library/pmon(v2.3)/...` —— 那是**合成路径**,但扫描器
按形状判定,它照样红。

改成 `/Users/me/Library/pmon(v2.3)/pmon-agent`:
- `/Users/me` 在扫描器白名单里(与 `resident_service_test` 等既有用例一致)
- `(` 和 `.` 挪到目录名上,转义断言(`\.` / `\(`)一个都没减弱

工作树命中从 12 条降到 11 条(余下 6 条是我此前几笔提交历史里的同一字符串,
2 条是既有的公网 IP,3 条是 commit-msg 里的既有内容)。**历史那 6 条只能靠
重写历史清掉,不在本笔**(会连带重写并行同事的提交,需 lead 拍)。

验证:`flutter test test/agent_probe_test.dart` → 15/0;`node tests/privacy-scan.js`
不再报 `client/test/agent_probe_test.dart`。
2026-10-28 00:06:58 +08:00
PMon Dev 4e3b4739d4 fix(常驻采集): 停本界面采集改为「只认在跑的正面证据」,避免 Windows 上双双停摆
## 改判:`r.ok` 不足以作为"已在跑"的判据

`install` 退出码 0 只承诺"**已注册**",三个平台语义不同(Rust `keepalive.rs` 事实):

| 平台 | 安装命令 | 装完立即在跑? |
|---|---|---|
| macOS | `launchctl bootstrap` + plist `RunAtLoad=true` | 是 |
| Linux | `systemctl --user enable --now` | 是(`--now`)|
| Windows | `reg add HKCU\...\Run` + `schtasks /SC ONLOGON` | **否,只在下次登录时起** |

旧逻辑"拿到 0 就停 GUI 采集"在 Windows 上是**必然**事故(不是概率):
停掉 GUI 采集 + 常驻要等下次登录 → 两个都没在采,而卡片还很平静。
这比双写更糟 —— 双写至少还有数据。

## 新判据

```dart
bool get _hasRunningEvidence =>
    _st.state == ResidentState.running && _st.probeAvailable != false;
```

- 有证据(`running` 且探针没说"我没查")→ 停 GUI 采集(防双写)
- 无证据 → **不停**,并在 `_note` 里说清:
  · 探针不可用(Windows 现状)→「已注册;本平台需重新登录或手动「开始」才会运行。本界面继续采集,不会断数据。」
  · 探针可用但没跑起来 →「安装已完成,但采集进程没有起来(原因见上)。本界面继续采集,不会断数据。」
- `probeAvailable == null`(老 CLI 无此字段)视同**有证据**:那种二进制报的
  `running` 是 `pgrep` 真查出来的,不能因为"没这个元数据"就否认证据

## 为什么这规则不放过双写

双写只可能发生在探针可用的平台(macOS/Linux)——那些平台恰好拿得到正面证据,
于是会停。Windows 拿不到证据,但它**压根不会有两个进程同时在跑**(安装命令里
没有 start 这一步)。**双写风险与"探针不可用"的交集为空**,故既不放过双写,
也不误停采集。上一轮的 A/B 是伪两难:把"所有平台"当成了一回事。

## 顺带:`_run` 的时序随之调整

以前是"先停 → 再刷新"(停不用等状态)。现在**必须先刷新再决定**,因为停的判据
就是刷新回来的状态。代价是:证据存在时,停采集比旧实现晚一次 CLI 往返
(新版 `build/pmon-install` 约 50ms);换来的是"没证据不乱停"。

## 验证

- `flutter test test/resident_card_test.dart` → **17/0**
- `dart analyze lib` → No issues found
- 变异自证(两次,均已恢复):
  · `_hasRunningEvidence` 短路成 `r.ok`(退回旧逻辑)→ 「探针不可用 → 不停」「探针可用但没起来 → 不停」两条 ★ 报红
  · `probeAvailable != false` 改成 `== true`(把"缺字段"当没证据)→ 「拿到在跑证据 → 停采集」报红
2026-10-27 23:54:46 +08:00
PMon Dev 3af5c592ff fix(常驻采集): 提示文案改指 build_cli.sh —— 原来的命令在 Dart 3.13 上必然失败
## 问题:我们把用户送到了一条死路

`resident_service.dart` 有三处**面向用户**的提示让用户敲
`dart compile exe bin/pmon_install.dart -o build/pmon-install`:

- 找不到 CLI 时(`status()` 的 detail)
- CLI 太旧、不认识 `--json` 时(`_statusOnce` 的 detail)
- 安装/卸载前找不到 CLI 时(`_action` 的 message)

而 Dart 3.13 起该命令遇到 build hooks(依赖链里的 `objective_c`)**直接失败**,
用户照做后看到的是 `'dart compile' does not support build hooks` ——
比没有提示更糟,因为**他会以为是自己环境坏了**。

## 改法:只指真相源,不抄命令

三处统一成:`client/scripts/build_cli.sh`(产物 `build/pmon-install`)。
构建入口的真相源是那个脚本(engineer-2 已把 README 与两个 bin 的注释/错误提示
一并改指它),这里不抄一份参数序列 —— 抄一份就多一处会漂移的副本。

## 加了一条守卫,防它再长回来

`⑧ 构建命令只许指向能跑通的那条`:
1. 源码守卫:`resident_service.dart` 里不许再出现那个精确形态
   (`dart compile exe bin/pmon_install.dart` —— 它只会出现在"让用户照着敲"
   的位置),且必须含 `client/scripts/build_cli.sh`
2. 行为断言:`status()` 在"找不到 CLI"时给出的 detail 必须含 `build_cli.sh`;
   装过 CLI 的机器跳过该用例(不假绿)

## 顺带确认:回落链不再被触发

新构建产物(`build/pmon-install`,6.2 MB,2026-10-27 23:46)实测**认识 `--json`**:

    候选: …/client/build/pmon-install        ← 第一候选直接答话
    候选: dart run …/bin/pmon_install.dart   ← 备用,未用到
    state=running pids=[1737]

`resolveCliCandidates()` 一个字符没改 —— 回落链从"今天的主路径"退回它本来的
身份(防御性代码),这正是不该把它写成主路径的理由。

## 验证

- `flutter test` → **576/0**、`dart analyze lib bin` → No issues found
- 真机 `status --json` → `state=running pids=[1737]`,三路径与 `serverUrl/deviceId` 解析正常

## 备案(未做)

`lib/` 里还有约 10 处文档注释用"能 `dart compile exe`"来解释"为什么这个模块
必须纯 Dart、不 import Flutter"。约束本身仍然成立(`dart build cli` 同样要求),
只是叫法过时。属文档口径整理,不在本笔。
2026-10-27 23:52:56 +08:00
PMon Dev d0d2d717a4 docs(installer): 把 log.path 未收敛的 TODO 补成可执行的正解形态
`statusSnapshot` 里 `/tmp/<label>.err.log` 是唯一还没收敛的路径(Rust 的
`macos_launchagent_plist` 把 `StandardErrorPath` 写成同一形状,两边各一份)。
原注释只说了"应该让 AutoStartPlan 返回它",没写清**改成什么样**,
后人读到仍不知道落点在哪。按 team-lead 要求补上具体形态:

  读 `AutoStartPlan.stderr_log_path`,**勿再在本地拼 `/tmp/<label>.err.log`**
  —— 与 `allow_failure` 同款:语义由 Rust 给,Dart 只消费。

仅注释改动,无行为变化。边界不变:需要改 Rust 数据结构 + FFI 出口,
不在"client 路径"这一轮范围内。

验证:`dart analyze bin` No issues found;`guard-structure-test.js` 44/0。
2026-10-27 23:51:32 +08:00
PMon Dev 5a909f14fd fix(构建): dart compile exe 已被 Dart 3.13 打断,构建方式收敛到 scripts/build_cli.sh 一处
## 根因(不是我们的代码,是 SDK 行为变了)
```
$ dart compile exe bin/pmon_install.dart
'dart compile' does not support build hooks, use 'dart build' instead.
Packages with build hooks: objective_c.
```
`objective_c` 是 `file_picker` 的传递依赖。于是 README 与历史脚本里的
`dart compile exe ...` **全部失效**,用户照抄必失败 —— 典型"文档在说谎"。

替代命令:`dart build cli -t bin/<x>.dart -o <dir>`,产物布局也变了:
`<dir>/bundle/bin/<x>` + `<dir>/bundle/lib/<libs>`(多一层 `bundle/`,多出动态库)。

## 1) "怎么构建"收敛到一处:`client/scripts/build_cli.sh`
- 默认构建 `pmon_agent` + `pmon_install`,可追加其它入口(`pmon_viewer` 等)。
- 权威产物是 **bundle**(exe 与 lib/ 必须一起用),同时产出**扁平副本**
  `build/pmon-agent` / `build/pmon-install`,并把 `bundle/lib/` 的动态库平铺到 `build/`。
- 构建命令不再出现在别处:`client/README.md:98`、`:765-766` 已改为
  `bash scripts/build_cli.sh`,根 `README.md:916` 的描述也改为指向脚本;
  `bin/pmon_agent.dart:13`、`bin/pmon_install.dart:6` 的注释同改,
  `pmon_install.dart` 找不到可执行文件时的提示也改为指向脚本。

## 2) 安装器落位要带运行期库
`_install` 新增 `_runtimeLibsNextTo()`:覆盖两种产物形态
- bundle:`<bundle>/bin/<exe>` + `<bundle>/lib/<libs>`
- 扁平:exe 与库同目录(脚本会把库平铺到 `build/`)
只按扩展名收 `.dylib/.so/.dll`,**不整目录拷贝**,并排除核心库(它单独落位,避免拷两遍)。

**依据(实测,不是猜)**:
- `otool -L build/cli/pmon_agent/bundle/bin/pmon_agent` 里**没有** `objective_c.dylib`
  —— 它不是链接期依赖,而是运行期按需 dlopen 的库。
- 把 exe 单独拷到临时目录(不带该 dylib)后跑 `--help` 与
  `--collector --duration 2`:**都 exit 0,日志无 dlopen/缺库错误**
  ⇒ 当前 CLI 已走到的代码路径**并不需要**它。
- 结论:拷贝是**自包含保险**(bundle/lib 是 SDK 为该 exe 声明的运行期依赖集),
  不是修复一个正在发生的失败。代价一次文件拷贝;收益是将来若某条代码路径用到
  ObjC 桥接,不会在运行期才报 dlopen 错、且错得与"装不上"完全不像。
第一版只在"exe 的 ../lib"里找库,实测**扁平形态装出来没有 objective_c.dylib**
(脚本已把库拷到 build/,但 exe 的 ../lib 指向 client/lib);已补上"exe 同目录"这一支。

## 3) `build/pmon-install`:**保留**,继续由脚本产出
理由不是"兼容旧习惯",而是:GUI 的常驻采集卡按候选链找 CLI,
**第一候选就是 `build/pmon-install`**(`lib/host/resident_service.dart`)。
若它不存在,候选链只剩 `dart run` —— 而**最终用户机器上没有 Dart SDK**,
那条候选在真实安装里等同不可用,卡片会永远显示"找不到 CLI"。
故让脚本继续产出该路径,候选链才有意义。

## 4) 完整验证:新命令构建 → 临时 HOME 安装 → 落位后真跑
```
$ client/scripts/build_cli.sh
   dart: .../flutter/bin/dart   版本: Dart SDK 3.13.3
   -- pmon_agent --   ✓ bundle: build/cli/pmon_agent/bundle    ✓ 扁平副本: build/pmon-agent
   -- pmon_install -- ✓ bundle: build/cli/pmon_install/bundle  ✓ 扁平副本: build/pmon-install

$ ./build/pmon-install --help                      → exit 0
$ HOME=$TMP ./build/pmon-install install --no-start --label com.pmon.collector.flat
  ✓ 已落位: $TMP/Library/Application Support/pmon_client/bin/pmon-agent
  ✓ 已落位: .../bin/libpmon_core.dylib
  ✓ 已落位运行期库: .../bin/objective_c.dylib
  ✓ 配置: .../config.json
  ✓ 已写入: $TMP/Library/LaunchAgents/com.pmon.collector.flat.plist
  ✗ launchctl bootstrap → exit 5 Input/output error   ← 本环境受限(老事实),失败语义正确非零
$ <落位目录>/pmon-agent --print-plan               → exit 0,打出完整 plist 计划(核心库加载成功)
$ HOME=$TMP ./build/pmon-install status --json      → 路径全部指向临时 HOME,running=false
$ HOME=$TMP ./build/pmon-install uninstall --label ...flat → exit 0,plist 已摘除
```

## 5) e2e 跟着更新(`tests/installer-clean-e2e.js` 34 → 37 项)
- 采集端候选**优先取 bundle 形态**(`build/cli/pmon_agent/bundle/bin/`),
  否则"落位要不要带运行期库"这条测不出来。
- 新增断言:`bundle 的运行期库已一并落位(objective_c.dylib)`。
- 新增断言:**落位后的可执行文件真的能跑** —— `--help`(exit 0) 与
  `--print-plan`(exit 0 且输出含 `install_commands`,证明 exe + 核心库 + 运行期库三件齐)。

## 验证
- `dart analyze lib bin`:**No issues found!**
- `flutter test`:**574/0**
- `cargo test --release`:**61/0**
- `bash tests/run-all.sh`:**32 套件 31 通过 / 1 失败**(唯一红仍是隐私扫描,未碰)
- `node tests/installer-clean-e2e.js`:**37 通过 / 0 失败 / 1 跳过**(跳过=launchd 受限)
- `node tests/cli-exit-code-e2e.js`:**11/0**
- `guard-structure-test.js`:**44/0**

## 未做(边界外,需他人跟进)
`client/lib/host/resident_service.dart:317/351/414` 与
`client/test/resident_service_test.dart:299` 里仍写着 `dart compile exe ...` 的提示文案
(那是 GUI 侧文件,本轮不碰)。建议替换为:
`请先执行 client/scripts/build_cli.sh(产物 build/pmon-install)`。
另 `client/lib/**` 里"纯 Dart 才能 dart compile exe"的说法是**能力描述**(仍然成立),
未改。
2026-10-27 23:50:15 +08:00
PMon Dev e6d1438864 test(常驻采集): 把"没快照就放行"这条例外钉在日志上,别让它只剩一句注释
## 为什么单为一条日志加测试

`_guiCollectorBlockReason()` 里只有一处"不拦"的例外:`status --json` 拿不到快照
(打包成 .app 且从未装过 CLI)时 `agentPath` 为空、无从探测,于是放行。理由成立
(否则没装过常驻采集的机器彻底不能采集),但它是**唯一一处靠日志才能复盘的判断**:

    将来有人问"这次为什么没拦住" → 没有日志就只能猜,
    而猜出来的原因通常比没有原因更糟。

故加两条:
1. **落盘验证**:临时目录注入 `StartupLog.dir`,写一条 `resident_probe_skipped`,
   读回 JSONL,断言 `e` 与 `f.reason / f.state / f.detail` 原样保留 ——
   特别是 `detail` 不能被脱敏逻辑吞掉(那条是排障的唯一线索)。
2. **源码守卫**:`main.dart` 里必须留着这句 `StartupLog.event('resident_probe_skipped'`,
   防"嫌麻烦删掉"。

## 管道本来就在工作(实机证据,不是推断)

沙箱容器里的 `startup_log.jsonl` 现有 `boot ×3 / restore_done ×2 / started ×2`,
且 `main()` 第一段就 `StartupLog.dir = ConfigStore.dir`(先于任何 `_start`)。
也就是说这条链路是通的,新事件只是加了一个名字。

## 验证

- `flutter test` → **574/0**
- `dart analyze lib bin` → No issues found
- 变异自证:把事件名改成 `resident_probe_SKIPPED_MUTATION` → ⑥ 的源码守卫报红,恢复即绿
2026-10-27 23:45:38 +08:00
PMon Dev 3b5007c1f6 fix(常驻采集): macOS 沙箱下锁对 agent 无效,补一道进程探测,堵住必然双写
## 为什么必须补(不是防御性代码)

`agentLockPath` = `ConfigStore.dir/agent.lock`,而 `ConfigStore.dir` 由 `HOME`
决定。macOS **沙箱** GUI 的 `HOME` 是应用容器,agent 由 launchd 拉起、`HOME`
是真实家目录:

    GUI   → ~/Library/Containers/com.pmon.pmonClient/Data/…/agent.lock
    agent → ~/Library/Application Support/pmon_client/agent.lock

**两个进程锁的是两个不同文件,谁也不挡谁** → 沙箱 GUI 里 `isHeldByOther()`
恒为 false。于是"agent 已在跑、用户又点了一次开始采集"→ 必然双写:两条 seq
流混进同一个 device_id,服务端按会话去重分不清,历史曲线出现无法解释的毛刺,
**且不报错**。`da88e30` 只堵住了"装完之后"那个窗口,这条路径它堵不住。

## 只修 macOS(不要把三个平台一起改)

| 形态 | GUI 的 HOME | agent 的 HOME | 锁是否同一文件 |
|---|---|---|---|
| macOS 沙箱 | 应用容器 | 真实家目录 | ❌ |
| macOS 非沙箱 / Windows / Linux | 同 agent | 同 agent | ✅ |

故 `probeInstalledAgent()` 在非 macOS 上直接返回 `notApplicable`,**一次都不执行
`pgrep`**(Windows 根本没有这个程序)。有一个测试专门守这条,防后人"统一成全平台"。

## agent 路径为什么取自 CLI,而不是本地推导

沙箱 GUI 里 `installBinDirFor(macOS, homeDir)` 的 `homeDir` 就是容器路径,算出来
是"容器/…/bin/pmon-agent",用它 `pgrep` 永远匹配不到 —— 更糟的是会得到一个
看起来确定的 `notRunning`。CLI 是非沙箱进程,`status --json` 报的
`installed.agent_path` 才是 launchd 实际启动的那条(也是 installer 侧
`probeAgentProcess()` 用的同一处真相源)。

## 判据

· `/usr/bin/pgrep -f <RegExp.escape(path + ' --collector')>` —— 绝对路径(沙箱 PATH
  可能被裁剪)、转义(家目录里的 `.` 会当通配符)、带 `--collector`(与 plist 的
  `ProgramArguments` 对齐,与 installer 同一套口径)
· exit 0 → running;**exit 1 → notRunning(唯一可信的"没在跑")**;
  其它非零 / `ProcessException` → `undetermined`(不能当成"没有")
· `undetermined` → **不放行**:双写是污染数据,漏采只是缺数据
· 锁命中优先(它能挡住探针看不出的"两个 GUI 实例"),且命中后不再多花一次 CLI 往返

## 一个如实记录的边界

`status()` 拿不到快照(打包后的 .app 且从未装过 CLI)时 `agentPath` 为空、无从
探测 → **放行**并留 `resident_probe_skipped` 日志。理由:若这也拦,等于让没装过
常驻采集的机器彻底无法采集(确定的永久性回归),换来挡住"装过之后又把 CLI 删了"
这个很偏的边界。这一条我在回报里也点了名。

## 验证

- `flutter test` → **572/0**(新增 13 条)
- `dart analyze lib bin` → No issues found
- 真机(非沙箱调用方,agent pid 1737 在跑):
  `cli 报的 agent_path=…/pmon_client/bin/pmon-agent` → `探针结论=running`
  → `合成结论=后台常驻采集已在运行(进程探测确认),本界面仅查看(不会重复采集)`
- 变异自证(三次,均已恢复):
  · 去掉 main.dart 的 macOS 分支 → 源码守卫 ⑤ 报红
  · `exitCode != 0` 一律当 notRunning → 「其它非零 → undetermined」报红
  · 去掉"非 macOS 不调探针"守卫 → 「Windows/Linux/Android 一次都不执行」报红
2026-10-27 23:43:56 +08:00
PMon Dev b11def8167 fix(agent): 退出码显式落到进程边界,并补「真实子进程退出码」e2e
## 依据:本 SDK 上 main 的返回值完全不设退出码(实测矩阵)
Dart SDK 3.13.3,各跑 `dart run` 与 `dart compile exe` 两种形态:

| 写法 | `dart run` | `dart compile exe` |
|---|---|---|
| `int main() { return 3; }` | **0** | **0** |
| `Future<int> main() async { return 3; }` | **0** | **0** |
| `void main() { exitCode = 3; }` | 3 | 3 |

即**只有显式 `exitCode =` / `exit()` 才生效**(官方文档说 `Future<int> main` 会设,本 SDK 不成立)。
所以 `cffdc50`「安装器不再撒谎、返回非零」的代码逻辑对,但**进程边界是死的**;
`pmon_agent.dart` 同样是 `Future<int> main`,后果更重:plist 用
`KeepAlive.SuccessfulExit=false`,launchd 靠「非零退出」判崩溃 —— 退出码被吞成 0
等于**采集端崩了也不会被拉起**,用户只看到历史断档,无任何报错。

## 改动:`bin/pmon_agent.dart`
`Future<void> main` + `exitCode = await runAgent(argv)`;函数体抽成 `runAgent`
(返回码语义清晰、可被测试直接调)。**语义逐条守住,没有一刀切归零**:

| 退出点 | 改前 | 改后 | 理由 |
|---|---|---|---|
| `--help` | return 0(被吞成 0) | 0 | 帮助是正常完成 |
| `--print-plan` 成功 | return 0 | 0 | 只打印,正常完成 |
| `--print-plan` 且核心返回 error | return 0 | **1** | 用户要的「计划」没拿到(iOS 不支持/路径非法);退出 0 会让脚本以为拿到了产物 |
| 抢不到单实例锁 | return 0 | **0(刻意不变)** | 非零会被 launchd 当崩溃立刻重启 → 两进程互踢成无限重启循环,比"没起来"更糟 |
| `--duration` 正常到期 | return 0 | 0 | 冒烟/CI 的正常路径 |
| Ctrl-C(SIGINT) | `exit(0)` | 0 | 用户主动停;非零会让 launchd 立刻拉起("关不掉的进程") |
| 常驻等待(Completer) | return 0 | 0 | 理论不可达(只有信号或 kill 能结束) |
| 未捕获异常(核心库缺失等) | 无 catch → 255 | **255(不变)** | 实测 `Future<void> main` 抛异常仍是 255;崩溃必须非零,否则 launchd 不重启 |

顺带把 `--print-plan` 里硬编码的 label 换成 `kDefaultAutostartLabel`
(label 唯一真相源是 `host/autostart_label.dart`,不在 bin/ 再写一份)。

## `pmon_viewer.dart` / `relay_check.dart`:**不需要改**,它们已经是显式 `exit()`
核对结果(`grep -c "exit("`):
- `pmon_viewer.dart`:**7 处** —— `exit(4)`(缺 --account / 启动失败 / 缺 --device)、
  `exit(3)`(配对失败 / 未订阅成功)、`exit(0)`(--list),末尾 `exit(ok ? 0 : 2)`。
- `relay_check.dart`:**2 处** —— `exit(same ? 0 : 1)`、catch 里 `exit(1)`。

`exit()` 比 `exitCode =` **更强**(立即终止并带上码),失败语义本来就到达边界。
我实测确认并写进了新套件:`pmon_viewer` 缺 `--account` → **4**,`relay_check` 打不通 → **1**。
顺带说明:`viewer-e2e-test.js:257` 那条 `cli.code === 0` 断言**因此是有效的**(不是死断言)。
为"统一写法"去把它们改成 `exitCode =` 是纯噪音,且会削弱语义,未做。

## 新增 `tests/cli-exit-code-e2e.js`(11 项,已接入 run-all)
**一律读子进程真实 exit code**(`spawnSync`/`spawn` 的 status),**不 import 被测代码断言返回值**
—— 断言返回值正是这次漏掉的东西。覆盖:
- 必须 0:`--help`、`--print-plan`、**抢不到单实例锁**、`--duration` 到期(后两条用"先起一个持锁实例再起第二个"构造,temp HOME 隔离)
- 必须非零:`pmon-install install --agent-bin <不存在>`、`pmon-viewer` 缺 `--account`、`relay_check` 打不通网关

## 变异自证
1. **旧写法复现缺陷**:把 `main` 临时改回 `Future<int>`,并让 `--help` `return 7` ——
   新写法实测进程退出码 **7**;旧写法实测 **0**(7 被吞)。同一份代码、只改 main 签名。
2. **测试确实会红**:临时注释掉 `pmon_install.dart` 的 `exitCode = await runCli(argv);`
   → `cli-exit-code-e2e` ⑤ 变红(`exit=0`),`installer-clean-e2e` 的
   「说失败⇒退出码非零」同时变红;恢复后两套全绿(11/0、34/0/1)。

## 验证
- `dart analyze lib bin`:**No issues found!**
- `flutter test`:**559/0**(含他人新增)
- `cargo test --release`:**61/0**
- `bash tests/run-all.sh`:**32 套件 31 通过 / 1 失败**(唯一红仍是隐私扫描,未碰)
- `node tests/cli-exit-code-e2e.js`:**11 通过 / 0 失败**
- `guard-structure-test.js`:**44/0**
2026-10-27 23:43:06 +08:00
PMon Dev 9db09bb923 fix(常驻采集): 停了本界面采集就必须说出口,别让用户在漏采时看到一张平静的卡
## 这个洞怎么来的

installer 这轮把「探针查不了」从**失败**改成**只 ⚠**(对的:不能把平台限制说成
缺陷)。而卡片的判据是"installer 退出码 0 → 停掉本界面采集"。两者相乘:

    探针不可用的平台(当前 Windows)
      → install 返回 0
      → 卡片停掉 GUI 采集
      → 而常驻进程是否起来**没人知道**(state=unknown)
      → 净效果:两个都没在采,卡片还很平静

## 为什么仍选择"停",而不是"等确认了再停"

两个代价不对等,且 team-lead 已核实**跨形态的锁是失效的**(GUI 在沙箱容器里查
`Containers/.../agent.lock`,agent 由 launchd 拉起锁真实家目录 —— 两个进程锁的是
两个不同文件,谁也不挡谁)。因此"等确认"并不能防双写:GUI 查容器那把锁永远是
空的,照常启动 → **双写是必然,不是可能**。

   · 停早了 = 缺数据(用户看得见、能补救:手动点开始采集)
   · 停晚了 = **双写**:两条 seq 流混进同一个 device_id,服务端按会话去重分不清
     它们,历史曲线出现无法解释的毛刺,**而且不报错**

故"宁可少采也不污染数据",但必须让用户知道这是我们的选择:

    「已提交安装;<为什么确认不了>。为避免与它重复采集同一台设备,已停止本界面
      的采集 —— 宁可少采也不污染数据。请用系统工具确认后再决定是否手动开始。」

## 实现

`_run` 里**先停、再刷新、再解释**(顺序不能反):停采集的时序一个字没动,
只是刷新之后多一步判断 —— `state == unknown` 才把这段话写进 `_note`。
拿到"在跑"证据时一个字都不加(不制造多余焦虑)。

## 验证

- `flutter test` → **559/0**(+3:装成功但确认不了 → 有解释;装成功且 running → 无解释;安装失败 → 不停也不解释)
- `dart analyze lib bin` → No issues found
- 变异自证(两次均恢复):
  · 删掉 `_explainStopIfUnverified` 的调用 → 「装成功但确认不了进程」报红,另一条仍绿
  · 去掉 `state != unknown` 的提前返回 → 「装成功且 running」报红(说明"不多话"这半边也被守住了)

## 未做(已单独立项,不在本笔)

锁的跨形态修复:GUI 侧改用进程探针(`pgrep -f <installBinDir>/pmon-agent`)判断
agent 在跑 —— agent 的绝对路径 GUI 能由 `app_paths` 推导,`pgrep` 是查进程不是读
文件,不需要跨沙箱访问权限;agent 侧继续用锁,只管"防两个 agent 实例"。
跨形态靠进程探测、同形态靠锁,各管一段。
2026-10-27 23:40:26 +08:00
PMon Dev d7147e67a7 fix(常驻采集): 探针不可用只报「不知道」,并把采集端实际读到的配置摆出来
## 一、探针不可用 ≠ 没在跑(把上一轮糊回去的洞补上)

installer 在 ccb6c3f 里给 `process` 加了 `probe_available/probe_error`,把
「查不了」与「没在跑」分开了(人话输出已经用上);但 JSON 消费方没跟上:
`ResidentStatus.fromJson` 只读 `running`/`pids`,于是 Windows(无 pgrep)上
`running=false + registered=true` → 卡片一本正经显示 **"已安装但没起来"(橙色告警)**,
而真相是"一次都没查过"。这比"没有卡片"糟得多:它看起来**很确定**,用户会
照着它去重装一个已经装好的东西。

- `fromJson` 读 `probe_available`,**只有明确 false 且没观测到进程**才降级为
  `ResidentState.unknown`(中性灰)+ detail 写明原因与"这不等于没在运行"
- `probe_available` 做成**三态**(`bool?`):缺字段(老二进制/老快照)→ 不看该判据。
  若把"缺字段"当 false,所有老机器都会被判成"未知"——那是用另一种方式骗人
- 正面观测优先:`running=true`/有 pid 时仍判 running(探针元数据不压过证据)

## 二、采集端实际读到的配置:诚实标注,不做同步

macOS GUI 是真沙箱(容器内 `ConfigStore`),常驻采集端由 launchd 拉起、读真实
家目录 —— 磁盘上**两份 config 同时活着**:界面里改网关地址只改容器那份,采集端
看到的还是旧的,两边都不报错。三条同步路本轮都不通(沙箱无权写容器外 / 沙箱子
进程继承沙箱 / 让 agent 读容器会给主路径加上"必须装过 GUI"的依赖),故只做标注:

- 卡片显示「采集端当前读到:<server_url> · <device_id>」(来自 `status --json`)
- 与界面输入不一致时明说不一致,并给出下一步(点一次「重新安装并启动」)
- 配置面板加一句:上面两项只对「界面内采集」生效,常驻采集读的是安装器那份
- 未安装/问不到时不显示这一行(没有"采集端读到的配置"这回事,不能凭空造)

## 三、`log.path` 的漂移种子写进注释

`/tmp/<label>.err.log` 在 Rust 的 `macos_launchagent_plist` 与 Dart 的
`statusSnapshot()` 各写一份,今天"逐字相同"只是碰巧。已在 `logPath` 的文档里
写明正解:让 `AutoStartPlan` 返回 `stderr_log_path`,Dart 只读不算。

## 四、顺带补上 label 守卫(等 ccb6c3f 落地后才敢提)

`⑥` 组:字面量只许出现在 `lib/host/autostart_label.dart`,两个消费者必须引用
`kDefaultAutostartLabel`。

## 验证

- `flutter test` → **556/0**(新增 7 条:接缝 6 + 卡片 5,另含 label 守卫 2)
- `dart analyze lib bin` → No issues found
- 变异自证:
  · 把 `fromJson` 里 `probeAvailable == false` 分支短路 → `⑦` 两条 ★ 报红(其余仍绿),恢复即绿
  · 把卡片的 `if (mismatch)` 短路 → 「与界面输入不一致」报红,恢复即绿
2026-10-27 23:37:21 +08:00
PMon Dev 24e79b06a6 fix(installer): 进程探针区分「查不了」与「没在跑」,并给 e2e 补 status --json 的路径断言
## 本笔包含什么
1. **进程探针三态化**(`_agentPids` → `probeAgentProcess()` / `AgentProcessProbe`)
   - 原实现 `Process.run('/bin/zsh', ['-c', 'pgrep -f ...'])`:**Linux 上 `/bin/zsh`
     通常不存在** → `ProcessException` 直接抛穿 `install` / `uninstall` / `status`;
     Windows 上 `pgrep` 一定没有 → 返回空列表,于是"查不了"被显示成"未运行"
     (用户会去重装,而真问题是本平台没有探针)。
   - 改为**不经 shell**直调 `pgrep -f <RegExp.escape 后的绝对路径> --collector`:
     macOS/Linux 通用,且路径含空格/引号时不再交给 shell 解析。
   - 返回值带 `available`:`pgrep` 退出码 1 = 确实没匹配(唯一可信的"没在跑"),
     其它非零 / `ProcessException` = **无法判定**。上层:`install` 不把"查不了"
     算成失败(只 ⚠),`uninstall` 不把"查不了"说成"已停止"(只 ⚠),
     `status --json` 增 `process.probe_available/probe_tool/probe_error`,
     人话输出显示「采集端: 无法判定(原因)」。只增字段,`running`/`pids` 语义不变,
     GUI 侧解析不受影响。
   - **Windows 未实现**(刻意):写一条未经真机验证的 PowerShell `-like` 分支,
     只会"看起来能判定",匹配一错就是又一次"用错事实下判断"。已留
     `TODO(多端前置)` 并明确报"无法判定"。

2. **`tests/installer-clean-e2e.js` 新增 8 项 `status --json` 断言**(26 → 34 项)
   - 解析策略按 team-lead 要求与 GUI 侧一致:**从后往前**扫行、行内从后往前找 `{`
     试解析 —— 因为 `dart run` 会把 `Running build hooks...` **拼在 JSON 同一行前面**
     (实测 `Running build hooks...Running build hooks...{"label":...}`),
     按"整行是 JSON"判断会把唯一有效输出判成解析失败。
   - 断言 `config.path` / `installed.agent_path` / `autostart.unit_path` 与真相源
     按**临时 HOME** 推导的值逐字相等 —— 硬编码旧值不可能跟着临时 HOME 变,
     故这是"路径收敛真的生效"的最强证据。

3. `statusSnapshot` 里 `log.path` 加 `TODO`:这是**唯一还没收敛**的路径
   (Rust 的 `macos_launchagent_plist` 把 `StandardErrorPath` 写成同一形状,
   Dart 侧又写一份)。正确做法与 `allow_failure` 同款(让 `AutoStartPlan` 多返回
   `stderr_log_path`),但需改 Rust 数据结构 + FFI 出口,超出"client 路径"边界,本轮未做。

## 归属说明(team-lead 要求留痕)
- `pmon_install.dart` 的 `statusSnapshot()` + `--json` 改动由**另一位工程师**实现;
  因与路径收敛同处一个文件、非交互环境下无法按 hunk 拆分,实际已在上一笔
  **c152ea2** 一并落地(该文件在 c152ea2 里是 151+/37-,即两人改动的合并体)。
  本笔只在其上追加"探针三态化 + e2e 断言 + TODO",未改动其判据逻辑。
- 反向留痕:`51c85b0`(team-lead 记作 `3ee0630`,该 hash 在全部历史与分支中都不存在,
  实际是 `51c85b0 test(常驻采集): 把「CLI 输出 ↔ GUI 解析器」两端的契约钉在一起`)
  是"测试先行"那一笔 —— 它 import `installer.statusSnapshot`,而当时 CLI 里
  `statusSnapshot` 出现次数为 **0**,单独 checkout 会因编译失败而红;
  **本笔的前置 c152ea2 使其转绿**。保留它不 revert,两侧都留痕,避免 `git bisect`
  把"顺序"读成"回归"。

## 验证(改前 → 改后)
- `cargo test --release`:61/0 → **61/0**
- `flutter test`:540/0 → **545/0**(含 `installer_status_json_test.dart` 4 项)
- `dart analyze lib bin`:**No issues found!**
- `bash tests/run-all.sh`:31 套件 30/1 → **31 套件 30/1**(唯一红仍是隐私扫描,未碰)
- `node tests/installer-clean-e2e.js`:26/0 → **34 通过 / 0 失败 / 1 跳过**
  (跳过=launchctl `Bootstrap failed: 5` 环境受限,原因逐条打印)
- `guard-structure-test.js`:42/0

## 已核实的现场事实(供 team-lead 复核)
`status --json` 在**真实 HOME** 与**临时 HOME** 下都取到正确路径,后者全部跟着临时
HOME 走,证明三个路径字段来自 `app_paths.dart` 推导而非硬编码:
- 真实:`config.path=/Users/me/Library/Application Support/pmon_client/config.json`、
  `installed.agent_path=.../bin/pmon-agent`、`log.path=/tmp/com.pmon.collector.err.log`
- 临时:`config.path=/tmp/pmon-json-probe-xxxx/Library/Application Support/pmon_client/config.json`、`installed.agent_path` 同源变化、
  且 `process.running=false`(**正版 pid 1737 不再被算进来**,路径作用域生效)
2026-10-27 23:33:52 +08:00
PMon Dev cb82678cdd refactor(常驻采集): 自启 label 收口到唯一真相源(GUI 与安装器问同一个自启项)
`'com.pmon.collector'` 原本在 `resident_service.dart` 与 `bin/pmon_install.dart`
各写一份。漂移的症状极隐蔽:GUI 查 A.plist、安装器注册 B.plist —— 两边都
"正常",只是问的不是同一个自启项,表现为"明明装了、卡片却说未注册"。
这与 install_paths / 指标字段 / 配对码是同一族缺陷,故同样收成一份常量。

本笔只含常量的**定义**与 GUI 侧消费者(纯 lib,自洽可红可绿):
  · 新增 `lib/host/autostart_label.dart`
  · `ResidentService.label` 默认值改为引用它

安装器侧那半行(`const _defaultLabel = kDefaultAutostartLabel;`)落在
`client/bin/pmon_install.dart`,该文件当前含另一位同学的在途改动,随他的
commit 一起落地;随之落地的还有源码守卫测试(字面量只许出现在
`lib/host/autostart_label.dart`)——守卫必须与两处消费者同笔,否则该 sha
单独 checkout 会红。

验证:dart analyze lib → No issues found;flutter test → 545/0
2026-10-27 23:31:57 +08:00
PMon Dev 2574011e5a fix(常驻采集): 过期 CLI 的输出不得冒充状态,改为校验形状并回落下一候选
端到端验收抓到的真缺陷(不是理论问题,本机可复现)。

## 血案形态
开发机上 `client/build/pmon-install` 是**几天前编译的旧二进制**,它不认识
`--json`,照旧打印人话 + 一行**配置 JSON**:
    === 配置 ===
    {"server_url":"http://127.0.0.1:3000","device_id":"mac-autostart-test"}
而 `ResidentService` 原先的解析是「从下往上取第一个能解析成 Map 的行」——
于是它抓到了**配置 JSON**,`ResidentStatus.fromJson` 得到:
    state=notInstalled、configPath=""、logPath=""、agentPath=""
卡片会一本正经地显示「尚未安装」—— **用户明明装过、而且在跑**。
这正是"形状对、内容错":接口对得上、值是错的,且不报错、不留痕。

## 修法(两条,缺一不可)
1. **形状校验**:新增 `ResidentService.looksLikeSnapshot()` —— 只接受同时含
   `autostart` 与 `process` 两段的 JSON。配置 JSON 缺这两段,直接不认。
2. **候选链逐级回落**:`resolveCliCandidates()` 返回全部候选(装好的 →
   `build/pmon-install` → `dart run bin/pmon_install.dart`),`status()` 逐个试,
   **只采用形状合规的那一个**;全都不合规时返回 unknown,并给出可操作原因
   ("这个 pmon-install 不支持 `status --json`,请重新编译",附输出前 120 字)。
   这样开发机上"编译产物过期"不再静默给错状态;生产上若用户只升级了 GUI
   没升级 CLI,卡片会说"CLI 太旧",而不是谎报"未安装"。
`install`/`uninstall` 仍走第一个候选(那两个动作没有"输出形状"问题,失败原因原样回显)。

## 测试(+3,共 17 项;全仓 543/0)
- `looksLikeSnapshot`:只有 config 段的 JSON **不算**状态快照(血案本体);
  缺 autostart/process 两段的不算;齐了才算。
- 过期二进制的输出 → **unknown**,绝不冒充 notInstalled;文案含"不支持"与
  "dart compile exe"(告诉用户该做什么)。
- 候选链逐个试:过期那个被跳过,合规那个被采用,且断言调用顺序
  `[过期二进制, dart]`(顺序本身也是契约)。

## 真机端到端(验具走 GUI 真正走的那条路 `svc.status()`)
```
候选 CLI 链:
  · .../client/build/pmon-install      exit=0 输出: === 自启注册 === ...(旧版,无 JSON)
  · dart run .../bin/pmon_install.dart exit=0 输出: {"label":"com.pmon.collector",...}
--- svc.status() 解析结果 ---
state=running  pids=[1737]
autostart: registered=true loaded=true unit=~/Library/LaunchAgents/com.pmon.collector.plist
config: exists=true  path=~/Library/Application Support/pmon_client/config.json
log:    exists=false path=/tmp/com.pmon.collector.err.log
agent:  exists=true  path=~/Library/Application Support/pmon_client/agent
✓ 端到端通过
```
(`status --json` 的实现仍在 `client/bin/pmon_install.dart`,随
software-engineer-2 的合并提交落地;本笔只修**消费侧**,不碰他的在途改动。)

验证:`flutter test` **543/0**;`dart analyze lib bin` **No issues found!**
2026-10-27 23:25:43 +08:00
PMon Dev d983cc5d7c fix(路径): 安装/数据路径合并到唯一真相源,并补 installer 干净目录 e2e(P0-3 / E10a)
## 缺陷(合并前四处口径互相矛盾)
- `install_paths.dart:27` 安装根目录**硬编码 macOS 路径**,零平台分支
- 同文件 `:33` 可执行文件名不带 `.exe`(Windows 上不可执行)
- `app_config.dart:245-260` 反而有 macOS/Windows/其它三分支
- 动态库文件名另有 3 份(`core_lib.dart:17-21`、`pmon_install.dart:89` 二分支、
  `:287` 三分支);plist 路径 `pmon_install.dart:184`/`:271` 各写一遍
后果不是"不好看",是**装得进、读不到**:Windows 上 agent 落到
`%USERPROFILE%\Library\Application Support\pmon_client\bin`,配置却写进
`%USERPROFILE%\AppData\Roaming\pmon_client`,两边都不报错。

## 合并后的真相源:`client/lib/host/app_paths.dart`
两条**正交**维度都做成显式参数(不是靠 `if` 补):
1. **平台** —— `PmonPlatform` 参数化,且**路径语法随参数走**(`p.windows`/`p.posix`),
   于是 Windows 口径能在 macOS 上被真测到(测真机形态,不是 posix 伪装)。
2. **进程形态** —— `PmonProc{installer, gui, guiSandboxed, injected}`。
   Android 的 `getFilesDir()` 注入被**收进**这个参数,不再是并列的第二套机制。

消费方全部改为委托,不再复制任何路径字面量:
`install_paths.dart`(保留全部既有 getter 名,bin/ 无需改调用点)、
`app_config.dart`(`ConfigStore._dir` 只剩一句 `dataDirFor`)、
`core_lib.dart`(动态库名取 `coreLibNameFor`)、
`pmon_install.dart`(`coreLibName` + `launchAgentPlistPath`)。

## 沙箱维度:怎么处理的(架构文档的四段代码没覆盖,这里补上)
先核实事实:`client/macos/Runner/{Release,DebugProfile}.entitlements` 里
`com.apple.security.app-sandbox = true` —— **本项目 GUI 确实是沙箱的**,
bundle id = `com.pmon.pmonClient`。所以这不是假想维度。
处理方式(不嗅探、不加注入通道):
- 沙箱只体现为"家目录取值不同",不是另一套目录规则;
  `dataDirFor(..., proc: guiSandboxed)` 从**真实家目录**插入容器前缀,
  与沙箱 GUI 拿自己 HOME(容器路径)算出的值**恒等**。
- 该等式写成了测试(三种形态各一条):
  macOS 非沙箱 → `安装目录 == GUI 数据目录`;
  macOS 沙箱 → `guiDataDirForInstallerView(h) == dataDirFor(容器家目录, gui)`;
  Windows/Linux → 配置目录同源(Linux 安装目录走 XDG 是有意的,见注释)。
- bundle id 有自动比对:测试读 `macos/Runner/Configs/AppInfo.xcconfig` 逐字核对。

## 顺带修掉两个"假判断"(都在同一个文件,且直接决定 e2e 能不能说真话)
1. **`Future<int> main` 把退出码吞掉**:本机 SDK 实测 `int main` / `Future<int> main`
   返回 3,进程退出码都是 **0**。即上一轮「安装器不再撒谎、返回非零」的修复
   **到不了进程边界** —— 屏幕打印"安装失败",shell / CI 仍拿到 0。
   改为 `Future<void> main` + `exitCode = await runCli(argv)`。
2. **`_agentPids` 只按 `pmon-agent --collector` 匹配**:会把**别人家**的采集端算进来
   (本机就有一份 pid 1737 在跑),于是 install 假成功、uninstall 假失败。
   改为按落位后的**绝对路径**匹配(`RegExp.escape` 防路径里的 `.` 当通配符)。

## 新增 `tests/installer-clean-e2e.js`(26 项,已接入 run-all)
临时 HOME 隔离,从零走:落位 bin+dylib → 写配置 → plist → status 四项判据 →
卸载摘干净。要点:
- 各平台期望路径在测试里**独立写死**(不 import Dart 口径)——
  否则口径写错时测试会"自洽地通过",等于没测。
- 特殊断言:**屏幕上说「安装失败」时退出码必须非零**(就是上面那个 P0)。
- `launchctl bootstrap` 在本环境必然失败(`Bootstrap failed: 5: I/O error`):
  落位/配置/plist **无条件断言**,自启注册一项明确打印 ⚠ 并记 skip(附原因),
  其它任何非零退出判失败。缺 dart / 缺 Rust 产物 / 缺采集端时 ⚠ 跳过并返回 0
  (不静默放行、也不假红);跳过分支不打印汇总行,免得结构守卫把"没跑"看成"全绿"。

## 验证(改前 → 改后)
- `cargo test --release`:61/0 → **61/0**
- `flutter test`:485/0 → **540/0**(含他人新增;本次新增 `app_paths_test.dart` 22 项)
- `dart analyze lib bin`:**No issues found!**
- `bash tests/run-all.sh`:30 套件 29/1 → **31 套件 30/1**(唯一红仍是隐私扫描,按要求未碰)
- `node tests/installer-clean-e2e.js`:**26 通过 / 0 失败 / 1 跳过**(跳过=launchd 环境受限)
- `guard-structure-test.js`:42/0(新增套件已通过"汇总晚于断言"守卫)

## 未做(清单外,只报告)
- **macOS 沙箱下"配置读不到"仍未接线**:GUI 是沙箱的,读容器;
  而安装器(非沙箱)与 launchd 拉起的采集端读真实家目录。路径层已能算出
  两者位置(`guiDataDir`),但"配置究竟写给谁、要不要双写、GUI 怎么拿到"是
  配置交接的决策,不属于本次路径合并,未擅自改。
- `pmon_agent.dart` 同样是 `Future<int> main`(退出码同样会被吞),不在本次边界内。
- 默认 label `com.pmon.collector` 在 `pmon_install.dart` 与他人新文件
  `resident_service.dart` 各有一份(不是路径,未动)。
2026-10-27 23:25:41 +08:00
PMon Dev 166963f994 test(常驻采集): 把「CLI 输出 ↔ GUI 解析器」两端的契约钉在一起
`test/installer_status_json_test.dart`(4 项)。它 import `bin/pmon_install.dart`
**在同一进程里**跑一遍 `statusSnapshot()`,再把结果 `jsonEncode → jsonDecode`
喂给 GUI 侧的 `ResidentStatus.fromJson`,断言四类判据(自启注册态 / 进程 /
配置 / 日志)两端字段名对得上。

## 为什么必须有这条
`pmon-install status --json` 与 `ResidentStatus.fromJson` 是**同一条契约的两端**。
任一端改字段名,另一端不会报错,只会静默退化成"状态未知"——卡片永远显示
"看不清",而日志里没有任何异常。这与本项目当年"指标字段四处各写一套、交集只剩
3 个字段、静默丢数"是同一类故障(那次是靠 schema-consistency-test 才抓住的)。
故这里不测"CLI 能不能跑通"(真机验),只测**两端字段是否还对齐**。

## 断言要点
- 四类判据齐全(缺一 GUI 就判不出三态);
- `jsonEncode(snapshot) → fromJson` 后 state 落在合法三态内(若变成 unknown,
  说明字段名漂移了);
- `config.path` / `log.path` / `installed.agent_path` 三个路径字段非空
  (这三条正是 engineer-2 路径收敛与本卡的交汇点);
- `status --json` 返回 0(只读命令永不失败退出);
- 人话输出与 JSON **共用同一份快照**(同一条断言保证"判据只有一处")。

## ⚠️ 已知的串联顺序(必须连着落地)
本笔单独存在时会**红**:GUI 侧读的 `statusSnapshot()` 落在
`client/bin/pmon_install.dart`,而该文件正被 software-engineer-2 的路径收敛
任务改写(未提交,且两人的改动都集中在 `_status()` 同一处,非交互环境下
`git add -p` 用不了),按 team-lead 的决定由他一次性合并提交(commit message 里
会注明那段由我实现)。他那一笔落地后本测试即绿。
若有人 bisect 到本笔,看到的是"契约测试先行、实现紧随",不是回归。

验证:working tree(含未提交的 `statusSnapshot`)上 `flutter test
test/installer_status_json_test.dart` → 4/4 通过;
`dart run bin/pmon_install.dart status --json` 真机输出四类判据齐全。

## 更正(数字,勿再照抄旧账)
`698b680` 的 message 写「新增 21 项(13 + 8)」,**实际 B 的 GUI 侧为 22 项**
(`resident_service_test.dart` **14** + `resident_card_test.dart` 8 —— 中途补了
「`dart run` 把 `Running build hooks...` 拼进 JSON 同一行」那条用例,从 13 变 14)。
`698b680` 未 amend,以保持"当时验证的是什么"可复核;此处补记以保证 `git log` 自洽。
逐文件实测:`link_mode_test` 8 / `transfer_card_test` 14(原 9 + 新 5)/
`resident_service_test` 14 / `resident_card_test` 8 / `installer_status_json_test` 4,
合计新增 39;`540 = 485(基线)+ 39(本任务)+ 16(并行任务 `app_paths_test`)`。
2026-10-27 23:23:06 +08:00
PMon Dev edaf52b06d feat(常驻采集): 采集页加「常驻采集」卡,把桌面端关窗即停变成可操作入口(P0-5 GUI 侧)
对应架构评审 2.5 节。此前 `bin/pmon_agent.dart` + `bin/pmon_install.dart`
(install/uninstall/status/restart/verify)都已就绪,但 **GUI 一个入口都没有**,
用户得自己敲命令 —— 等于这个能力不存在。

## 这一笔包含什么(GUI 侧)
- 新增 `lib/host/resident_service.dart`:GUI 与 `pmon-install` 的**唯一接缝**。
  只做三件事:**找到 CLI → 跑它 → 把 `status --json` 解析成类型**。
  · 不在 GUI 里做 launchctl/plist:沙箱 GUI 写不了 `~/Library/LaunchAgents/`,
    自启产物内容由 Rust 生成、落盘由非沙箱 CLI 执行;GUI 再写一套就是第二套自启逻辑。
  · 不在 GUI 里自己 stat 文件/pgrep 进程:判据只有 CLI 那一处。
  · `ResidentState` 三态(在跑 / 装了没起来 / 未安装)+ unknown(问不到)。
    unknown **不猜**:字段缺、执行失败、输出不是 JSON 都如实说"看不清"并带原因。
  · CLI 定位按三级回落:装好的 `installBinDir/pmon-install` → 开发树
    `build/pmon-install` → `dart run bin/pmon_install.dart`;都没有就报
    "先 dart compile exe ...",**不假装已安装**。
- 新增 `lib/ui/resident_card.dart`:卡片本体。
  · 三态色点 + 一句话状态;"装了但没起来"必须说清判据(自启未加载 / 进程没起)
    并给日志路径 —— 这一态最容易被误解成"装过就好了"。
  · 按钮:安装并启动(未装)/ 重新安装并启动(装了没起)/ 卸载(已装)。
  · **失败回显 CLI 原文**(stderr 尾部):P0-6 已让 installer 返回非零并打印
    哪条命令失败,卡片把那几行原样显示(可复制),不用"安装失败"盖掉。
  · 5 秒低频轮询(状态在外部进程里,事件拿不到;5 秒够"改完能看见")。
- `lib/main.dart`:采集页 dashboard 挂载,**仅桌面端**
  (`core.canRunInBackground && !AndroidForeground.isAndroid` —— Android 的常驻
  是前台服务,语义不同)。
  · `onResidentStarted` → 停掉 GUI 自己的采集:两个进程同时上报会把两条 seq 流
    混进同一 device_id,服务端按会话去重判不清 → 丢帧或重复帧,且不报错
    (与 `_start()` 里 AgentLock 那条同源)。
- `test/theme_guard_test.dart`:登记 `lib/ui/resident_card.dart`
  (新增 UI 文件必须进 `_uiFiles`,这条吃过亏)。

## 测试(新增 21 项)
- `test/resident_service_test.dart`(13):解析四类判据 / 三态判定只在一处 /
  字段缺失不崩不猜 / **失败带具体原因** / 输出非 JSON → unknown /
  真机噪声(`dart run` 把 "Running build hooks..." 拼在 JSON 同一行)仍能解析 /
  CLI 路径从 `installBinDir` 派生 + 三级回落 + 找不到给明确指引。
- `test/resident_card_test.dart`(8):未安装 / 在跑(含 pid、且不再给"安装"按钮)/
  装了没起来(说清判据 + 日志路径)/ 状态未知如实说 / **失败原因原样可见** /
  安装成功才通知宿主停采集 / 安装失败不停采集 / 卸载回未安装。

## 验证
- `flutter test`:**540 通过 / 0 失败**(本笔新增 21 项)
- `dart analyze lib bin`:**No issues found!**(不用 `flutter analyze`:本机中文路径会让它崩)

## 与并行任务的边界(重要)
`pmon-install status --json` 那一半(CLI 侧 `statusSnapshot()` + `--json`)
落在 `client/bin/pmon_install.dart`,而该文件正被 **software-engineer-2** 的
路径收敛任务改写中(未提交)。为不把他的在途改动卷进我的提交,本笔只含 GUI 侧;
CLI 侧与 `test/installer_status_json_test.dart` 紧随其提交后单独落一笔。
2026-10-27 23:21:03 +08:00
PMon Dev 1711425b08 feat(能力收口): 中继上限点击前明示 + P2P 下线(隐藏档位,不删代码)
对应架构评审 2.6 节(P0-6):中继/PM-4 选②(先明示)、P2P/PM-5 选②(下线)。

## A1 中继大文件:明示上限(不做流控)
`RELAY_MAX_BYTES=1MiB` 让服务端从"卡死"变成"明确拒绝",但用户是
**选完文件、点了发送、进度条不动**之后才知道 —— 界面在骗人。
现在两处改:
1. `transfer_card` 的发出区在**还没选文件时**就把话摆在按钮下面:
   「跨网时走服务器中继,当前单次上限 1.0 MB;更大的文件请在两台设备处于
   同一局域网时传(直连不受此限制)」;
2. `_pickAndOffer` 里在 `openRelayForSend()` **之前**用纯函数
   `relayLimitWarning(size)` 拦一道:超限则撤销本地票据、**不发起传输**、
   把原因显示出来("没有开始传输"说清楚,别让人以为在等)。
阈值只有一处来源:`net/relay_client.dart` 的 `kRelayMaxOnceBytes`,注释指向
`server/gateway/relay.js:44-47`(服务端 `MAX_ONCE_BYTES`),界面不再手写 1048576。
文案已避开"监视/监听/追踪"类词(合规红线),并有测试守着。

## A2 P2P 下线:隐藏档位,不删代码
按 2.6.2 的五条动作(第 4 条 Rust 事件常量已在 03b30c1 完成):
1. `viewer_page.dart` 数据路菜单摘掉 `p2p` 与 `relay`,只剩「自动 / 内网直连 /
   云端订阅」;`onSelected` 同步去掉这两个分支。
   `relay` 一并摘掉的理由:它只服务**文件面**(PM-E7),放在指标路菜单里
   同样是"界面在说谎"。
2. `link_mode.dart`:`LinkMode.p2p` 枚举值保留(协议词汇/评分表/既有测试都引用),
   加"待接线"注释;`relay` 同注。新增 `isModeSelectable()`(可选项的唯一判据)
   与 `normalizePreferredMode()`(偏好收敛点)。
3. `link_hub.dart`:推荐逻辑**排除**已下线档位(`isModeSelectable` 过滤
   candidates + 手动偏好分支复检),**不改评分权重** —— 只排除产出;
   将来接线后权重原样生效。
4. `stun.dart` / `p2p_driver.dart` 文件头各加"待接线"说明,写明
   "`stun_test.dart` 是真测试、保留;生产路径未调用"(依据:`stunDiscover()`
   全仓唯一调用点是测试;`publishOffer` 两处注入点都没传 `candidates`)。
5. 没删任何 P2P 代码,没动既有 stun/p2p 测试。

## 旧偏好回落(摘菜单后的必答题)
存量的 `p2p`/`relay` 偏好一律收敛成 **null = 自动**,收敛点只有
`normalizePreferredMode()` 一处,在三个入口生效:`ViewerSession.setPreferredMode`、
`LinkHub.setPreferredMode`、`viewer_page` 菜单勾选态。
不崩、不空白、不留"选不回来又不生效"的勾选态。

## 测试
新增 `test/link_mode_test.dart`(8 项):可选项只有 3 个 / p2p+relay 不可选但枚举值仍在 /
normalize 收敛 / hub 与 session 收到旧偏好都回落 / 只有 p2p 可达时推荐为空 /
p2p 分数更高也不抢推荐 / 内网照常推荐。
`test/transfer_card_test.dart` +5 项:1 MiB 以内不提示 / 超限文案含"多大+上限+没开始" /
必须给出"换同一局域网"的可行动作 / 合规禁词 / **卡片在未选文件时就显示上限**(widget 级)。
`test/link_hub_test.dart` 改 1 处:那条"手动指定不可达模式 → 回落自动"原本用
`relay` 举例,relay 已下线会走不到该分支,改用在线档位 `lan`(用例意图不变)。

## 验证
- `flutter test`:全绿(本文件改动前的共享树基线 485,本分支新增 13 项;
  另有 engineer-2 并行新增的 16 项 app_paths 测试,合计 514)
- `dart analyze lib bin`:**No issues found!**(不用 flutter analyze:本机中文路径会让它崩)
2026-10-27 23:15:17 +08:00
PMon Dev d296701468 docs(落库): 把 storageMaxRetries=9 的依据写进注释,防后人改回 3
team-lead 批准 9 轮窗口的前提是"注释里写明依据",否则后人看到 9 会以为
是随手写的数。补清三点:
  · 9 轮 ≈ 18~20 秒(flush 周期 2 秒),覆盖 PG 重启 / 连接池耗尽后恢复的典型时长;
  · 放宽**不增内存**:上界由槽容量 _storageQueueMax(5000 帧)硬约束,
    窗口只决定"抖动期间攒多久",两件事必须解耦、不要合在一起调;
  · 改回 3 会让 tests/storage-failure-test.js 的 3d 段("PG 抖动 10 秒必须全救回")
    立刻变红,并直接说明"10 秒抖动救不回"。

纯注释改动,无行为变化。验证:storage-failure-test 41/41 绿;
10 秒抖动量具仍为 20/20 落库、storage_failed=0。
2026-10-27 13:33:57 +08:00
PMon Dev f543739b8d fix(落库): 重试槽改为多轮合并,短暂抖动不再"每 2 秒丢一批"(E2 复核改判)
## 改判内容(team-lead 复核结论)
上一版(58f82a6)在"重试槽已被占用"时把新失败批**直接丢弃**,只让最早那一批
享有重试特权。幂运算一下就看出问题:PG 抖动 10 秒(2 秒一轮 → 5 轮)时,
轮 1 批 A 进槽(有救),轮 2/3/4 的批 B/C/D 全丢,轮 5 槽 A 超限丢、批 E 也丢 ——
**10 秒抖动只救回一批**,等于把"每批都丢"降级成"每 2 秒丢一批",
而短暂抖动恰恰是重试最有效的场景。故改为**合并进槽**。

## 实现
- 槽的数据形态不变(`{ batch, attempts, lastError }`),但 `batch` 现在是
  **多轮合并累积**的帧数组:失败时一律 `concat`,不再"占用即丢弃"。
- **有界性靠槽容量,不靠丢新批**:合并后若超过 `_storageQueueMax`(默认 5000,
  与主队列同一上界)→ **截断**,保留**较早**的帧(先保已经等了更久的数据),
  超出部分走 `_discardStorageBatch(..., '超出重试槽上限 N 帧')`:计入
  `_storageFailed` + 告警,**明确丢弃不静默**。于是重试槽内存上界 = 主队列上界,
  重试不会让内存膨胀。
- `attempts` 语义改为**槽的重试轮次**(每轮 flush +1,首个失败轮记为 1),
  不再是"某一批的尝试次数"。超过 `_storageMaxRetries` → 整槽丢弃。
- 顺序不变(上一版写对的地方保留):**先试槽 → 无论成败都照常处理主队列**,
  旧批失败绝不阻塞新数据;不在同一次 flush 内忙等重试。

## 附带发现:默认重试窗也要放宽(这一条请复核)
改完合并后我用临时量具实测"PG 抖动 10 秒"(5 轮 × 4 帧 = 20 帧,第 6 轮恢复):
- 默认 `storageMaxRetries = 3` → 只救回 **8/20** 帧(第 4 轮 ≈8 秒整槽被丢,丢 12 帧)
- 因为合并后 attempts 记的是**轮次**,上限 3 意味着"只覆盖 4 轮 ≈ 8 秒",
  与"要覆盖一次真实抖动"的意图不匹配。
故默认值改为 **9 轮 ≈ 20 秒窗口**(2 秒/轮 × (1+9))。放宽不增加内存:槽容量由
`_storageQueueMax` 硬约束,窗口长短只影响"抖动期间攒多久"。改后同一场景
**20/20 全部救回、`storage_failed` = 0**。上限机制本身仍由测试 1(显式传
`storageMaxRetries: 3`)钉住。

三个版本在同一量具下的实测对照:
| 版本 | 落库 | storage_failed |
|---|---|---|
| 最初(只 log) | 0/20 | 字段不存在(静默) |
| 58f82a6(槽占用即丢) | 4/20 | 16 |
| 本次(合并 + 20 秒窗) | **20/20** | **0** |

## 测试(tests/storage-failure-test.js:29 → 41 项,全绿)
新增:
- **3b 跨 flush 合并**:3 个周期各失败 3/4/2 帧 → 槽内 **9 帧**、`attempts` = **3**
  (不是 9);第 4 轮 PG 恢复 → **9 帧全部落库**、seq 1..9 顺序完整、`_storageFailed` = 0
- **3c 合并仍受容量约束**:槽上限 10,连续失败凑 15 帧 → 槽里只留 10 帧、
  另 5 帧计入 `_storageFailed`、**只告警一次**、保留的是较早帧(1..10)
- **3d 抖动 10 秒(默认窗口)**:5 轮 20 帧全攒槽、恢复后 20 帧一次落库
原有 5 条全绿(含"失败不阻塞新数据"毒丸隔离、重试成功不丢数据、计数/告警节奏、
队列满丢最旧语义不变、`drainStorage` 刷净)。
另:把 1/3/4 段的重试上限**显式**传 3,让"上限机制"与"抖动窗口"两件事在测试里
各自独立可读。

## 变异自证
把失败分支改回旧实现(只 `log.error`、批次消失)→ **41 项里 27 项红**:
第 1 段 8 项全红、第 2 段 2 项、第 3 段 2 项、**3b 4 项、3c 5 项、3d 2 项**、
第 4 段 3 项、`drainStorage` 1 项;恢复后 41/41 绿。
(顺带加固了 3b/3c 的空槽判定为 `?.`,让回归时"报红"而不是抛异常中断整个套件。)

## 三侧数字
- GATEWAY_PORT=3107 bash tests/run-all.sh:**29 通过 / 1 失败**(30 套件;唯一红仍是隐私扫描,未触碰)
- cargo test --release:**61 / 0**
- cd client && flutter test:**485 / 0**
2026-10-27 13:29:45 +08:00
PMon Dev 748785bb8e fix(落库): 批量写库失败不再静默丢整批,改有限重试 + 明确丢弃计数(E2)
## 缺陷(最后一个数据正确性缺陷,与 E1 同级)
gateway.js 的 `_flushStorage()` 旧实现是「先把队列整个 splice 走,再 try/catch 只 log」:
    const batch = this._storageQueue.splice(0, this._storageQueue.length);
    try { await this.repo.insertMetricsBatch(batch); }
    catch (e) { this.log.error('指标批量落库失败', e.message); }
catch 之后批次**已经不在队列里**:PG 抖动 / 磁盘满 / 连接池耗尽时,
一次失败丢掉**整批(最多 5000 帧)** —— 不留计数、不重试、`/health` 完全正常。
对照同文件"队列满丢最旧"那条路径(_storageDropped + 周期 warn),这条是裸的。
与"静默丢数据"同源,只是这次连丢都没记。

## 为什么用**独立重试槽**,而不是把失败批次放回主队列
放回主队列是最直觉的写法,但有两个硬问题:
  ① 与"队列满丢最旧"语义打架:`_enqueueForStorage` 满时会 shift 掉**队首**,
     把失败批次放回去等于让"最该被救的旧数据"变成"最该被丢的旧数据";
  ② 毒丸(某帧的值始终违反约束,重试多少次都失败)会永久占据队首,
     把后面所有**新数据**一起堵死 —— 一条坏帧拖垮整台设备的落库。
故改为 `this._storageRetry = { batch, attempts, lastError }` 独立存放。

## 毒丸如何隔离
每轮 `_flushStorage()` 的顺序是:**先试重试槽一次 → 然后无论成败都照常处理主队列**。
  · 旧批失败**绝不阻塞**新数据落库(这是本缺陷最关键的性质);
  · 不在同一次 flush 内忙等重试(会把 2 秒定时器拖住,让"暂时抖动"滚成"持续积压");
  · 有界:attempts 超过 `_storageMaxRetries`(默认 3,即首次 + 3 次 ≈ 8 秒窗口)
    → 调 `_discardStorageBatch()` **明确丢弃**;
  · 重试槽已被占用时新失败的批次不挤掉它(挤掉等于"更早的数据先死"),
    也不攒着(内存必须立刻有界),同样走"明确丢弃 + 计数"。

## `_storageFailed` 的语义(与 `_storageDropped` 不是一回事)
  · `_storageDropped`  = 队列满,主动丢**最旧**(有设计的有界策略,正常压力下的取舍)
  · `_storageFailed`   = 写库失败、重试也救不回来而**明确丢弃**的**帧数**
    (>0 就意味着真丢数据,要查 PG/磁盘;`/health.storage_failed` 直接暴露)
告警节奏:首丢必报一条(带帧数、已尝试次数、错误原文、前 3 帧 `device_id@seq`,
便于事后判断丢了哪台设备的哪一段、要不要补),之后每累计 100 帧一条,
对齐 `_storageDropped` 的节奏,避免持续故障时每 2 秒刷一条把日志淹了。
`/health` 另加 `storage_retry_attempts` / `storage_retry_frames`:
运维据此区分"暂时抖动(attempts 在涨但 finally 落库)"与"PG 一直不可用"。

`drainStorage()` 改为最多 3 轮:一次 flush 只给重试槽一次机会,关闭时多给几轮,
仍救不回来的打印一行"关闭时仍有 N 帧未能落库"(不静默)。

## 顺带删一处死代码
`_releaseSeqWindow(deviceId)` 全仓零调用(只有定义),已删除并在原位置留注释说明
为什么现在不需要它:E1 之后去重窗口的权威副本在 Redis,断连**刻意不删**
(那正是"重启后补传仍能判重"的依据,靠 TTL 收敛);内存窗口只是兜底判据,
条目数由 `_seqWindowMaxDevices` 的 LRU 上限约束,不需要断连时主动回收。

## 新增 tests/storage-failure-test.js(29 项,已接入 run-all.sh 离线段)
1. 有界重试:批次进槽不消失 → attempts 1..3 → 超限明确丢弃(6 帧计数准确)、
   总尝试 4 次、丢弃告警只 1 条且含帧数/次数/错误/样本
2. 重试成功后不丢数据:前 2 次抛错、第 3 次成功 → 该批 4 帧**原样补上**(seq 完整)、
   `_storageFailed` 保持 0
3. **毒丸隔离(最关键)**:含 dev-poison 的批持续失败,同时灌入新帧 →
   新帧照常落库(3 → 6 帧),毒丸最终被丢弃且不计入新帧
4. 多次丢弃:累计帧数 5 / 批数 2 准确,告警仍只 1 条(未到 100 帧阈值不刷屏)
5. 回归:正常路径 `storage_failed` 恒 0、队列满 `storage_dropped` 语义不变、
   `drainStorage()` 把重试槽那批也刷净
6. 真进程 `/health`:`storage_failed=0`、`storage_retry_attempts=0`、
   `storage_retry_frames=0` 且 `storage_dropped` 仍在(PG/Redis 不可达时明确跳过)

## 变异自证
把 `_flushStorage` 的失败分支改回旧实现(只 log、批次消失)→
**16 项红**(第 1 段 7 项全红、第 3 段"进槽/最终丢弃"2 项红、第 4 段 3 项红、
`drainStorage` 1 项红);恢复后 29/29 绿。
注:第 3 段那两条 ★"新帧照常落库"在变异下仍绿 —— 因为旧实现是"立刻丢",
同样不阻塞;它们守的是"将来别为了重试而阻塞新数据",与本缺陷的计数/重试断言互补。

## 三侧数字
- GATEWAY_PORT=3107 bash tests/run-all.sh:**29 通过 / 1 失败**
  (30 个套件;唯一红仍是隐私扫描,本次未触碰)
- cargo test --release:**61 通过 / 0**
- cd client && flutter test:**485 通过 / 0**
2026-10-27 13:21:14 +08:00
PMon Dev 7c11ec490b fix(时钟): 状态机改用进程内单调时钟,NTP 回拨不再导致重连挂死(E4)
## 缺陷
设备时钟被 NTP 调整(尤其回拨)时,喂给 Rust 状态机的 now 会倒退,而
transport.rs 的重连判断是**绝对值比较**:
  transport.rs:570  if self.reconnect_at_ms != 0 && now >= self.reconnect_at_ms
`reconnect_at_ms` 是 on_disconnected 时用 now + delay 算出来的
(transport.rs:511 / 577)。墙钟一回拨,它就落在"未来"→ 条件长期不成立 →
**重连挂死**:界面只有灰灯,不报错、不留痕,用户只能说"连不上了,重开就好"。

三处病灶(都已核实):
  · client/lib/host/link.dart:273   static int _now() => DateTime.now()...
  · client/lib/host/viewer.dart:761 同上
  · client/lib/link/link_hub.dart:234 `_monotonicishMs() => DateTime.now()...`
    而它上一行注释写着"刻意不用 DateTime.now()" —— 注释写了正确意图,
    实现和函数名(monotonicish = 假单调)都是错的。

## 改法
新增 client/lib/host/clock.dart(纯 Dart,不 import flutter,与 timing.dart 同风格):
  · `wallMs()`            —— 墙钟,只用于线格式/落盘/日志
  · `MonotonicClock`      —— **真单调**:构造时取一次墙钟作基准,
                             之后 `originMs + Stopwatch.elapsedMilliseconds`
  · `processNowMs()`      —— 进程默认单调读数(各处的默认注入值)
  · `debugOverrideProcessClock()` —— 仅测试用,验证"默认值确实走单调时钟"
刻意不是"把 DateTime.now() 包一层":那样回拨问题原样存在,等于白做。
用 Stopwatch 而非墙钟的好处是增量真单调;基准取墙钟则让读数仍贴近真实时间,
日志/排查时看得懂(不会变成"启动后第 12 毫秒")。

三处调用点改为走该时钟,且都可注入(`ClientAgent(nowMs:)` /
`ViewerSession(nowMs:)` / `LinkHub(nowMs:)`,后者本来就有这个缝)。
link_hub 的假单调函数删除,注释改写为真实原因(并留下这段历史教训)。

## 故意保留墙钟的地方(改了就是新 bug)
| 位置 | 用途 | 为什么必须墙钟 |
|---|---|---|
| link.dart:294 | 日志行前缀 [HH:MM:SS] | 人要读"几点发生的事" |
| link.dart:541 | 生成 session_id 的时间戳段 | 给人看、可排序的标识,不参与间隔计算 |
| link.dart:557 | agent:hello 信封 ts | 线格式字段,服务端据此排障/落库 |
| viewer.dart:1036 | viewer:subscribe 信封 ts | 同上 |
| viewer.dart:1318 / 1378 | lastFrameLocalMs | 界面用 DateTime.now() 与它相减算"离线多久" |
| viewer.dart:1579 | 缓存 saved_at | 下次启动要跟真实"上次保存时间"比,单调基准跨进程不可比 |

## Rust 侧持久化结论:**没有任何持久化**(换基准安全)
依据:
  · rust-core 全仓无 `fs::` / `File::create` / `write_all`(grep 无命中),
    状态机不落盘、不跨进程传递;
  · `reconnect_at_ms` 只是 `Transport` 的普通字段
    (transport.rs:465 定义、495/503/511/531/570/577 使用),生命周期 = FFI 句柄对象;
  · `Serialize/Deserialize` 只挂在 `Envelope`(transport.rs:134),不含状态机时间戳;
  · 故"进程内单调基准"完全安全,不需要动存档格式。
一处**既有**的轻微混用(本次未改,rust-core 不在边界内):`with_capacity`
用 Rust 自己的 `now_ms()`(系统时钟,transport.rs:487)给 HalfOpenDetector 播种,
而宿主喂的是单调值 —— 两者只差"进程启动→建句柄"的毫秒级偏移,且所有比较都走
saturating_sub,不会出现负数或大跳。若要彻底单一时间源,可让 host 在
onConnected 首次喂值时覆盖播种(建议另开一条)。

## 新增测试 client/test/clock_test.dart(16 项)
① MonotonicClock:10 万次调用严格不减 / origin 权威(origin=0 时读数必须是个位数
   毫秒级 —— 这一条能抓住"包一层 DateTime.now()"的假实现)/ 贴近墙钟 / 实例独立 /
   注入可恢复
② **真 Rust 状态机**(走 FFI,不是模拟):正常推进到点才重连(差 1ms 不触发);
   回拨 1 小时连喂三拍全 Idle(缺陷本体的证据);同一实例改喂单调值立刻 ReconnectNow;
   前跳 1 小时只触发 1 次 ReconnectNow(不连发,避免唤醒后连接风暴)
③ ViewerSession:注入脚本时钟 → policy 收到的 now 就是脚本值,且全部不是墙钟量级;
   等 700ms 让 500ms 事件循环跑一拍 → nextAction 收到的 now 也来自时钟且单调不减
④ 默认路径:LinkHub / ViewerSession 不传 nowMs 时确实用 processNowMs
⑤ 源码结构守卫:三处不得退回 `_now() => DateTime.now()`,不得再出现假单调函数名,
   clock.dart 必须真的用 Stopwatch 算增量(与 theme_guard_test 同一套纪律)

## 变异自证(逐个注入 → 变红 → 恢复 → 绿)
1) clock.dart 的 nowMs() 改回 DateTime.now()(假单调)→ 3 项红(含"origin 权威")
2) viewer.dart 忽略注入时钟、改回 DateTime.now() → 4 项红,含两条 ★ 行为断言
   ("policy 收到的 now 就是脚本值" / "事件循环 now 单调不减")
3) link_hub.dart 默认时钟改回 DateTime.now() → ④ LinkHub 用例红
4) link.dart 默认时钟改回 DateTime.now()(采集端无行为观测缝)→ ⑤ 源码守卫红
恢复后:16/16 绿。

## 测试数字
- cd client && flutter test:**485 通过 / 0 失败**(基线 469,净增 16)
- cd rust-core && cargo test --release:**61 通过 / 0 失败**(未动 rust-core)
2026-10-27 08:21:55 +08:00
PMon Dev e9c02c981e fix(退避): 严格单调断言偶发失败——是断言过严,不是 jitter 源码 bug
## 结论:断言过严(抖动实现本身正确,未改退避算法)
`apply_jitter_downward` 只向下扣 [0, capped*ratio],结果恒落在
[capped*(1-ratio), capped] 内,**绝不突破 max_ms** —— 上限这条硬承诺没被破坏,
抖动叠加方式也没有写错,不存在"源码 bug"那一路。
严格单调不成立是设计内的必然:封顶后 capped 恒为 60s,扣减量
offset = (now_ms() ^ capped.rotate_left(13)) % (span+1),每跨 1 毫秒 offset 就 +1,
于是后一个样本比前一个正好小 1ms(实测 59009 → 59008、50901 → 50900)。
原断言要求"相邻样本单调不减",等于要求这件不可能的事。

## 复现(真复现,非推测)
- 并行单跑该用例:48 次内命中
  `退避应单调不减: [... 57955, 57954, 57954 ...]`
- 变异回严格单调后,16 进程 × --test-threads=61 并发整包跑:第 1600 次命中
  `[... 50901, 50901, 50901, 50900 ...]`
单跑命中率约 1/1000(12 次调用里只有封顶区那段约 1µs 窗口可能跨毫秒),
这正是它"平时全绿、负载高才红"的原因。

## 改动
- transport.rs 主用例改按**档位表**断言(capped(n)=min(base*2^n, cap)):
  每个样本必须落在本档位抖动带 [level*(1-ratio), level];相邻样本回落不得超过
  一个抖动幅度(这才是"防退化"该守的不变量);末次必须真的顶进封顶带。
  注释写清为什么不能要求严格单调,防后人改回去。
- 新增 capped_backoff_dips_within_jitter_band:在封顶区**跨毫秒**采样,
  断言"必然观察到回落,且回落幅度 ≤ 一个抖动幅度",把"回落是正常行为"
  钉成可执行断言(而不是靠注释约定)。
- 新增 Backoff::base_delay_ms / max_delay_ms / jitter_ratio 只读访问器:
  测试不再重抄 1000 / 60_000 / 0.3,避免第二个真相源。
- buffer.rs 头注释 "±jitter" 与实现(只向下)不符,一并改正。

## 变异自证
1) 把 dips 用例的容差断言改成 `after >= before`(严格单调)→ 确定性变红 3/3,
   报文 `45363 → 45362`。
2) 把主用例容差断言改回 `w[1] >= w[0]` → 上述并发压测第 1600 次变红,
   特征与原报告一致;恢复后 500 次整包并发复跑全绿。

## 测试
改前 60 passed / 0 failed;改后 61 passed / 0 failed(净增 1 条)。
仅动 rust-core/src/buffer.rs 与 rust-core/src/transport.rs,server/ tests/ client/ deploy/ 未触碰。
2026-10-27 08:09:52 +08:00
PMon Dev 14395692e4 fix(dedup): 去重窗口落 Redis,网关重启后补传不再重复入库(E1)
## 缺陷
`gateway._seqWindow` 是进程内 Map。网关重启窗口即清空,而客户端 session_id 不变、
会重发最多 120 帧补传 → 全部被判成新 seq → 重复入库 → 历史回溯的均值被慢慢拉偏,
且**不报任何错**(只翻库才看得见 seq 重复)。实测复现:10 帧正常入库后重启网关、
同会话重发同 10 帧,`device_metrics` 由 10 行变成 **20 行**(修复后仍为 10 行)。

## 为什么不能放 DB 层(实测已排除,不再试)
device_metrics 在 TimescaleDB 分支上是 hypertable(按 ts 分区),PG 要求分区表上的
唯一索引必须包含分区键,实测报:
  ERROR: cannot create a unique index without the column "ts" (used in partitioning)
而 ts 由服务端改写(同一帧补传时 ts 不同),放进唯一键就丧失幂等语义。
详见 deploy/init.sql 中 idx_metrics_device_seq 上方的注释。

## 改法(不新增任何 Redis 往返)
- store.pushFrameAndFanout(deviceId, frame, dedup):签名加可选第三参,null 时行为与
  改动前完全一致;去重命令**追加在原来那一个 multi 末尾**,返回值改为
  { subscribers, isNewSeq },并改用显式索引(原 res.length-1 会指错项)。
- gateway.onMetrics:按 `seqwin:<device>:<session_id>` 传 dedup。键里带 session_id
  是刻意的 —— 新会话 = 新键,天然实现"换进程就重置窗口"。
- gateway._hydrateSeqWindow:内存窗口不存在时(网关刚重启/设备首次出现)用
  zRange 从 Redis 读回最近 N 个 seq;在 onHello 里 await,**每设备只查一次**。
- 判据优先级:Redis 的 isNewSeq 优先(跨重启权威),内存窗口为兜底(零往返,
  Redis 不可用时用它挡住同进程重复)。
- 裁剪窗口与续 TTL 按 32 帧节流(首帧必发,保证键生下来就带 TTL)。理由见实测:
  三条命令每帧都发时突发 p99 由 6ms 升到 13~15ms(Redis 单线程,突发约 1 万 multi/秒,
  瓶颈是命令条数);节流后每帧 8 条命令,p99 回到改前水平。判重正确性不受影响。

## 失败语义:与配对码**相反**,别"统一"
配对码守的是安全边界(判定不了就关门,失败关闭);去重守的是数据正确性
(判定不了就**放行入库**,宁可偶发重复一行,不可整段历史丢数据)。
故 pushFrameAndFanout 抛错时 catch 住、`_dedupDegraded++`、warn 日志、
把帧照常入队落库,并在 /health 暴露 `dedup_degraded`。代码注释里写明了这个区别。

## 顺带发现并处理(请复核)
无 session_id 的老客户端(1.0.8 及更早)**刻意不落 Redis 窗口**:
没有会话标识就无法把窗口划到会话作用域,若按 `nosession` 建持久窗口,
老客户端进程重启后 seq 从 1 重来 → 全部命中旧窗口 → 被判重复丢弃,
正是当年"永久哑火"事故(这次上界是窗口 TTL,但一小时无数据同样是丢数据)。
实证:去掉这条守卫后连跑两次 tests/persistence-restart.js,第二次
"落库行数与上报帧数一致" 变成「期望 30 实得 0」。现行客户端
(client/lib/host/link.dart 的 _sessionId 每进程生成)不受影响。

## 新增套件 tests/dedup-restart-test.js(22 项,已接入 run-all.sh)
① 重启后同会话重发同批 seq → 行数不变(E1 修复目标)
② 新会话从 seq=1 重发 → 必须能入库(win.sid 历史事故的回归守卫)
③ Redis 不可用 → 帧仍落库 + dedup_degraded 增加 + 补齐不抛
④ 无 session_id 不建 Redis 窗口(见上)
变异自证:把 dedup 传参改成 false(等价"没接线")→ ①② 相关 3 项全红,
恢复后 22/22 绿。

## 验证
- tests/run-all.sh:28 通过 / 1 失败(唯一红 = 隐私扫描的公网 IP 项,本次未触碰)
  其中 持久化与重启恢复 24/24、去重跨重启 22/22、Rust 60/0、Dart 469/0
- cargo test --release:60/0(连跑 3 次;run-all 首跑偶发
  transport::tests::backoff_grows_... 抖动一次,见回报)
- client flutter test:469/0

## perf-bench 前后(同机同一脚本,本地栈;含 3 次交错复测)
改前(HEAD ae90726)burst p50/p99 = 1/6、4/18、5/14、1/9 ms,recv 9434~9709
改后(本次)        burst p50/p99 = 1/4、1/6、1/6、2/7、3/7 ms,recv 9302~9569
steady(定速 100fps)p50 0~1ms / p99 1~3ms,两边一致;churn p50 3ms、offer p50 1ms、
heap 增量 34MB→31MB 均在噪声内。p99 中位数由 14ms 降到 6~7ms,无明显回退。
2026-10-27 00:36:20 +08:00
PMon Dev edc1053278 fix(单实例): 采集端加跨进程锁,GUI 检测到常驻在跑则不自建采集
问题:桌面端有两种合法采集形态 —— GUI 内建(关窗即停)与无界面常驻 agent。
实测确认:加这把锁之前代码里**没有任何跨进程保护**(PmonCore.instance 只是进程内
单例,对另一个进程毫无约束)。两者同时跑会双写同一 device_id:各自 seq 从 1
独立递增,服务端的 seq 去重窗口把两条流混在一起判重 → 丢帧或重复帧,
历史曲线出现台阶与毛刺,且**不报任何错**。

方案 A+C(产品侧确认):
- A(兜底):新增 lib/host/agent_lock.dart,用 dart:io 的 FileLock 做跨进程
  互斥。agent 启动时 tryAcquire(),拿不到就退出。
- C(体验):GUI 启动采集前 isHeldByOther(),为真则不启动自己的采集,
  只做查看,并明确提示"后台常驻采集已在运行,本界面仅查看"。
  而不是静默地少采一半数据。

三个关键细节:
1. agent 抢不到锁必须 **exit 0**。launchd 的 KeepAlive.SuccessfulExit=false
   语义下,非零退出会被当成崩溃并立刻重启 → 两个进程互踢,无限重启循环。
2. 用 FileLock.exclusive(非阻塞)而不是 blockingExclusive:后者会让第二个
   采集进程一直挂着等,用户看到的是"卡住不动"。
3. 锁文件放 ConfigStore.dir(与 config.json 同目录),不另算一份路径 ——
   本项目"同一份路径写两处"已翻车多次,安装路径只能由一处派生。

为什么用文件锁而不是 pid 文件 / 命名管道:
- 不依赖平台:dart:io 的 FileLock 在 macOS/Linux/Windows 通用,
  不必为三个平台各写一套,也是多端扩面的最低成本路径。
- 不会留"僵尸锁":记录锁随 fd 由内核释放。pid 文件方案在 kill -9 后会留下
  死 pid 永久阻塞下一次启动。

验证:
- 单测 6 项(获取/释放/幂等/残留空文件不算占用/探测是只读的/路径口径)。
  其中"探测后别人仍能加锁"守的是最隐蔽的一种自锁 —— isHeldByOther 若忘了
  unlock,查一次就把采集永久锁死。
- **跨进程真实验证**(单测覆盖不到,因为 POSIX 记录锁是进程级的):
  用 Python fcntl.lockf 持锁(与 Dart 的 FileLock 同为 F_SETLK,可互通),
  Dart 侧得到 BLOCKED: Resource temporarily unavailable;
  外部释放后再测得到 ACQUIRED。双向都验过,排除"恒失败"的假绿。
- dart analyze lib bin 无问题;flutter test 469/0(+6)。
2026-10-26 21:21:44 +08:00
PMon Dev 03b30c1534 fix(dedup): session_id 贯通写入链路,并实测推翻"DB 唯一索引"方案
E1(网关重启后补传帧重复入库)的第一段:把 session_id 落到库。

## 做了什么
- device_metrics 加 session_id 列(init.sql 建表 + migrate.sql 迁移,两条路径同源)。
- gateway 入队指标帧时带上 session.sessionId —— 此前 806 行已经在用这个值判重,
  但没往下传,所以 DB 侧永远拿不到它。
- repository 的 INSERT 列清单加 session_id,并补 ON CONFLICT DO NOTHING。
- 固定头列清单(ts/device_id/seq/session_id)收到 metrics-schema.js 的
  METRIC_TABLE_HEADER_COLUMNS,仓储写入与一致性测试都引用它。

## 关键发现:原定的"DB 唯一索引"方案在本项目行不通
架构评审给的方案是建 (device_id, session_id, seq) 唯一索引。我在真实部署形态
(本机 Docker 的 TimescaleDB,device_metrics 有 262 万行)上实测,直接报错:

  ERROR: cannot create a unique index without the column "ts" (used in partitioning)

原因是 device_metrics 是 **TimescaleDB hypertable** —— pg_inherits 里挂着 6 个
_timescaledb_internal._hyper_1_*_chunk,hypertable 按 ts 分区,而 PostgreSQL
硬性要求分区表上的唯一索引必须包含分区键。把 ts 放进唯一键同样不行:
ts 由服务端改写,补传同一帧时 ts 不同,唯一键就失去幂等语义 ——
骨架期那条"不加唯一约束因为 ts 会被改写"的注释想说的正是这点,
它错在把结论下成了"所以不能建",而正确结论是"唯一键不该含 ts,改由应用层承担"。

顺带纠正一个认知:pg_relation_size 在本机报 0 bytes 是 Docker Desktop
VirtioFS 的统计怪象,262 万行是真实的(count(*) 45ms,全在 page cache)。
排查时不要被那个 0 带偏。

## 因此 ON CONFLICT DO NOTHING 是"安全网"而非本层去重手段
现在是 no-op(没有唯一约束)。留着是因为 PG 的 INSERT 是语句级原子:
一批 5000 帧里只要有 1 行撞约束,整条语句抛错、_flushStorage 只 catch 掉
打一行日志 → 整批(含合法帧)全丢。将来若在普通 postgres 分支补上约束,
没有这一句会立刻变成"一条脏数据毁掉整批"。

## 尚未完成(如实标注)
真正的幂等防线要挪到**网关侧持久化去重窗口**(原进程内 Map 重启即清空)。
方案已定:seq 窗口按 (deviceId, sessionId) 落 Redis,用 ZADD NX 的返回值
做原子判重,并并入 pushFrameAndFanout 那个已有的 multi —— 不新增往返
(本项目历史教训就是每帧多次 Redis 往返把帧率上限按死了)。
但这次没做完:_isDuplicateSeq 目前是同步函数、被 seq-session-dedup-test.js 的
28 项直接测,改成异步会波及那批测试,需要单独一轮谨慎处理。

## 验证
本机 TimescaleDB 实测:ALTER TABLE ADD COLUMN 3.67ms(PG16 元数据操作,不重写表);
自带的去重 CTE 在有 262 万行的表上耗时 2.85s(seq scan),这也是为什么
"部分索引只覆盖新数据"的思路值得 —— 但 hypertable 不支持,最终没用上。
schema-consistency 107/0;seq-session-dedup 28/0;run-all.sh 28 套件 27 通过/1 失败
(唯一红为隐私扫描的存量远端引用,本地历史已脱敏干净,待推送后消除)。
2026-10-26 21:18:48 +08:00
PMon Dev cffdc50df2 fix(installer): 安装器不再"撒谎"——按 Rust 给出的成败语义返回非零
P0-6。此前 pmon_install.dart 遍历 install_commands 时,非零只打印一行就继续;
循环结束后**无条件**打印「安装完成」并 return 0。于是 launchctl bootstrap /
systemctl --user enable --now 全部失败,安装器照样宣布成功。

根因在 :137 那行注释:"bootstrap 已加载时 bootout 会返回非零,属预期,不算失败"。
作者为了容忍 bootout 的预期非零,把 bootstrap 的非零也一并容忍了。

危害不是文字不对,而是**进程退出码失去意义**:远程部署脚本、CI 真机构建、
用户自己写的 supervisor 包装,全都只能拿到 0。叠加 P0-1 的 GUI 一键安装,
就是"点一下永远显示成功"——用户信了绿勾就不再自查。假成功比失败更糟。

修法(关键约束):
- Rust 的 AutoStartPlan 里,install_commands / uninstall_commands 从裸
  Vec<String> 改为 Vec<AutostartCommand>{cmd, allow_failure}。
  语义随命令一起产出,**不在 Dart 侧按命令文本猜**——contains("bootout")
  这类白名单等于把同一份语义写在第二处,本项目已因此翻车五次。
- 语义分配:幂等前置动作与增强项 allow_failure=true
  (bootout 未加载时必然非零、loginctl enable-linger 受限环境可能无权限);
  对外承诺 allow_failure=false(bootstrap、systemctl enable --now、
  reg add、schtasks /Create)。卸载侧一律容忍"删本来不存在的项"。
- Dart 侧:install / uninstall / restart 三处只要有必成失败即 return 非零;
  --no-start 未指定时轮询最多 3 秒等进程起来,没起来也算失败(原先只打印 ⚠)。
- restart 里 `id -u` 从平台判断之前挪到之后——Windows 上没有 id 命令,
  原顺序会先抛 ProcessException 而不是给出明确提示。

为什么把执行逻辑抽到 lib/host/autostart_runner.dart:
bin/ 下的私有函数无法被 flutter test 覆盖,而"安装器会不会撒谎"恰恰最该被测住。
抽出后判定逻辑可注入假 runner,新增 autocstart_runner_test.dart 12 项,
含一条回归守卫:全部命令失败时 ok 必须为 false(即 2026-10-25 的缺陷形态)。

新增 Rust 不变量测试:每个桌面平台的 install_commands 至少一条必成,
否则安装失败无从判定(防后来把语义整体宽化改回去)。

验证:cargo test --release 60/0(+1);flutter test 463/0(+12);
dart analyze lib bin 无问题;run-all.sh 28 套件 27 通过/1 失败
(唯一红仍为隐私扫描的存量公网 IP)。
⚠️ 如实说明:CLI 层的退出码接线只经单测与代码审查覆盖,未做真实 install 验证
—— 那会写用户的 ~/Library/LaunchAgents 与真实配置,不宜在本机跑。
真机验证应在测试机或装完立即 uninstall。
2026-10-26 00:10:54 +08:00
PMon Dev ca5c8014f3 fix(护栏): 修好三处"假装在工作"的护栏,并补结构性守卫防复发
体检发现护栏自身有三处缺陷。护栏是后续所有改动的前提,故先修它。

1) schema-consistency-test.js 第 13 段被误包进 if (fail > 0)({ 在 643 行、} 在 668 行),
   全绿路径下那 7 条断言从不执行、脚本照样返回 0;而汇总行又打印在它之前,
   即使执行也不计入统计 —— 护栏看起来在工作,实际没有。
   改为:第 13 段无条件执行 + 汇总移到文件末尾 + 退出码按失败数返回。
   实测 100 通过/2 失败 → 107 通过/0 失败(多出的 5 条正是此前从未跑过的)。
   变异自证:把 candidates.dart 的 kMaxCandidates 8 改成 9,本套件 exit=1 并报
   「上限值一致」;改回即恢复绿。修复前此变异根本测不出来。

2) Rust 与 JS 事件清单漂移:transport.rs 上行 4/下行 6,protocol.js 上行 7/下行 8。
   Envelope::validate() 会拒绝表外事件报 E_BAD_TYPE,属潜伏故障。
   补齐 5 个事件并把长度常量改为 4→7、6→8,同步 transport.rs 里写死数量的单测。
   P2P 入口已下线但代码保留,故事件表按完整集合补齐;代码注释已记明:
   将来若物理删除 P2P 代码,必须同步删掉这三个事件,否则就是给死代码陪绑。

3) theme_guard_test.dart 的 _uiFiles 漏登记 transfer_card.dart(630 行) 与
   skeleton.dart(111 行),共 741 行界面代码不受护栏保护。已补登记。
   两文件本身无硬编码色值,登记后直接绿。

新增 tests/guard-structure-test.js(36 项):汇总行必须晚于最后一个断言 + node --check。
刻意不采用"process.exit 只许一次"(实测 12 个文件各有 2-5 次,前置依赖缺失时早退正当)
与"汇总行缩进为 0"(本仓有脚本主体包在 main() 里,属正常结构)。
变异自证:造一个复刻上述形态的探针,守卫报「汇总@17 最后断言@20」并 exit=1。

验证:run-all.sh 28 套件 27 通过/1 失败(唯一红为隐私扫描的存量公网 IP,另行处理);
cargo test --release 59/0;flutter test 451/0。
顺带订正 run-all.sh 头部过时计数(指标一致性 99→107、Dart 416→451),
并注明计数以实跑为准 —— 静态数 test( 会漏掉 for 循环内动态生成的用例。
2026-10-25 23:58:17 +08:00
PMon Dev 53279ea256 fix(接入层): 覆盖 1Panel WAF 注入点,修复公网 403(客户端此前完全连不上)
★ 这是**客户端连不上**的真凶,也是我上一轮报告里说"仍然待办"的那条。

根因:1Panel 的 WAF 在 http 级注入
  access_by_lua_file /usr/local/openresty/1pwaf/waf.lua
且**只放行登记在 1Panel 站点列表里的域名**。pmon 的接入层是手工落盘的 conf
(不在 1Panel 站点列表),于是所有走 upstream 的请求一律 403;
而 /healthz 是本地 return 200,**反而正常** —— 极易误判成"服务是好的"。

修法:在 pmon 的 server 块里写 `access_by_lua_block { }` 覆盖 http 级指令。
只对本站跳过 WAF,rbook/searxng 实测不受影响(502/301 原样)。

★ 途中踩的两个坑(都已固化进脚本,防止再犯):
  1. **仓库模板的 server_name 是脱敏占位** 203.0.113.10。直接 scp 覆盖生产
     → Host 不匹配任何 server → 落到 1Panel 的 default_server → 又被 403,
     且 /healthz 仍 200,看起来像"生效了"。我第一次就是这么被骗的。
  2. 只替换 server_name 不够:注释里还有一处占位(客户端指向的地址文案)。

新增 deploy/apply-openresty.sh:
  · 全文替换脱敏 IP(可用 --dry-run 预览)
  · **自检生成文件里不得残留占位 IP**(残留就报错退出,不写生产)
  · 自动 nginx -t + reload + 回环自检(打印 /healthz 与 /health 的状态码)

现网验证(从另一台外部机器 <EXTERNAL_HOST>):
  /health → 200,socket.io 握手 → 拿到 sid + upgrades:["websocket"]
  rbook → 502(其自身后端未起)、searxng → 301(均未受影响)
2026-10-17 23:27:21 +08:00
PMon Dev b380c88b06 fix(relay): 单次上限收紧到 1 MiB(可配 RELAY_MAX_BYTES),把「卡死」变「明确拒绝」
背景:生产压测确认中继 >1MB 时吞吐崩塌(2MB 传 20s 只到 139KB)。
缺陷**本轮未修好**,如实记录尝试过的五条路都失败(详见 relay.js 注释与 runbook):
  ① 放大限速 128× → 仍慢;② 摘背压 → 仍慢;③ 背压改 once('drain')
  → 仍慢;④ 完全去掉限速 → 更差(139KB 卡死);⑤ 改 pipeline() → done()
  依赖的 onDrain 被删导致 /relay/open 崩,已回滚。
已排除:容器内 nginx(另修 128KB 卡点)、body-parser(PUT 路由无)、
TCP/内核(容器内纯 HTTP 4MB 响应仅 497ms)。

因此在根因修好前收紧上限:
· MAX_ONCE_BYTES: 2 GiB → **1 MiB**(实测稳定边界:1MiB 337ms 完整,
  2MiB 卡)。可用 RELAY_MAX_BYTES 覆盖,修好缺陷后调大即可、不必改代码。
· 报错文案改 **humanBytes() 自适应单位**(原来写死 "GiB",上限一变就错)。
  文案里给出路:"较大的文件请用局域网直连"(LAN 直连不走中继、不受此限)。
· compose/.env.example 透传 RELAY_MAX_BYTES。
· relay-route-e2e 的 E_TOO_LARGE 断言**解耦单位**(匹配「上限」二字,
  不再绑死 GiB —— 否则改上限就假红)。

现网验证:恰好 1MiB → 完整传输 337ms;1MiB+1 → 409 + 人话;2MiB → 409;
健康检查五项全过。
2026-10-17 20:50:20 +08:00
PMon Dev a5e9f090b5 fix(entry): 非 WS 请求的 Connection 必须为空串,否则长流式响应卡在 128KB
生产压测抓到的真缺陷(**统一入口引入**,四容器时代无此层):

现象:经 3500 传 1MB 中继,只收到 130699 字节就挂死;直打 3000 完整。
根因:容器内 nginx 配了 upstream keepalive 池,却在非 WebSocket 请求上发
`Connection: close`(map 的默认分支写成 close)。两者语义冲突——连接被
标记关闭却要归还到池里,导致上游响应流在 ~128KB(正好是 nginx 的
proxy_buffer 量级)处停住。
修法:map 的 `''` 分支改为空字符串(保持 keepalive 复用),WebSocket 仍 upgrade。
这是 WebSocket + keepalive 并存的标准写法。

修复后:1MB 经 3500 完整传输 0.433s(与直打 3000 一致)。

同时新增 tests/bench-srv.py(生产压测,三维度:HTTP 吞吐 / socket.io 握手 /
中继传输),用共享容器网络命名空间跑,只读不写库。

压测结果(现网 2 核 3.5G):
· HTTP /health:1000 请求 / 1.21s = **827 QPS**,p50 22ms / p99 66ms
· socket.io 握手:200 次 / 0.15s = **1369 QPS**,p50 6.5ms
· 中继 1MB:3.05 MB/s(限速目标 8MB/s 的 38%)
· 中继 10MB:**异常**,见下
· 容器 CPU 峰值 **113%**(2 核吃满 1 核以上),内存 170MB

⚠️ 遗留缺陷(已确认、未修,需专门一轮):
中继传 **>约 2.6MB** 时吞吐骤降到 60-180KB/s(curl 对照一致,非压测脚本问题)。
已排除三个假设:容器内 nginx(本轮已修 128KB 卡点)、限速常量(放大 128× 仍慢)、
背压 pause(摘除仍慢)。剩余嫌疑在 gateway 的 pipe/写入模型。
这是**既有缺陷**(直打 3000 同样复现,四容器时代即存在),非本轮引入。
2026-10-17 20:21:47 +08:00
PMon Dev e591c6579d feat(ops): 可观测性三件 —— PG 日志前缀/慢查询、健康检查逐项输出、看门狗
按评估结论(性能无压力 p99 5-11ms/CPU 0.09%,真风险是"挂了没人知道")
做的三处低成本加固,不改架构:

· PG 日志前缀 + 慢查询(migrate.sql / init.sql,ALTER SYSTEM 幂等):
  - log_line_prefix = '[postgres] %m [%p] ':单容器五进程共用一个日志流,
    PG 原来无标识,排查要靠猜;现在与 [gateway]/[business]/[supervisor] 对齐
  - log_checkpoints = off:每 5 分钟两条 checkpoint 是纯噪音
  - log_min_duration_statement = 200:**慢查询直接落日志**,不依赖
    pg_stat_statements(免装扩展、免重启),是排查"这次为何慢"最省事的抓手
  走 ALTER SYSTEM 而非改 supervisord command:%m/%p 里的 % 会被
  supervisord 的 %(ENV_x)s 插值解析,绕开这类坑。

· healthcheck-all.sh:set -e 改为**逐项 chk + ✓/✗ 输出**。
  原版一挂就退出,docker inspect 的 Health.Log 里只有空输出——
  unhealthy 时看不出是哪一项,等于把排查成本转嫁出去。

· deploy/watchdog.sh(新):每 2 分钟探 /healthz + /health,
  **只在状态变化时**写日志(DOWN/RECOVERED),可选 INKWATCH_ALERT_CMD
  外部通知。装法:/usr/local/bin + /etc/cron.d/inkwatch-watchdog。
  坑:手动以普通用户跑会 Permission denied → 预建 /var/log 与
  /var/lib 下的文件并 chown 给运维用户(cron 以 root 跑不受影响)。
  坑:curl 连接失败时 -w 已打印 000 且退出码非 0,再 `|| echo 000`
  会拼成 "000000" → 改为"输出为空才兜底"。

实测:
- PG 参数生效(show 三项确认)、日志里 checkpoint 噪音消失、
  business/gateway/supervisord 行清晰可辨
- 健康检查输出五项 ✓
- 看门狗:正常 exit 0 / 故障 exit 1 并记 DOWN / 持续故障不重复写 /
  恢复记 RECOVERED;crond 在跑,cron.d 已装
- schema-consistency 100 项(2 个既有 Rust 事件漂移)、
  dockerfile-coverage 45/0、privacy-scan 零命中
2026-10-17 20:02:26 +08:00
PMon Dev 33193aa03a feat(deploy): 单容器收敛为**单一入口 3500**(容器内 nginx 合并两进程)
对外只发布一个口:3500 = 容器内 nginx 统一入口
  /api/  → business(4000)
  其余   → gateway(3000)(socket.io / health / relay)

· deploy/nginx-all.conf(新):容器内入口配置。关键细节:
  - /healthz 本地直返(区分"入口挂了"与"后端挂了")
  - ★ X-Forwarded-For **透传**而非追加:若用 $proxy_add_x_forwarded_for,
    XFF 会变成 "client, 127.0.0.1",而 business 的 TRUST_PROXY_HOPS=1
    取到的就是 127.0.0.1 —— 所有客户端在配对码限流看来是同一个人。
    故用 map:上游有 XFF 就原样透传(hops 语义不变),无才补 remote_addr。
  - relay 必须 proxy_request_buffering off + proxy_buffering off(否则
    上传体被缓冲到临时文件,"不落盘"是假的)
· Dockerfile.all:装 nginx、整份替换 nginx.conf、EXPOSE 只留 3500
· supervisord:加 [program:nginx](QUIT 优雅退出)
· healthcheck-all:加第 5 项(入口 /healthz),入口挂=全站挂,必须一起判
· compose.all:ports 只留 "127.0.0.1:3500:3500"
· openresty-pmon-ip.conf:由"按路径分反代 3000/4000"简化为**单一 upstream 3500**
  ——"哪个路径给哪个进程"只在容器内定义一次,接入层不再有第二份分流清单
· dockerfile-coverage 第 6 段:断言由"≥3 条端口"改为"必须有 3500 且
  **不得发布 3000/4000/5432/6379**"(比原来更严,防绕开统一入口)

实测(现网):docker port 仅 3500;五进程 RUNNING;healthy;
3500 上 /healthz ok、/health 通、socket.io 握手拿到 sid、relay 往返 2048 字节全等;
经 80(OpenResty→3500)再验 relay 128 字节全等。
变异自证:删端口 → 精确 2 红;恢复 → 45 全过。
2026-10-17 19:44:59 +08:00
PMon Dev f8b5cb2684 chore(deploy): 单容器不再发布 5432(最小暴露面)
5432 原本只为本机 psql 直连排障而发布,收益仅是少敲一次 docker exec,
却让宿主多开一个可连的口子。改由 docker exec 进容器排障。

实测:docker port 只剩 3000/4000(均绑回环),devices=6、gw/biz 200。
2026-10-17 19:35:11 +08:00
PMon Dev 12433ccedf chore(deploy): 单容器项目名改为 inkwatch(容器名 inkwatch-app-1 / inkwatch-migrate-1)
改 name: pmon → inkwatch,让容器名与项目名一致。
★ 关键防呆:name 同时是**卷名派生源**,故 volumes 段显式锁定
  pg_data: {name: pmon_pg_data} / redis_data: {name: pmon_redis_data}。
  不锁的话 compose 会去找 inkwatch_pg_data 这套新空卷 = 清库。
实测:切换后卷仍只有 pmon_pg_data / pmon_redis_data(无 inkwatch_*),
devices=6 不变,migrate Exited(0),gateway/business 200。
镜像同步改名 pmon-all → inkwatch-all(docker tag 复用,不重建)。
2026-10-17 19:28:51 +08:00
PMon Dev ca47186615 fix(deploy): 单容器 compose 的 migrate 必须共享 app 网络命名空间
首次真跑(腾讯云切换 all-in-one)时抓到:migrate 连 -h 127.0.0.1 却
在自己的网络命名空间里,pg_isready 无限循环、迁移永远不跑。
四容器时代用服务名 postgres 所以没暴露;单容器化时漏了这条。
加 network_mode: "service:app" 后 migrate Exited(0)。
2026-10-17 19:22:03 +08:00
PMon Dev 4170838e45 feat(p2p): 数据面接入 —— P2P 真正承载指标帧(P4)
P3 留下的边界("P2P 是能力而非在用通道")本轮闭合。

· P2pSession(并入 link/p2p_driver.dart):打洞成功后**保留 socket**——
  NAT 映射绑在本地端口上,关掉就废、重开等于重打一次洞。帧协议单包:
  PMON-FRAME-REQ:<nonce> → PMON-FRAME:<nonce>:<json>,payload 与云端
  server:metrics 同形(查看端两套数据源共用一套解析)。nonce 必须回显
  (迟到/串包丢弃);单包上限 8KB,超了宁可不回也不发分片包。

· 采集端:收到带候选的 offer → 开数据面 → startServing(() => _latestForLan)
  (与局域网直连同一份数据,不新造缓存)。

· 查看端:收到带候选的 offer → 开数据面 → 2 秒节拍拉帧 → seq 去重后走
  同一条摄取路径 → hub 记 LinkMode.p2p;连续 3 次无响应即关闭回落。

· LinkHub 加 observeP2pProbe(P2P 的分数进选路,打通与否如实记录)。

★ 两个坑(都已修,且值得记):
  1. RawDatagramSocket 是单订阅流——"打洞一个监听、数据面换一个监听"会抛
     "Stream has already been listened to",异常被 catch 吞成 null,表现
     为"打洞无声失败"。只能一个监听器活到底、按前缀分派。
  2. 测试辅助函数把对端候选端口写死,参数只改了本地端口 → 只有第一组
     恰好对上,其余全挂。辅助函数必须全参数化。

测试:Dart 451 全过(+会话 6 项);变异自证(响应 nonce 写死 → 拉帧用例
精确变红);run-all 25 通过 / 2 失败(仅剩既有 Rust 事件集漂移)。

限制(诚实):候选目前随文件 offer 交换,故指标走 P2P 发生在该对设备
发过一次文件之后。要"订阅即可 P2P"需把候选上报挪进 agent:hello/快照,
属协议扩展,下一轮做。
2026-10-17 18:59:10 +08:00
PMon Dev de609af2a8 feat(link): 切换状态机 + 手动覆盖 + 徽标显示模式(P3 编排)
· link/handover.dart:切换状态机 idle→decided→proving→overlapping→
  retiring→done,失败 aborted(旧路保持)。不含定时器——超时由调用方
  isStalled() 判,测试不必等真时间;跳跃/倒序一律忽略(宁可停住也不
  做半截切换)。接入 LAN 进/出两个方向:探测证通即进重叠期,首帧到达
  才退役旧路。时序与既有行为一致,只是把散落 if 变成可读相位。

· LinkHub.setPreferredMode():手动覆盖跳过评分与迟滞(人的判断优先于
  算法);指定的路不可达时回落自动并在日志说明——不能因为用户选了
  一条不通的路就装死。

· ViewerSession:linkSummary / preferredMode / setPreferredMode;
  ViewerStatus 加 linkLabel / linkDetail;强制云端时禁用内网自动切换。

· UI:顶栏徽标「在线/断开」升级为「在线 · 内网」,点击弹菜单
  (自动/内网/云端/P2P/中继),勾选回显当前偏好;全走主题令牌。

★ 过程中抓到并修掉一处真事故:最初用 hub.changes.listen → _setStatus
  做同步,导致 widget 测试内存暴涨被系统杀(SIGTERM 137,且**回退页面
  改动后仍复现**才锁定是会话侧)。根因是"流订阅→状态更新→页面重建"
  在有周期重建的页面里形成失控环。改为三处显式同步(云端连上/进内网/
  离内网)后正常。教训已记入设计文档 §9。

测试:Dart 445 全过(+Handover 6、+偏好覆盖 2);页面 28 项回归;
run-all 25 通过 / 2 失败(仅剩既有 Rust 事件集漂移)。
2026-10-17 18:22:23 +08:00
PMon Dev 50f4abb1d4 feat(p2p): STUN 候选 + UDP 打洞 + offer 候选交换(P2)
能力就位(不是端到端打通互联网 NAT —— 见文末诚实边界):

· net/candidates.dart:host/srflx 两类候选(relay 类别不做——本项目的中继
  是独立通道,再做 ICE relay 候选等于再造一套)。上限 8、按 endpoint 去重、
  脏项逐条丢:打洞器会对候选发包,脏地址等于替攻击者打探测。

· net/stun.dart:RFC 5389 最小 Binding 客户端(零新依赖,约 100 行纯逻辑)。
  只做两件事:发请求、解 XOR-MAPPED-ADDRESS。安全要点:校验事务号
  (防串包投毒)、只认 Binding Success、截断不抛。

· link/p2p_driver.dart:UDP 打洞。同时向所有候选发 PUNCH:<nonce>,
  收 PONG/PUNCH 即成功并回 ACK;先通先赢,死候选拖不住活候选。
  **非 PUNCH/PONG 的包一律忽略**——否则一次随机 UDP 扫描就能让
  "打洞成功"变成谎言(有专门用例守着)。对称 NAT 打不通如实返回失败。

· offer 管线:candidates 作为增量元数据随 agent:offer/viewer:offer 传递;
  服务端 normalizeCandidates 校验(host 只收私网、srflx 只收公网、
  拒回环/云元数据、上限 8),不参与"三条路至少一条"的判定。

· schema-consistency 第 13 段:MAX_CANDIDATES ↔ kMaxCandidates 相等、
  类型词汇一致、srflx 私网禁令在位(跨语言护栏)。

测试:Dart 436 全过(416 + STUN/候选 12 + 打洞 7 + offer 解析 2);
lan-endpoint 41 项(+14 候选);run-all 25 通过 / 2 失败(仅剩既有
Rust 事件集漂移)。变异自证:摘 host 私网禁令 → 恰 1 红。
网关容器已重建确认是新代码(grep normalizeCandidates = 2)。

诚实边界:真实跨网打洞成功率取决于双方 NAT 类型;对称 NAT 必失败→落中继。
数据面接入(P2P 承载指标/文件)属 P3 编排。
2026-10-17 16:04:44 +08:00
PMon Dev e8e6fb3ec1 feat(relay): 中继客户端收口到连接域,并补两层端到端验证
P1 阶段的结论先行:**「中继端到端未打通」是过时批注**——对活网关实测,
服务端三条路由与客户端三个函数一直是通的(open 200 → PUT/GET → 字节
全等)。真缺的是自动化验证与代码归位。本轮补上:

· net/relay_client.dart(227 行):LanDownloadResult / receiveToFile /
  LanRelayChannel / openLanRelay / uploadViaRelay / downloadViaRelay 从
  host/lan_service.dart 原样抽出(纯搬迁,export 转出,调用方零改动)。
  这样 link/ 不必反向依赖 host/,分层纪律不破。

· link/driver.dart:TransportDriver 契约(mode / probe / close)。P1 的
  RelayDriver 是第一个实现者,契约按真实需要定;数据面方法故意不立
  (四种模式传输形态差异仍大,此时统一只会造空壳适配器)。

· link/relay_driver.dart:探活走 GET /health(**不烧配额**——探活若走
  open 会占满"每账号并发 1"的闸,选路一频繁用户就永远"请等前一条");
  openForOffer 委托 openLanRelay;upload/download 委托既有函数。
  host 的 openRelayForSend 改为仅注入凭据、逻辑全交驱动。

· tests/relay-route-e2e.js(14 项,需网关):打真路由验八组——
  完整往返 / 401 缺身份 / E_BAD_SIZE / E_TOO_LARGE / E_TOO_MANY(并发 1)/
  超声明即断连且不超量 / 错向令牌 404。relay-test.js 测不到"路由怎么接
  进来"(曾出过类全绿、路由 503 的事故)。

· client/bin/relay_check.dart + tests/relay-client-e2e.js(6 项):跑客户端
  自己的 Dart 实现打真网关——这是「服务端通 + 客户端函数在」真的连起来
  用的唯一证据。CLI 输出机器可读 JSON,e2e 断言字节数与 sha256。

· client/test/relay_driver_test.dart(7 项,真起本地 HttpServer):
  health 200+ok / degraded 200+ok:false 按不可达 / 500 / 端口没人听 /
  缺凭据抛人话 / 409 中文原因原样上抛 / close 幂等。
  最关键一条:**探活只许打 /health,绝不许打 /relay/open**(变异自证:
  改成打 open → 精确变红)。

全量:Dart 416 项全过(409+7);run-all 26 通过 / 3 失败——3 个失败中
2 个是既有(指标字段一致性 / FFI 冒烟的 Rust 事件漂移),第三个是隐私
扫描(另一笔提交信息含本机 denylist 词,已在下一轮处理)。
2026-10-17 10:54:34 +08:00
PMon Dev 46b5b3b050 refactor(link): P0 收口 —— 抽 net/ 原语层、立 link/ 连接域、ViewerSession 挂旁路度量
统一应用多模式连接设计的第 0 阶段(纯收口,行为零变化):

· net/probe.dart(59 行)+ net/offer.dart(246 行):从 host/lan_service.dart
  原样抽出探测(probeLanEndpoint/pickReachableLanEndpoint)与 offer/ticket
  编解码(LanIncomingOffer/LanTicket/parse*)。原文件只留 export 转出,
  既有调用方(viewer/transfer_card/测试)零改动——全量 409 项证明纯搬迁。
  这是为四驱动共用做准备:探测与邀约解码只许有一份。

· link/link_mode.dart:四种模式(云端/内网/P2P/中继)的词汇与可解释评分
  (100 − RTT/30 − 丢包×2 − 成本权重,钳 0–100)+ 迟滞常数 8 分。
  成本进分数是刻意的:轻量服务器按流量计费,不把成本写进排序,
  "自动选路"就会在半夜把流量包烧光。

· link/link_hub.dart:每(会话 × 对端)一实例的连接枢纽。**P0 只观测、
  只建议、不控制**——数据面仍走 ViewerSession 老逻辑。推荐变化经既有
  onLog 诊断出口可见("先让走哪条路变成可验证的事实,再去动数据面")。

· host/viewer.dart:十一处观测点接线(+18 行纯插入、零删除):云端连/断/
  连接失败、LAN 探测结果、LAN 数据面开/关、两条摄取路径的帧来源、stop 时回收。

测试:link_hub_test 16 项(评分三部件逐一钉住/迟滞边界恰好 8 分不换 9 分换/
活跃路追踪/observeRtt 不凭空造度量/表恒 ≤4 有界);viewer_test +1 接线测试。
变异自证 ×4:删迟滞 → 2 红;坏评分公式 → 4 红;摘云端观测 → 1 红;恢复即绿。

全量:Dart 409 项全过;viewer-e2e 真集成 21 项全过(真网关真会话)。

与设计的两条偏差已记入本地设计文档 §9(TransportDriver 接口推迟到 P1
等第一个真实现者;P0.3 以事件旁路接线代替驱动实例)。
2026-10-17 09:43:27 +08:00
PMon Dev c060efa4fe chore(docs): 文档类文件下架仓库,只留本地(政策入 .gitignore)
用户定:除 README / LICENSE / .env.example 外,文档类文件只在本地保存,
除非项目运行离不开才提交。docs/ 全部 11 个文件(架构方案、专项设计、
腾讯云运维篇、改名记录、性能基准 JSON、两份旧名历史存档)无一是运行
依赖(引用全是注释与文档间链接),整体移出。

本地存档:~/WorkBuddy/水墨屏工具/inkwatch-docs-local/(412K)。

同时清掉 patch_relay_v2.py —— 一次性字符串手术脚本(给旧 relay 修复
用),现在跑只会损坏 relay.js,属运行无关物。

连带修正(不留悬空引用):
· README:目录树去 docs/、单容器取舍改为内联、perf-bench 数字改本地存档
· client/README.md:三处 docs 引用去路径留实质
· eink_board.dart / config_migration.dart:注释去路径留实质
· docker-compose{,.all,.prod}.yml:三处"详见 docs/…"改为内联关键纪律
· privacy-scan.js:quotepath 判据的例句随中文名文件消失而改写,判据保留
· .gitignore:加 docs/ 与 patch_*.py 段(政策机械固化,防再误提交),
  顺手去掉尾部重复的 .git-rewrite 条目

验证:隐私扫描 207 文件零命中;镜像覆盖 43;Dart 392 全过;
全仓库 docs/ 悬空引用为 0。
2026-10-17 02:23:37 +08:00
PMon Dev 1238e5d530 docs: README 补单容器入口与 perf-bench、套件计数 18→25;改名文档登记历史方案存档
仓库审计(提交后)发现的三处文档漂移:
· README 快速开始缺单容器路径入口(runbook 第七节写了但门面没指路);
· README 验证段缺 perf-bench(性能口径工具,动热路径前后各跑一次);
· README 套件计数停留在 18,实际 25(本次全量跑出来的实数);
· rename-inkwatch.md 未登记 docs/ 下两个带旧名的历史方案文档
  (pmon-v4-架构方案-V4.0.html / 商业化实施方案V4.0.docx)——
  内容是 PMon 时代原文,刻意保留文件名作历史存档,登记备查。

TODO/FIXME 全库零命中;架构文档为纯设计定位,部署内容以 runbook
为准(第七节已含与 V4.0 第 11 章的互切说明),不强制对称。
2026-10-16 22:57:42 +08:00
PMon Dev 42ad9ffae5 chore(privacy): 文档示例改 RFC 5737 文档段地址 + 扫描规则窄放行边界探针
lan_service.dart 的服务器基地址示例换成 203.0.113.7:3000 并注明
「示例用文档段地址,勿写真值」。历史重写(tree-filter)已把含真实
IP 的三个提交改写,工作树这处是同一脱敏的最后落点。

privacy-scan 的公网 IPv4 规则加窄放行:172.15.0.1 / 172.32.0.1
两个精确串是 172.16/12 私有段判定的边界探针(tests/lan-endpoint-test.js
与 client/test/lan_service_test.dart 用),它们存在的意义就是
「形状是公网 IP 但不是私网」——换成文档段地址就测不到「把 172 整段
误判成私有」的回归。只放行精确串,不给任何真实地址开口子。

全量扫描(工作树 + 全部提交 + 提交信息):219 文件 / 59 提交零命中。
2026-10-16 22:48:00 +08:00
PMon Dev 29c4a9c846 deploy(all-in-one): 单容器部署路径(四进程一个容器,应用代码零改动)
一个 pmon-app-1 容器跑 postgres + redis + gateway + business
(supervisord 各自守护、各自重启),外加跑完即退的 migrate。
合容器不合进程:两个 node 进程仍是独立进程,SIGTERM 直达原生优雅
关闭处理器,全部既有安全测试原样有效。

关键不变量(均有测试看守):
· compose project 仍是 pmon、数据卷仍是 pmon_pg_data/pmon_redis_data
  → 与四容器路径随时互切、数据不动(卷名锚点,改名=清库);
· 端口全部绑 127.0.0.1(云防火墙全端口放通,0.0.0.0=公网直露);
· init.sql「空数据目录才初始化」语义复用官方 entrypoint,不重写;
· 启动门 start-{gateway,business}.sh 复刻 depends_on service_healthy,
  防全新卷首启时 node 抢在 PG 初始化前 FATAL 停摆;
· 安全开关两个进程同读一份环境,一处传全。

dockerfile-coverage-test.js 扩展:「all」镜像双入口逐断 require 图、
安全开关通路按 compose 文件分别断言(含服务名映射)、新增第 6 段
端口回环断言(IP 前缀可选——裸映射 "3000:3000" 恰是绑 0.0.0.0 的
最危险写法,第一版正则漏了它,审计加固)。

代价如实记入 runbook 7.3:进程隔离弱于四容器、重启四进程齐动、
business 启动参数比四容器多堆上限(D-1)。
2026-10-16 22:47:43 +08:00
PMon Dev 21b1a561a3 test(perf-bench): 性能基准工具 + 前后对照数据存档 + 接入 run-all
tests/perf-bench.js:四场景基准(metrics 定速/突发/内存/服务端计数器、
churn 订阅风暴、offer 定向时延、scan 索引微基准),--json 机器可读。
存在理由:性能优化必须"前后同一把尺",不接受"应该更快了"。

本地四容器栈的镜像可能比仓库旧(曾吃过 09-15 镜像的亏:测的根本不是
当前代码)——工具注释里写明测前先 up -d --build 并用 docker exec grep
验证代码版本。本次基准就是这么发现容器缺 viewer:unsubscribe 的。

docs/perf-baseline-{before,after}-2026-10-14.json:5+5 轮完整存档。
结论:旧代码突发呈双峰(~4800/~9350fps 两档),新代码全快档;
中位墙钟 0.42s→0.21s、p50 8ms→2ms、p99 17ms→5ms、丢帧 0。

run-all.sh 接入三个离线套件(会话索引 50 / 登录限流 22 / 中继 30),
计数标签随实际断言数修正。
2026-10-16 22:47:13 +08:00
PMon Dev 35ee33ef13 perf(gateway): 热路径 4→1 次 Redis 往返 + 会话索引 O(n)→O(1),附三个正确性修复
onMetrics 每帧原 4 次顺序往返(pushFrame → touchPresence EXISTS →
markOnline → getSubscribers),合并为 pushFrameAndFanout 单 multi;
touchPresence 的 EXISTS 与写入并入同一 multi(2→1)。语义逐项对齐:
presence 只写 {ts}、SMEMBERS 原样返回、任一步抛错整体失败。
pushFrame / markOnline 两个再无调用方的旧入口按"同一件事不留两个
入口"的纪律删除。

三处全表扫描(Array.from(this.sessions...))改索引:
_agentByDevice / _viewersByAccount 存同一 session 对象引用(单一真相源
仍是 sessions 表),hello/subscribe 登记、disconnect 摘除;
_findAgentByDevice 带 !s.gone 竞态防护。首帧标记 Set<字符串> →
Map<账号, Set<设备>>,回收从 O(全表) 降为 O(该账号设备数),计数账目平。

正确性修复(均经变异测试自证:删掉即红、恢复即绿):
· relay sweep 双类判定:传输中按 lastActivityAt(每 chunk 刷新),
  修「慢传被误删致每账号并发 1 额度统计破」;挂死传输仍有兜底;
· LoginThrottle 加 FIFO 硬上限 + hit() 命中移队尾(淘汰序 FIFO→LRU),
  修「一次性 key 喷洒把热点 key 计数冲掉、限流被绕一截」;
· 角色翻转(协议违规:双角色挤一条连接)对称摘另一侧索引,
  修「索引留死 session 引用、反复违规无界增长」;
· relay 限速定时器覆盖前先取消,修「与背压 drain 交错时限速被突破一瞬」。

getOwnerId 加缓存(只缓存非 null —— 缓存 null 会造出"设备刚注册
2s 看不了"的拒绝窗口;owner_id 三个写点均不改非空值,已逐一实证);
claimDevice 成功后广播失效(纵深防御)。

实测(2000 帧突发 ×3 查看端,5+5 轮,tests/perf-bench.js):
墙钟 0.42s→0.21s、吞吐 ~4800→~9350fps(+95%)、p50 8ms→2ms、
p99 17ms→5ms、丢帧 0;稳态 68fps/订阅/offer 无差异。
2026-10-16 22:46:38 +08:00
PMon Dev 886eb61af9 fix(relay): 三个真 bug + 简化「谁先到都行」;⚠️ 但中继端到端仍未打通
## 背景:三层降级上线后做真机验证

产品负责人要求实测,臣写了探针用**客户端自己的代码**走三层
(另写一份测的只是"我以为的逻辑")。**第一层局域网直连当场全绿**,
**第三层中继连续三次失败** —— 过程与结论如下。

## 修掉的三个真 bug(都值得记)

**① 失败的中继会占满并发额度**
一次失败(对端没点收下)会让空壳会话活到总 TTL(10 分钟),
期间再开中继永远是"请等前一条结束"——**而那条根本没人传**。
→ 加 `sweepUnpaired()`:`open` 时先回收"两端都没连、且过了等待窗口(3 分钟)"的空壳。

**② HEAD 预检被 Express 的 `app.get` 抢走**
`app.get('/relay/:id')` **会自动处理 HEAD 请求**,且注册在 `app.head` 之前 ——
于是 HEAD 落进"下载端"逻辑,拿**上传令牌**去验**下载令牌**,必然 404。
症状是"发送端永远等不到对端已就绪",而日志只有一串 E_BAD_TOKEN,**看不出是 HEAD 引起的**。

**③ 客户端 close 顺序导致死锁**
原写法"先 `await req.close()` 拿响应头、再写 body" —— 但 Dart 的 `close()`
要等 body 完整才把请求发出去,于是它等一个永远不会来的响应,**请求根本没出门**。
⚠️ **服务端用 curl 手测全绿**(curl 是先发 body),**只有真客户端能暴露它**。

## 简化设计(前两次都在打补丁,这次换形态)

取消"发送端先探对端在不在"那整套预检 —— 它对,但脆。
改成 **`attachUploader` 对端没到就挂着等**,接收端一到就叫醒它。
"**谁先到都行**"本来才是中继的自然语义,客户端因此什么都不用协调:
直接写完 body 再 close 即可。

## ⚠️ 但仍然没打通

当前症状:**接收端(GET 侧)的连接被重置**,服务端日志为
`[relay] 中断: 接收端断开`。臣**三次尝试后停下**,不再盲试 ——
手上有明确下一步(查 GET 侧为什么先断:嫌疑在 `HttpClient.idleTimeout`
默认 15 秒,与"挂着等对端"的时长冲突),但**没有验证过的结论,
所以不写成"已修复"**。

## 现在的真实状态

- ✅ **第一层:局域网直连** —— 真机验证通过(4MB,sha256 一致)
- ❌ **第三层:中继** —— **未打通**,用户走到这层会看到"中继传输中断"
- ⚠️ **第二层:公网直连** —— 逻辑完整,但无真实公网可达地址可测
- 服务端 `relay-test` 18/18、客户端 392/392、analyze 干净(**它们覆盖不到这个缺陷**)

## 教训

**"服务端用 curl 测过"不等于"客户端能用"** —— 这次两个 bug(③ 与 ②)
都是只在真客户端形态下才现形的。**端到端探针必须用真代码走真链路**,
手测只能证明服务端自己没错。
2026-09-19 08:47:15 +08:00
PMon Dev 16879e0a56 test(transfer): 三条路的解析用例(+5)
新增解析用例,重点守两条最容易漏的:
  * **只有中继(没有内网端点)也必须能解析** —— 三层里的最后一层,
    解析不了就等于没有,而不在同一局域网也能传全靠它;
  * **三条路一条都没有 → 不解析**,即不发一条哪个都走不了的 offer。
另守:走直连必须有领取令牌;中继 id/token 缺一视为没有中继(不当半个对象用)。

客户端 387 → 392/392。
2026-09-19 07:52:59 +08:00
PMon Dev 28d53611e6 feat(transfer): 三层降级 —— 局域网 → 公网 → 中继(1.2.0)
产品负责人拍板"优先选快的、局域网优先、公网次之、中继兜底、中继要有限制"。
本提交补齐**客户端**这一环,三层第一次真正接通。

## 服务端:新增 wan_endpoints 与放宽 offer 校验

- **新增 `wan_endpoints`**(公网直连):与 `lan_endpoints` **分开两个字段**,
  这样"私有优先"的排序才有地方落地,服务端也能一眼看出要去连的是内网还是公网。
  ⚠️ 但两者**共享同一条禁令**:`169.254`(云元数据,SSRF 头号目标)/ `127` / `0.x`
  **无论出现在哪个字段里都不收**。"允许公网"不等于"允许任何地址"。
- **放宽 `_buildOffer`**:早先硬性要求"必须有内网端点",那会让"不在同一局域网"
  **直接变成功能不可用**。现在改成**三条路至少有一条**。
  中继那段服务端**只校验形状** —— 取数权在 32 字节随机令牌上,
  伪造 id 没用、把 id 转给别人也没用,所以不必回查会话。

## 客户端

- **中继通道**(`openLanRelay` / `uploadViaRelay` / `downloadViaRelay`)
- **下载校验只有一处**(`_receiveToFile`):局域网与中继**共用同一份收尾比对**。
  各写一份迟早漂移,而这是"文件到底对不对"的唯一判据。
- **中继凭据按 `frameProvider` 同一模式注入**:查看端本来没有设备令牌,
  而采集端总有 —— 且两者同处一个 App、采集端总在跑,所以两端都取得到。
- **上传要等对方**:中继是"两条连接对接后直接 pipe",发送端早到了没地方去。
  故**轮询重试**(先等响应头证明管道已建立,再发字节),
  这样不必为"对方开始收了"再加一个信令事件。

## ★ 界面上必须明示走的是哪条路

- 走中继:已走**中继**…会消耗流量包 + 限速 X MiB/s
- 走直连:已走**局域网直连**(最快、且不消耗服务器流量)
- 接收侧完成后同样标注「本次经中继中转(不落盘)—— 消耗了服务器流量包」
  / 「本次走直连 —— 未消耗服务器流量」
- 中继**上传进度条只在真走中继时出现** —— 直连是秒传的,
  给它加进度条只会让人以为卡住了。

## 验证

- 信令端到端 **14/14**,四条最关键:
  * **只有中继、没有内网端点 → 仍能发出**(三层的前提)
  * 公网端点走 `wan_endpoints` → 通过
  * **云元数据地址在 `wan` 里同样被拦**
  * **三条路都没有 → 被拦**(不发一条"哪个都走不了"的 offer)
- 客户端 **387/387**,analyze 干净,Android release 构建通过(19.9MB)
- 线上:`/relay/open` 经公网 → 200 并返回两条一次性令牌
- 出包 dist/inkwatch-eink-1.2.0.apk

⚠️ 诚实边界:真机形态(两台设备分别处于同网段 / 不同网段)未实测。
2026-09-19 07:52:08 +08:00
PMon Dev 878aa50bb5 chore(deploy): 仓库 nginx 配置补 /relay/ 段(用 upstream 名)
上一提交没找到锚点(仓库这份用 upstream 名 pmon_gateway、注释是中文,
与生产那份的字面 socket.io 不同),臣**没有硬塞**,这次按实际结构补上。

★ 关键两行:proxy_request_buffering off / proxy_buffering off ——
nginx 默认把请求体缓冲到临时文件,那样服务端不落盘就是假的。

生产侧已改并验证:外网 POST /relay/open → 200 且返回两条一次性令牌。
2026-09-19 00:36:00 +08:00
PMon Dev 7bd123ac8e chore(deploy): 仓库里的 nginx 配置补 /relay/ 段
生产接入层已改并 reload 验证(外网 /relay/open → 200)。
仓库这份同步补上,保证**能重建出同样的接入层** —— 只改服务器不改仓库,
下次重新部署就丢了,而这类只在线上存在的配置最难排查。

★ 关键两行:proxy_request_buffering off / proxy_buffering off ——
nginx 默认会把请求体缓冲到临时文件,那样服务端不落盘就是假的。
这不是性能调优,是这个功能成立的前提。

(生产那份在 /opt/1panel/www/conf.d/pmon-ip.conf,改完需 reload 才生效 ——
 臣当时改了没 reload,从公网打仍是 404,白排查了一阵。
 reload 用 kill -HUP master 即可,因为 openresty 可执行不在 PATH 里。)
2026-09-19 00:35:05 +08:00
PMon Dev 2d04728b91 feat(relay): 文件中继核心 —— 流式转发、服务端不落盘、四道限制
产品负责人拍板了"三层优先级:局域网 → 公网直连 → 中继(要有限制)"。
本提交做**中继核心**,它是唯一能保证"不在同一局域网也传得成"的一层。

## 为什么"不落盘"而不是"传完就删"

原始要求是"文件不保存在服务端"。落盘再删**不满足**它:中间那段时间磁盘上
真有这份数据,而服务器只有 45G 可用、一次大文件就可能填满。
所以这里是**两条 HTTP 连接对接后直接 pipe** —— 字节从发送端进来、
立刻从接收端出去,进程内存里只有管道缓冲。
(测试里真的起了两个 HTTP 连接跑一遍,并断言**服务端目录里没有多出文件**。)

⚠️ 代价如实说:**两端必须同时在线**。这与"不落盘"是同一枚硬币的两面。

## 四道限制("中继传输要有限制")

| 闸 | 值 | 越界行为 |
|---|---|---|
| 单次大小 | 2 GiB | 拒绝并说明上限 |
| 每账号并发 | 1 | 拒绝,提示等前一条 |
| 限速 | 8 MiB/s | 节流 |
| 每账号每日 | 20 GiB | 拒绝并给出已用/上限 |

另外:**假 size 绕不过**单次上限(传输中累计,超了直接断),
配额**按实际字节记**(不是声明的 size)。
任何一道越界都**明确告知用户**,不是静默失败。

## 安全

- **上传/下载各自一条一次性令牌**,拿到一个不能当另一个用(有用例守着);
- 令牌比较用 `timingSafeEqual`,不按字符提前返回;
- 数据面失败一律 **404,不区分"不存在"与"令牌错"** —— 避免给爆破反馈。

## ⚠️ 测试抓到的真 bug(值得记)

`waitDownloader` 最初只接 `(id, token)`、**没接住下载端的 `res`**,
于是配对时拿不到 pipe 的目标。**只测"开/拒"永远不会暴露它** ——
是"真起 HTTP 跑一遍"这条用例抓出来的。

另一条教训:臣第一版断言"系统临时目录前后条目数相等",
**那是假失败** —— tmp 是全局共享的、别的进程随时在写。
**量不出来时先怀疑量法**(本项目已多次踩)。

## 验证

- 服务端 `relay-test` **15/15**:四道闸、令牌双向隔离、过期、
  **真传一遍且字节一致**、**服务端不落盘**、按实际字节记配额、假 size 被截断

## ⚠️ 未完成(如实说明)

1. **接入层(nginx)未落定**:中继走 HTTP,外网访问必须过 nginx,
   且**必须关掉 `proxy_request_buffering`**(默认会把请求体缓冲到临时文件,
   那样"不落盘"就是假的)。臣改了 `/opt/1panel/www/conf.d/pmon-ip.conf`,
   **但排查发现它似乎不在 openresty 的 include 链路里** ——
   生效配置的路径未确认,故**不敢说这一环做对了**。
2. **客户端三层降级未做**(局域网 → 公网端点 → 中继的候选排序与界面明示)。
2026-09-19 00:32:18 +08:00
PMon Dev 0d72f13235 feat(lan): 局域网数据面(分支同步,1.1.9)
与主干同一份实现(产品级)。同步前逐个核对基线:分支已有上几轮的改动,
本轮差异是纯新增;viewer_page 的看板自检页因两分支差异大,用锚点精确插入。

要点见主干上那条提交,关键三条:
  · **两套数据源必须互斥** —— 两条都在跑意味着云端流量照烧,
    而那正是本功能要省的东西;
  · **三道闸防重复**:_lanMode 时忽略云端帧 / 轮询按 seq 去重 /
    两条路共用同一个 _ingestMetricsPayload;
  · **不等退订回执就切** —— 在途帧由第一道闸丢掉,等回执只慢一拍。

验证:客户端 383 → **387/387**,analyze 干净,Android release 构建通过。
⚠️ 切换时序仍无自动化覆盖,真机形态验证待两台设备同局域网实测。
2026-09-19 00:16:35 +08:00
PMon Dev 553ddef497 feat(lan): 局域网数据面 —— 指标走内网、退掉云端订阅(省流量)
方案文档里的 ①(LAN 优先)在本提交闭环。之前几步已备好地基:
端点交换、可达探测、以及 `viewer:unsubscribe` 这个退订事件。

## 为什么必须**互斥**,而不是"两条都用"

同一个设备的两套数据源会各送来同一帧。纵使有 seq 去重,"两条都在跑"
也意味着**云端流量照烧** —— 而那正是本功能要省的东西。
所以要么走内网、要么走云端,**不同时**:进内网就退云端订阅,
内网连续失败 3 次就重订云端。

## 三道闸防止"同一帧画两遍"

1. **`_lanMode` 时忽略云端帧** —— 退订是异步的,回执到达前可能还有在途帧;
   不拦就会出现重复点;
2. **内网轮询按 seq 去重** —— 轮询**必然**反复拿到同一帧(2 秒一拉、
   帧可能 10 秒才出一张),这是常态不是异常;
3. **两条路共用同一个 `_ingestMetricsPayload`** —— 各写一份解析迟早出现
   "一条修了、另一条没修",且 seq/断点/能力集处理必须完全一致。

## 一处刻意的简化

**不等退订回执就切**:`_lanMode` 一置真,在途的云端帧就被第 1 道闸丢掉,
不会重复。等回执只会让切换慢一拍,换不来确定性。

## 采集端侧

`/metrics/latest` 返回**与云端 `server:metrics` payload 完全同形**的形状
(device_id / ts / seq / metrics)—— 查看端才能共用一套解析,
少一处"内网来的要特殊处理"的分支。`frameProvider` 由 link 注入,
本模块仍只依赖 `dart:io`,不知道 link 的存在。

## 验证

- **真机验证端点**:未采到帧 → `204`("还没采到"是正常状态,不是错误);
  有帧 → `200` 且四个字段齐备、形状与云端一致 ✅
- **防重复判据抽成纯函数** `shouldAcceptLanFrame` 并单独测 4 条 ——
  它嵌在轮询回调里就只能靠真起 socket 才验得了,而"跨层状态不同步"
  恰恰是本项目最难复现的一类问题
- 客户端 323 → **327/327**,`analyze` 干净,Android release 构建通过
- 看板自检页区分「**走内网**」与「可达(未切换)」——
  只看端点可达不足以判断省流量有没有生效

## ⚠️ 诚实边界

**切换时序(进内网/回落的重订时序)没有自动化测试覆盖** ——
它要驱动 `ViewerSession` 的私有状态机,本轮没做到。
已验证的是:判据本身(纯函数,4 条)、端点行为(真机)、退订语义(服务端 E2E)。
**真机形态验证仍待做**:需要两台设备在同一局域网下实测。
2026-09-19 00:15:33 +08:00
PMon Dev ddf890ed47 feat(transfer): 客户端接上信令(主干同步)
与 feature/eink 同一份实现(产品级,不属于水墨屏)。
同步方式:**先逐个 diff 核对基线**(确认主干已有上几轮的改动、
本轮差异是**纯新增**)→ 再取这五个文件;`main.dart` / `viewer_page.dart`
因两分支差异大,用锚点**精确插入**。

要点见 feature/eink 上那条提交,这里重述两条最重要的:
  · **两条路并存**:免粘贴通了走它,不通自动退回"把码发给对方" ——
    不是二选一(那会让"不在同一局域网"变成功能不可用);
  · **`didUpdateWidget` 是主动补的**:宿主的会话对象异步创建、卡片先建,
    只在 `initState` 订阅会**永久漏掉**通道("时灵时不灵",极难定位)。
    这与本项目刚修过的 `AccountCard` 是同一类问题。

验证:主干 `dart analyze lib test` 干净、客户端 319 → **323/323**、
Android release 构建通过。
2026-09-19 00:07:53 +08:00
PMon Dev 9f5abdc74d feat(transfer): 客户端接上信令 —— 免去手工粘贴领取码(1.1.8)
服务端侧上一步已就绪。这一步把两端接上:
选完文件**自动通知对端**,对端那边直接出现待领提示,不用再转发那条码。

## 两条路并存,而不是二选一

免粘贴通了就走它;**不通就自动退回"把码发给对方"**。
刻意不是二选一 —— 那会让"不在同一局域网"直接变成功能不可用。
界面把这两种结果**分开说**:能通知对端时不给用户增加任何动作;
没通知成时明确要求"请把这条码发给对方"。

## 共用一份解析(单一真相源)

`parseIncomingOffer` 放在 `lan_service.dart`,**两端共用** ——
采集端与查看端收到的是同一个事件形状,各写一份解析迟早会漂移
(本项目为此吃过多次亏)。

## ★ 一处必须补的坑:`didUpdateWidget`

宿主(采集端页/查看端页)的会话对象是**异步创建**的:卡片先建、`_agent`/
`_session` 后到。若只在 `initState` 订阅,这个通道会被**永久漏掉** ——
症状是"免粘贴时灵时不灵",而且极难定位。
**这与本项目刚修过的 `AccountCard` 是同一类问题**(入参缓存必须配 `didUpdateWidget`),
所以这次是**主动**加上并写测试守住的,而不是等它变成故障。

## 避免重复实现

手工粘贴与免粘贴**共用同一个下载实现**(`_download`)—— 两条入口、一套逻辑。
分开写迟早出现"一条路径修了、另一条没修"。选端点也复用
`pickReachableLanEndpoint`(对端可能报多个,先挑真的可达的那个)。

## 验证

- 卡片测试 5 → **9 条**,新增:收到待领文件显示"谁给的/什么/多大"、
  「忽略」后不再纠缠、**流入参后到也必须订得上**(守 didUpdateWidget)、
  没有免粘贴通道时粘贴那条路仍可用(降级而非不可用)
- 客户端 379 → **383/383**,`analyze` 干净
- Android release 构建通过:`com.inkwatch.eink` v18 / 19.8MB
- 出包 `dist/inkwatch-eink-1.1.8.apk`

⚠️ 服务端**无需再部署**(信令在上一步已上线)。
2026-09-19 00:06:18 +08:00
PMon Dev 2f7f85624f feat(signal): 传文件信令 —— 协议与网关(免粘贴的地基)
产品负责人要"设备间传文件"免去手工粘贴领取码。这需要一条信令通道。
本提交只做**服务端侧**(协议事件 + 网关转发 + 端到端验证),
客户端接线留作下一步 —— 一次改太多、又验不透,是本项目最贵的一类错误的温床。

## 协议:新增 3 个上行 + 2 个下行事件

| 事件 | 方向 | 作用 |
|---|---|---|
| `agent:offer` | 上行 | 采集端发布"有个文件待领" → 广播给该设备的订阅者 |
| `viewer:offer` | 上行 | 查看端发布 → 定向送到目标设备的采集端 |
| `viewer:unsubscribe` | 上行 | **退订** —— 局域网直连时改走内网,必须能退掉云端订阅 |
| `server:offer` | 下行 | "有人要给你一个文件,凭这些信息去取" |
| `server:unsubscribed` | 下行 | 退订回执 |

## ★ 三条设计取舍

**① 服务端刻意不解析"领取码"的格式。**
载荷里把 `lan_endpoints`(数组)与 `token` **分开传**:端点走
`normalizeEndpoints` 校验(只收私有网段 —— 与 hello 里**同一条防线**,不因
"这次是 offer"而放宽),而那个码是客户端之间的约定,服务端多懂一层
就多一处要跟着改的地方。

**② `viewer:offer` 必须先过授权。**
`isAuthorized(target, accountId)` 复用同一套判据。少了这一步,
"发文件"就成了**绕过授权往别人设备推东西**的口子。

**③ 退订只摘 Redis 订阅,不动 socket 房间。**
房间是**账号级**(`viewer:<accountId>`,一个账号可能多端在线),
而订阅是**设备级**。把房间退掉会连带断掉该账号看别的设备的连接 ——
那是另一回事。转发路径本就按 `getSubscribers(deviceId)` 查,摘订阅即生效。

## 验证:端到端穿透(`tests/tools/lan-signal-e2e.js`)

真起 socket 走完整链路,**8/8 通过**:
  · `agent:offer` → 订阅者收到,端点/文件名/大小原样到达
  · **公网端点在 offer 这条路上同样被拦**(没绕过同一条防线)
  · `viewer:offer` → 目标设备的采集端收到
  · `viewer:unsubscribe` → 收到回执,且**此后不再收到该设备的帧**
    (这一条是"省流量"能不能成立的前提 —— 退不干净就是空话)

另:跑 `lan-signal-e2e` 时发现订阅本身有授权校验(匿名订阅被 `NOT_PAIRED` 拒),
这在设计之内,脚本里如实写明。
2026-09-19 00:00:05 +08:00
PMon Dev 247f5c6491 chore: pubspec 去掉一行孤立注释,与主干保持一致
先写 file_picker ^8 再改成 ^13 时留下的说明,内容已被上方那段(解释为何不能低于 13)
完全覆盖。留着会让两分支的 pubspec 多一处无谓差异 ——
而同一份清单两处定义正是本项目反复踩的坑,注释同理。

现在 pubspec 与主干**只剩 version 一行差异**(两支刻意不同的版本号)。
2026-09-18 23:17:37 +08:00
PMon Dev 49a9537f2c feat(ui): 设备间传文件的界面(主干同步)
与 feature/eink 同一张卡片(该功能是产品级的,不属于水墨屏)。
同步方式:**新文件直取**(`transfer_card.dart` 与测试),
三个有差异的文件(`main.dart` / `viewer_page.dart` / `pubspec.yaml`)
一律**锚点精确插入**,且插入前逐个确认锚点唯一 ——
主干版本号保持 `1.0.9+9` 不动。

功能与纪律要点见 feature/eink 上那条提交,这里重述两条:
  · **「先确认再收」**:粘贴码 → 看清将收到什么 → 才出现「收下」;
    输入一变,上一次的确认立刻作废(防"看 A 收 B");
  · ⚠️ **加原生依赖必须真跑一次 Android 构建**:
    `file_picker` 8.x 自身用 android-34 编译,而其传递依赖要求 36+,
    `pub get` 与 `analyze` 都过得去、**构建却会直接失败**。升到 13.x 解决。

验证:主干 `dart analyze lib test` 干净、客户端 314 → **319/319**、
**Android release 构建通过**(19.7MB)。
2026-09-18 23:17:09 +08:00
PMon Dev ff070e8b7f feat(ui): 设备间传文件的界面 —— 发出 / 接收两个区(1.1.7)
把上一步的领取码接上界面。放在**采集端页与查看端页各一张** ——
"我的设备间传文件"是双向的:任一台都可能既发又收。

## ★ 一条贯穿全卡片的纪律:「先确认再收」

接收是**两段式**的:粘贴码 → **看清"将收到什么(来自哪台机、什么名字、多大)"**
→ 再点「收下」。**不给"粘了码就自动开下"的路径** —— 文件会落到接收方磁盘上,
用户有权在写盘之前知道那是什么。
(与本项目对"破坏性操作要先列清单再确认"的态度一致。)

配套的一条细节:**输入内容一变,上一次的确认立刻作废** ——
否则会出现"看着 A 的信息、收的却是 B"这种最不该有的错。

## 保存到哪

优先**下载目录**(Android 上是 app-specific 外部目录,**系统文件管理器看得到**),
拿不到才退回应用私有目录。存在用户翻不到的地方等于"传了个寂寞"。

文件名来自对端,**只取 basename**(把分隔符换掉)—— 不让对端用路径做别的事。

## ⚠️ 引入依赖时踩的坑(值得记)

加 `file_picker` 后 **Android 构建直接失败**:
```
Dependency ':flutter_plugin_android_lifecycle' requires ... compile against version 36 or later
:file_picker is currently compiled against android-34.
```
即 **`file_picker` 8.x 自身用 android-34 编译**,而其传递依赖要求 36+。
→ 升到 **13.x** 解决(顺带 API 变了:`FilePicker.pickFiles()` 是**静态**方法、
返回 `List<PlatformFile>`、用户取消返回**空列表**而非 null ——
"取消"不是错误,不该被渲染成红色提示)。

⚠️ **教训:加原生依赖必须真跑一次 Android 构建**,
`pub get` 成功、`analyze` 干净都**不代表能构建**。

## 验证

- 新增 5 条界面纪律用例(没确认就没有「收下」、垃圾码给人话错误且不放行、
  有效码先显示"将收什么"、**改输入即作废旧确认**、发出区初始无码)
  → 客户端 374 → **379/379**,`analyze` 干净
- **Android release 构建通过**:`com.inkwatch.eink` v17 / 19.7MB
  (比上版大 0.6MB,来自 file_picker + path_provider,合理)
- 出包 `dist/inkwatch-eink-1.1.7.apk`(正式签名 CN=InkWatch)
2026-09-18 23:15:41 +08:00
PMon Dev 5f382fbca8 feat(lan): 同步领取码(主干 f16bdc3)
与主干同一份实现。同步前已 diff 确认两文件在编辑前一致,故可安全取;
并顺手把 pubspec 里 crypto 的注释与主干统一 —— 两次插入的文本有引号差异,
虽无害,但同一份清单两处定义正是本项目反复踩的坑,注释也不该例外。
(pubspec 现在只剩 version 一行差异,那是两支刻意不同的版本号。)

验证:客户端 368 → 374/374,analyze 干净。
2026-09-18 23:00:29 +08:00
PMon Dev e48f9336d9 feat(lan): 领取码 —— 把"去哪取、凭什么取"编成一段可转给对方的字符串
场景确认为「**我的设备间传文件**」。跨设备传"令牌"本可以走服务端信令,
但那要给采集端加寻址 + 改下游事件白名单 + 部署(中改 + 生产风险)。
先用**码**把可用闭环做出来:发送方出示、接收方粘贴,
数据仍然是**两端直连、不经服务器**。信令优化留给下一步。

## 码里放什么

端点、令牌、**文件名与大小**(接收方在下载前就能看到"要收什么"——
这是"先确认再收"的前提)。**不放哈希**:完整哈希从响应头拿,
放进码里只会让它变长而不增加保证。

实测码长 127 字符(含中文文件名),复制粘贴与二维码都装得下。

## ★ 安全:端点必须只允许私有网段 IPv4

这条不是格式检查,是**安全底线**:码是从**外部粘贴进来**的,
里面的端点会被拿去做 HTTP 请求 —— 若放行主机名/公网地址,
粘贴一条码就等于让应用去连任意地址(SSRF 面)。
(而本功能的前提本就是"同一局域网",公网地址也不该出现在这里。)

测试里专门造了 `evil.example.com:80`、`http://...`、
`192.168.1.5:80/../../etc/passwd` 这类输入,**必须全被拒**。

⚠️ 服务端 `server/shared/lan-endpoint.js` 有语义相同的 JS 实现 ——
**那不是重复,是纵深防御**:两端处在不同进程、不同信任位置,
客户端这道是"别去连不该连的地方",服务端那道是"别把不该发的发出去"。
跨语言无法共享代码,故两处都要有、判据必须一致。文件里已写明。

## 令牌 32 → 16 字节

场景是"同一局域网 + 5 分钟 TTL",128 位随机在这个窗口内爆破不现实;
而令牌要塞进码里发给对方,每多 16 字节码就长 22 个字符 ——
抄错与二维码变密的代价是真实的。32 字节是给长期凭据用的强度,这里不是那个场景。

## 验证

- 新增 6 条用例(往返含中文名、**端点必须私有网段**、令牌字符集受限、
  各类垃圾输入 → null 而非抛异常、字段缺失/类型不对、空白容忍)
  → 主干 308 → **314/314**,`analyze` 干净
- **完整闭环真机演示**(走用户实际路径):
    登记 → 出示领取码(127 字符)→ 解析出「季度报告.pdf 16.1 KB」
    → 直连下载 → 校验 ✓ 通过 → 内容一致 true → 撤销后登记数 0

## 下一步

UI(发送方选文件 → 出示码;接收方粘贴码 → 收下)。那需要引入文件选择器,
是**界面层**的依赖,与"传输层零原生依赖"不冲突。
2026-09-18 22:59:38 +08:00
PMon Dev 419da432eb feat(lan): 文件传输 A 方案(主干同步)
与 feature/eink 同一份实现 —— 该能力是产品级的,不属于水墨屏。

⚠️ 同步方式**刻意不整文件复制**,且这次**先 diff 确认**再动手:
  · `lan_service.dart` 与测试:先跑 `git diff main feature/eink` 确认**一致** → 可安全取;
  · `pubspec.yaml`:**已确认不一致**(版本号 `1.0.9+9` vs `1.1.6+16`,是刻意不同的)
    → 改用锚点**精确插入** crypto,主干的版本号保持不动。
  (上一轮臣就是没查就整文件覆盖,把水墨屏内容带进主干、导致主干编译不过。)

功能与安全要点见 feature/eink 上那条提交,这里重述三条最关键:
  · **令牌即授权,不拿"同网段"当授权**(合规红线:物理接近 ≠ 授权);
  · 令牌无效/过期一律 **404 而非 403**(403 会泄漏"令牌存在过");
  · 校验结果 `verified` 是**返回值**而非日志 ——
     "没坏"与"验过了"是两件事。

验证:主干 `dart analyze lib test` 干净、客户端 **308/308**(301 + 新增 7 条)。
2026-09-18 22:53:48 +08:00
PMon Dev 6ade1320db feat(lan): 文件传输 A 方案 —— 内网直连,数据不经服务器
方案文档里的 ③,也是产品负责人的**原始诉求**:文件不落服务端、
端到端传输、支持超出服务器磁盘容量的大文件。
实现落在第一步做出的那个"只绑内网的小服务"上 —— 这正是把底座先做出来的回报。

## 端点

| 端点 | 作用 |
|---|---|
| `offerFile(path)` | 登记一个待传出的文件,返回带**一次性令牌**的句柄 |
| `GET /file/<token>` | **流式**返回,支持 `Range` 断点续传 |
| `revoke(token)` | 传完即撤销 |

文件从发送端磁盘**直接流到**接收端磁盘,服务端一分钟都不参与 ——
所以既不受服务器 45G 可用磁盘的限制,也不烧流量包。

## 安全(这一块比功能重要)

- **令牌即授权,不拿"同网段"当授权** —— 那等于把物理接近当成授权依据,
  正是隐蔽监控的形态(方案 2.1 的合规红线)。令牌由上层显式发放,与配对码同一思路。
- **令牌不存在/过期一律 404,刻意不用 403**:403 会泄漏"这个令牌曾经存在",
  等于给爆破一个反馈信号。
- **路径不来自请求**(由登记时的本地路径决定)→ 结构上就没有路径穿越。
- **`stop()` 清空登记**:停止服务 = 不再共享任何文件。否则下次 start 会带着
  上一轮的令牌复活 —— 令牌不该比它所属的那次服务活得更久。
  (这条是被测试逼出来的:用例间单例状态残留,一查才发现语义本身就不对。)

## 校验:把"传完了"和"内容对"分开

`LanDownloadResult.verified` 是**返回值的一部分**,不是一行日志。
大文件最常出的问题是"字节数对得上、内容不对"(断点拼接错位、对端文件被换过)。
对端没给哈希时**不算验过** —— "没坏"与"验过了"是两件事,不能拿后者冒充前者。

哈希走**流式**(`sha256.bind(file.openRead())`),大文件不整个读进内存
—— 那正是本模块要解决的问题本身。

## 验证

- 新增 7 条用例(端到端 sha256 一致、令牌错 404、撤销后 404、
  Range 206 + 分片内容正确、越界 416、中文文件名 URI 编码、登记不存在的文件报错)
  → 客户端 361 → **368/368**
- **真机实测**(本机真起服务、真传文件、真校验):
    源文件 64.0 MB
    登记(含流式 sha256)502 ms
    传输 64.0 MB / 601 ms ≈ **106 MB/s**
    端到端校验 ✓ sha256 一致,字节数一致
  → 1GB 也就十来秒,**"超出服务器磁盘的大文件"这条诉求成立**

## 依赖

新增 `crypto`(Dart 官方、纯 Dart、极小):大文件传输没有端到端校验是不合格的,
这个依赖值得。未引入任何原生插件,安装包体积基本不受影响。
2026-09-18 22:53:12 +08:00
PMon Dev 84ac9ddce3 test(lan): 内网端点交换的端到端穿透冒烟(跨四层,真 socket)
按项目纪律「**凡多层持同一份清单,必须有真实形态数据端到端穿透**」补上 ——
这条链路横跨四处:客户端上报 → 网关校验 → 网关存会话 → 随快照下发 → 客户端接收。
单测只能覆盖"网关校验"那一处,而本项目最贵的一类 bug 恰恰是**跨层静默丢数据**
(历史上白名单过严把 capabilities 全剥掉,谁都没发现)。

跑法(本脚本自带环境,不碰生产):
  起临时 redis + 本地网关(无 PG 走 Redis 授权回退)→ 手动写一条 auth 授权 → 跑

★ 实测结果(**证明安全规则在链路里真的生效**,不只是"函数会拦"):
  上报 5 条(2 条合法 + 公网地址 / 主机名 / 云元数据地址各 1 条)
  → 快照里只有 ["192.168.1.5:49504","10.0.0.7:1234"]
  → 三类非法值**在真实链路里被拦掉**
  再次全部非法上报 → 旧端点**被清掉**(快照不再带该字段),
  避免查看端拿着上一轮的过期地址去连

过程中顺带确认了两件既有机制(都不是本次改动引入的):
  · 订阅有**授权校验**,匿名订阅会被 NOT_PAIRED 拒 —— 头一次跑就撞上了,
    这反而说明授权守得住;本地无 PG 时走 `auth:<deviceId>` 的 Redis 回退;
  · 无 PG 时网关是 stateless,不落库。

另:生产网关已同步部署并核对 —— `sha256sum` 与本地**一致**,
即生产跑的就是这份被 E2E 验证过的代码。
2026-09-18 22:47:51 +08:00
PMon Dev fcd7d24803 feat(eink): 自检页显示「对端内网」可达性
把第二步的探测结果露出来 —— 否则"走内网还是走公网"只是代码里的一个变量,
用户看不见,也就没法判断到底有没有生效(本项目一贯要求可验证)。

**三种状态必须分得开**:
  · 对端未上报(可能不在同一局域网)
  · 已上报但探不通(会列出试过哪些端点)
  · 可达(显示具体是哪个端点)
混成一句"不可用",用户就没法判断是"我这边的服务没起来"还是"对端根本没报"。

⚠️ 诚实边界:探测结果**不是实时推送的**,在下一次节流刷新时反映到界面(最多 10 秒)。
这是刻意的取舍 —— 为一个诊断行加一条推送通道要多一处要维护的接口,
而看板本来就有节流刷新,够用。

客户端 361/361、analyze 干净。
2026-09-18 22:40:20 +08:00
PMon Dev 977067c114 merge(main): 合入内网端点交换与可达探测
# Conflicts:
#	client/lib/host/lan_service.dart
#	client/lib/main.dart
#	client/test/lan_service_test.dart
2026-09-18 22:40:20 +08:00
PMon Dev 723852e1da feat(lan): 内网端点交换 + 可达探测(第二步,仍未改数据面)
第一步做出了只绑内网的小服务,这一步把"对端能否走内网"变成**可见的事实**。

## 服务端:新增内网端点校验(`server/shared/lan-endpoint.js`)

⚠️ **这是本步最关键的一处,不是"再算一遍"**:`lan_endpoints` 是
**客户端上报的不可信输入**,而它会被下发给**查看端**,查看端据此去连接。
不校验就原样下发,等于给了一个**"让别人的手机去连任意地址"的 SSRF 面** ——
上报 `169.254.169.254:80` 或某个公网地址,查看端就成了跳板。

故服务端只收 **IPv4 私有网段**(10/8、172.16/12、192.168/16),
端口 1..65535,最多 4 条,超长/主机名/IPv6 一律丢弃,
且**整条丢弃而不是"截断成合法"**(猜用户意图比丢掉更危险)。

> 客户端的 `pickLanAddress` 也在挑地址,但那是**选"发哪个"**(可用性),
> 这里是**验"收的是否合法"**(安全性)—— 两者在**信任边界两侧**,
> 不是同一份判定的两处拷贝。这条写进了文件头,免得后人当重复删掉。

网关侧:端点存**进程内 Map(刻意不落库** —— 内网地址是瞬态的,
换 Wi-Fi 就变,落库只会给查看端一个过期地址、让它白等一次超时),
随 `server:snapshot` 下发;**设备断连即回收**(同一设备重连时保留,
与 `_viewerEncoding` 的回收同一处理)。

## 客户端

- 采集端:`agent:hello` 带 `lan_endpoints`(**没有内网地址时不带该字段**,
  而不是带空值 —— "没有"是合法状态,不该让查看端去猜空列表的含义)
- 查看端:收端点 → **只在列表变化时**探测一次(快照会反复到达,每次都探是浪费)
  → 记录"可达的是哪个"
- 探测超时**刻意短(800ms)**:探测只决定走哪条路,而**云端本来就通** ——
  为了内网快而让用户每次先卡几秒,是把优化的代价转嫁给体验。
  探不通立刻回云端,用户感知不到这一步
- 探测**要求 body 里 `ok:true`**,光有 200 不算 —— 同网段里别的服务也会返回 200

## 诚实边界

⚠️ **本步只做「探测 + 让用户看得见」,还没有改数据面** —— 指标仍走云端。
先让"走内网/走公网"成为**可验证的事实**再动数据面,
否则改完也说不清到底有没有生效(本项目吃过"静默失败"的亏)。
界面显示留在下一步(看板自检页)。

## 验证

- 服务端 27 条用例(**重点在不合法值必须被拦**:公网地址、云元数据地址、
  回环、端口越界、主机名、IPv6、超长、超条数)→ 已接进 run-all
- 客户端探测 3 条(真起服务判可达、真起一个"只返回 200 内容不对"的假服务
  判不可达、多端点取第一个可达)→ 客户端 **301/301**
- 服务端离线套件 **7/7 PASS**
2026-09-18 22:38:13 +08:00
PMon Dev 10aa9f4119 feat(lan): 局域网直连底座 —— 只绑内网的小服务(主干同步)
与 feature/eink 同一份实现(该底座是产品级的,不属于水墨屏)。
本次同步特意**不整文件复制** —— `main.dart` 在两分支已不同(差 90 行),
上一轮臣就是整文件覆盖把水墨屏内容带进主干、导致主干编译不过。
这次是:新文件直取,`main.dart` 用锚点**精确插入**两处(import + initState/dispose)。

底座的作用与理由见前一条提交。要点重申:
  · 只绑 `pickLanAddress` 选中的那一个内网地址,**绝不绑 anyIPv4**;
  · 只暴露只读无数据的 `/lan/ping`,未知路径 404;
  · 将来接数据/文件用一次性令牌(**不拿"同网段"当授权**);
  · 没有可用内网地址时**返回 null 且不启动**,不退而绑公网。

验证:主干 `dart analyze lib test` 干净、客户端 **298/298**(286 + 新增 12 条)。
2026-09-18 22:29:58 +08:00
PMon Dev 3104aeda8e feat(lan): 局域网直连底座 —— 只绑内网的小服务(第一步)
方案落地第一步。它是两件事的**共同底座**(故先做它,不分成两遍):
  · 指标走内网(LAN 优先)
  · 大文件端到端传输(不经服务器 ⇒ 不受服务器磁盘与流量限制)

⚠️ **这一步最关键的不是功能,是安全**:
绝不能 `HttpServer.bind(InternetAddress.anyIPv4)` —— 那会同时绑到公网网卡上,
等于在公网开了一个端口。故:
  · 只绑 `pickLanAddress` **选中的那一个**内网地址;
  · 只暴露**只读、无数据**的 `/lan/ping`,未知路径一律 404;
  · 将来接数据/文件时用一次性令牌 —— **不拿"同网段"当授权**
    (那是隐蔽监控的形态,见方案 2.1 的合规红线)。

地址选择收敛在 `pickLanAddress` 一条纯函数里,因为**选错网卡的后果最难定位**
(表现是"服务看着开了、对面却连不上")。规则:排除回环 / 169.254 链路本地 /
IPv6 / 虚拟网卡(utun、docker、br-、veth、vmnet、tap…),**私有网段优先**。
没有可用内网地址时**返回 null 且不启动** —— 不退而求其次绑公网。

可测性上做了一处刻意取舍:入参收 `(网卡名, 地址列表)` 简单形状,
而不是直接吃 `NetworkInterface` —— **后者没有公开构造函数**,
吃了它就没法在测试里构造"VPN + Wi-Fi 同时存在"这类最容易选错的场景。

验证:
  · 新增 12 条测试(9 条穷举地址筛选 + 3 条服务本身),客户端 346 → **358/358**
  · 其中一条**真起服务 + 真发 HTTP 请求**(不是"调了 API 就算过"),
    因为"绑了但对面连不上"正是本模块最容易出的问题;并断言未知路径 404
  · **Mac 真机验证**:网卡 en0 192.168.0.85 / en7 192.168.0.12 → 选中前者;
    服务起在 192.168.0.85:49504;/lan/ping → 200 {"ok":true,"v":1};未知路径 → 404
  · `dart analyze lib test` 干净

接线:主壳 initState 启动、dispose 停止;启动失败**不影响主流程**
(局域网只是"更快",不是"必需"),只在看板自检页如实显示状态 ——
**"没开"和"开了但对面连不上"必须能分辨**。

下一步:查看端拿到地址后先试内网、失败回落云端;再做文件传输。
2026-09-18 22:29:11 +08:00
PMon Dev 49b76035d8 test(privacy): 收窄 WARN 噪声 —— 单字母域名不可能是真实邮箱
护栏的邮箱一条是 WARN(只列不判红),但规则太紧:本地部分限 1-3 字符,
于是测试夹具 `typed@x.com` / `other@y.com` 每轮都被列出来,
18 条噪声把真正的信号淹了。

判据其实是**域名**:`x.com` 这种单字母域名不可能是真实的企业或个人邮箱,
本地部分多长都不改变这一点。故放宽本地部分到 1-12 字符,域名仍限单字母。

(这与"护栏总报红就没人看"是同一类问题:噪声会让人对信号麻木。)
2026-09-18 22:22:21 +08:00
PMon Dev a5b0d3f3f9 merge(main): 合入文件传输与局域网专项功能设计方案
# Conflicts:
#	tests/privacy-scan.js
2026-09-18 22:20:41 +08:00
PMon Dev 52cc4bf4d6 docs: 文件传输与局域网专项功能技术方案
产品负责人提出两个需求:① 增加文件传输,不落服务端、端到端、支持超出服务器磁盘的大文件;
② 设备接入局域网时增加一组专项功能。本文给出可行性判定与具体设计。

**结论**:三条诉求都可行,但**必须先说清三条硬约束**,否则方案会打脸:

① **现有协议不能承载文件**。实测 `shared/protocol.js` 的边界:
   字符串 ≤512 字节 / 单层键 ≤64 / 嵌套深度 1 —— 它是为小控制消息
   (capabilities、keepalive)设计的,1MB 的分片连第一个约束都过不去。
   → 文件必须走**独立通道**,不能塞进 agent:metrics 那套。
(顺带确认:「对象一律丢弃」那段注释是**历史缺陷记录、已修**,
 现在允许一层嵌套对象,capabilities 机制可用。)

② **比磁盘更该担心流量包**。腾讯云轻量按流量计费,服务端中继一个 10GB 文件
   = 上行 10GB + 下行 10GB = 双倍。所以"不落盘"只是第一步,
   **"不经过服务器"才是关键** —— 这两件事必须分开说。

③ **P2P 不是总能打通**(对称 NAT/CGNAT)。失败就得中继,而中继就是②里烧流量的东西。
   这个取舍必须显式做出来并让用户知情,不能默默降级。

**文件传输三方案**:A 同网段直连(服务端零流量、100% 成功、零新依赖)
/ B 公网 P2P WebRTC(零流量但引入 flutter_webrtc 增重)/
C 服务端流式中继(唯一会烧流量的)。
推荐 **A 优先 → B 兜底 → C 默认关闭**,理由是收益/代价比而非技术偏好。
信令只需三拍,且**形状落在现有协议边界内,不动 protocol.js**。

**局域网专项五项**:① LAN 优先直连 ② LAN 高速采样 ③ LAN 大文件传输
④ 断公网可用(需采集端有界落盘)⑤ 链路自检(让用户看得见走了哪条路)。
建议顺序 ①②→⑤→③→④。

⚠️ **本文划了一条合规红线**:**同网段不能成为免授权理由**。
方案 2.1 的定位是"监控自己或经明确授权的设备、不提供绕过对方操作的隐蔽监控",
若做成"连上同一 Wi-Fi 就能看到",等于把物理接近当成授权依据 ——
那正是隐蔽监控的形态,与过审与杀软信誉直接冲突。
故:局域网只降低流量与延迟,**不降低授权门槛**;不做同网段自动发现入库。

另附风险清单(流量包、内置 HTTP 服务被扫、采集端落盘配额、
flutter_webrtc 增重、局域网误判不能只凭 IP)。
2026-09-18 22:20:20 +08:00
PMon Dev 99deda3980 test(eink): 探针脚本改为"先查谁占着设备",并记下 Neo2 无 ADB 通道
2026-09-17 为了连上 Neo2 绕了四轮弯路,根因**不在设备侧**:
Neo2 被 Parallels 直通进了虚拟机(`prl_vm_app` 持有它的 user client),
之后释放了又被 Microsoft Edge(Chromium WebUSB)认领。
而脚本当时只会说"未发现设备,请检查 USB 调试" —— 于是排查一直被引向
设备侧(开关、拔插、描述符),迟迟找不到主机侧的真凶。

改法:把"设备在总线上"与"根本没插上"**分成两条路**:
  · 在总线上 → 打印它暴露的接口类别 + **谁持有它**(user client 占用者)
  · 不在总线上 → 才是线/开关的问题
并明确写出 ADB 接口的判据(`255 / 66 / 1`)。

命令用的是**按设备递归取子树**(`ioreg -r -n "<名>"`),
不是全量 grep 拼字段 —— 后者会让不同节点的字段串在一起,
臣据此一度误判"Neo2 有 ADB 接口"。

⚠️ 同时记下一个**已确认的硬事实**:iReader Neo 2(SmartOS / Android 10)
在主机侧**无任何占用者**、设备侧 USB 调试开关反复确认已开的情况下,
仍只暴露一个名为 `MTP` 的 `255/255/0` 私有接口 ——
**该机型不产出 ADB 通道**,调试通道不可用。以后不要再在这上面花时间,
改用设备内「自检」页取数。

(脚本细节:`$NAME」` —— bash 会把紧跟其后的多字节字符字节并进变量名,
故一律写成 `${NAME}`。)
2026-09-18 00:13:34 +08:00
PMon Dev 408940b282 fix(eink): 看板全屏 —— 系统栏与主壳导航都要收起来(1.1.6)
真机反馈「开看板后底下的导航键没隐藏」。查下来是**两个成因,都要治**:

① **系统导航栏**:代码里**从未**调过 `setEnabledSystemUIMode` —— 一直显示。
   修法:进看板 → `SystemUiMode.immersiveSticky`;退出 → `manual` + 全部 overlays。
   · 用 `immersiveSticky` 而非 `immersive`:前者在用户误触屏幕边缘后**会自动重新隐藏**,
     副屏是常驻的,不该被一次滑动永久唤出系统栏。
   · 恢复用 `manual` + `SystemUiOverlay.values` 而非 `edgeToEdge`:后者要求 API 29+,
     前者全版本可用,语义就是"把系统栏都还回来"。

② **应用自己的底部导航**(采集/查看两页签):主壳是 `IndexedStack` +
   `bottomNavigationBar`,而后者是 Scaffold 的**独立区域** —— 看板渲染在 body 里,
   它一无所知。修法:新增 `PmonEinkBoardMode`(`ValueNotifier<bool>`),
   主壳用 `ValueListenableBuilder` 订阅,看板前台时不渲染导航。
   · 用 ValueNotifier 而非像 `PmonEinkMode` 那样的静态 bool:这里必须**触发主壳重建**。

**判定只有一处**:`_syncBoardChrome()` 用同一个 `_eink && !_einkPaused` 同时驱动
系统栏显隐与主壳导航 —— 本项目已因"同一判据写两处"反复踩坑(指标清单、单位换算、
身份判据…),这里不再重演。

顺带修掉一个隐患:转屏校验原先用 `Future.delayed`(**不可取消**),页面销毁后
回调仍会醒来。已改成可取消的 `Timer` 并在 `dispose` 里取消。
(这是写测试时被 "Pending timers" 揪出来的 —— 测试的价值不只在断言。
 `dispose` 里同时还原 `PmonEinkBoardMode` 与系统栏,免得离开这一页留下副作用。)

验证:
  · 新增 2 条用例(`viewer_page_test` ⑨ 组)→ 客户端 344 → **346/346**
  · **变异测试**:注释掉"通知主壳"那行 → 「进看板 → active 置真」**报红**;恢复即绿
  · `dart analyze lib test` 干净

⚠️ 本提交是 **feature/eink 专属**,不同步主干:`ui/eink_mode.dart` 主干上不存在,
`main.dart` 的订阅逻辑也只在有水墨屏模式时才有意义。
(上一轮臣就是整文件复制到主干,把分支专属内容带了过去 → 主干编译不过。
 这次明确不跨分支。)

设备事实卡补第七节,记录两个成因、取舍与遗留。
2026-09-17 22:44:44 +08:00
PMon Dev 81607cca5e fix(eink): 系统不理会转屏请求 → 加"应用内旋转"兜底(1.1.5)
真机反馈「转屏功能无法使用」。排查:**不是代码没接上,是系统不理** ——
`SystemChrome.setPreferredOrientations` 发出去后窗口尺寸纹丝不动,
界面上当然"没反应"。这与设备事实卡里的「零传感器」自洽:
没有加速度计,SmartOS 层面就不做方向变化。
(接线本身是对的:按钮 → setState + 请求 + 落盘,manifest 也没有写死
 `screenOrientation`、`configChanges` 也含 orientation|screenSize。)

⚠️ 由此得一条通用认识:**发了请求不等于生效**。能验证的只有窗口尺寸本身。

改法:
1. `_verifyOrientation()` —— 请求后回读窗口尺寸(900ms 宽裕),
   宽高比没变成想要的那样就切到应用内旋转。
   **不猜设备、不看厂商,只看事实。**
2. `_einkBoardShell()` —— 必要时套 `RotatedBox(quarterTurns: 1)`。
   它的关键性质是**交换子节点的约束**(窗口 1072×1448 → 子节点拿到 1448×1072),
   于是横屏布局正好按 1448×1072 排布、再旋转 90° 铺满物理屏 ——
   **不缩放、字号无损**;命中测试也跟着旋转,按钮照常可点。
   代价:系统 UI 不参与旋转,用户需把设备物理横置。
   同时清掉系统 padding(旋转后它描述的是未旋转的窗口,语义已不对)。
3. 自检页新增「窗口(物理)」「布局(看板用)」「转屏方式」三行 ——
   前两者**不相等**即证明在应用内旋转,一眼可验证。

对**支持系统转屏的设备(如 F50)零影响**:请求后尺寸确实变了 →
`_softRotate` 保持 false,行为与之前完全一致。

验证:
  · eink_board_test 39 → 40 项,客户端 332 → **333/333**,analyze 干净
  · 新用例「竖屏窗口里也排出 3 列」:断言物理窗口是 536×724(竖)
    而看板列数是 3(横)—— **一竖一横并存**即证明约束真被交换
  · ⚠️ 写这条测试时踩坑:不能沿用按屏幕 y 坐标分行的 `columnsOf()` ——
    RotatedBox 之后 `getTopLeft` 返回的是**旋转后**的屏幕坐标,同一行会变成同一列。
    **度量方法本身失效,别把"量不出来"当成"功能没实现"。**

设备事实卡(docs/eink-device-neo2.md)补第六节,记录该结论与验证方法。
2026-09-17 22:23:25 +08:00
PMon Dev ba41b5ec5c fix(account): 修正上一提交误把水墨屏内容带进主干
上一提交(登录态同步)在同步到主干时,臣用
`git checkout feature/eink -- client/lib/ui/account_card.dart`
把**整个文件**从分支复制了过来 —— 但两分支的这个文件**并不相同**
(实测差 5 行),于是把分支专属的两处带进了主干:

  1. `import 'eink_mode.dart';`  ← 主干根本没有这个文件
  2. `CircularProgressIndicator` → `WaitIndicator`(水墨屏静止等待指示)

后果:主干 `dart analyze` 报 2 个 **error**(URI 不存在 / 类未定义),
即主干当时处于**编译不过**的状态,且已推送远端。

修法:用 `git checkout <上一提交>^ -- <文件>` 恢复主干原版,
再**只**插入 `didUpdateWidget` 一段(不含任何水墨屏内容)。

验证:
  · `git diff` 对主干旧版**只有新增、无删除**
  · `dart analyze lib test` = No issues found
  · 客户端 286/286(282 + 4 条新同步测试)

⚠️ 教训:**跨分支复制文件前必须先 diff,不能假设两边一致**。
臣当时在命令行里顺手打了一句"(空 = 一致,可直接同步)",
但那只是**臣自己写的说明文字**,不是检测结果 —— diff 实际输出的是
`1 file changed, 2 insertions(+), 3 deletions(-)`,臣没看就往下走了。
**把假设写成结论,比没有结论更危险。**
2026-09-17 00:53:17 +08:00
PMon Dev b19a8bf0a9 fix(account): 登录态在两个页面之间不同步 → 表现为"查看端和采集端各要登录一次"
真机反馈:在采集端登录后,切到查看端仍显示未登录,于是又登一次。

排查结论:**不是登录两次,而是登录态没同步**。令牌早就共享
(两端都写同一个 `AppConfig.accountToken`,且两端都订阅了 `ConfigStore.updates`),
整条链路本来是通的:

  采集端登录 → onAuthChanged → ConfigStore.save
    → _publish 广播 → ViewerPage._onExternalConfig
    → sessionAffectingChanges(含 accountToken)为真
    → _restartWith → _startFromConfig → `_token = cfg.accountToken` ✅

断在**最后一环**:`AccountCard` 在 `initState` 里
`_token = widget.initialToken` 读了**一次**就不管了,且没有 `didUpdateWidget`。
而主壳是 IndexedStack,两页的卡片**都长期存活** —— 父级更新了 `initialToken`
也不会重跑 `initState`(同位置、无 key → State 被复用),
卡片内部仍是旧的空 token,照样渲染登录表单。用户看到的自然就是
"那边登了、这边还得再登一次"。

修法:给 `AccountCard` 补 `didUpdateWidget`,只在 `initialToken` 变化时同步;
登出(token 变空)时一并清掉 `_info`,否则会留着上个账号的档位与设备数。

边界(刻意保守):
  · 邮箱**只补空、不覆盖** —— 只在输入框为空或仍等于旧值时才同步,
    免得把用户正在敲的内容冲掉;
  · token 未变时直接返回,不做无谓重建(避免自写盘自激)。

对照:`PairingCard` 全程直接用 `widget.accountToken`,所以没有这个问题 ——
同一应用里两种写法,错的那个恰恰是"看起来更规范"的那种(把入参缓存进 State)。

验证:
  · 新增 4 条测试(`test/account_test.dart` ⑥ 组),客户端 328 → **332/332**
  · **变异测试**:移除 `didUpdateWidget` → 「别处登录/登出后卡片要跟着变」两条**报红**;
    恢复即绿 —— 证明测的就是这条修复本身
  · `dart analyze lib test` 干净
2026-09-17 00:52:10 +08:00
PMon Dev b5f33ef485 fix(account): 登录态在两个页面之间不同步 → 表现为"查看端和采集端各要登录一次"
真机反馈:在采集端登录后,切到查看端仍显示未登录,于是又登一次。

排查结论:**不是登录两次,而是登录态没同步**。令牌早就共享
(两端都写同一个 `AppConfig.accountToken`,且两端都订阅了 `ConfigStore.updates`),
整条链路本来是通的:

  采集端登录 → onAuthChanged → ConfigStore.save
    → _publish 广播 → ViewerPage._onExternalConfig
    → sessionAffectingChanges(含 accountToken)为真
    → _restartWith → _startFromConfig → `_token = cfg.accountToken` ✅

断在**最后一环**:`AccountCard` 在 `initState` 里
`_token = widget.initialToken` 读了**一次**就不管了,且没有 `didUpdateWidget`。
而主壳是 IndexedStack,两页的卡片**都长期存活** —— 父级更新了 `initialToken`
也不会重跑 `initState`(同位置、无 key → State 被复用),
卡片内部仍是旧的空 token,照样渲染登录表单。用户看到的自然就是
"那边登了、这边还得再登一次"。

修法:给 `AccountCard` 补 `didUpdateWidget`,只在 `initialToken` 变化时同步;
登出(token 变空)时一并清掉 `_info`,否则会留着上个账号的档位与设备数。

边界(刻意保守):
  · 邮箱**只补空、不覆盖** —— 只在输入框为空或仍等于旧值时才同步,
    免得把用户正在敲的内容冲掉;
  · token 未变时直接返回,不做无谓重建(避免自写盘自激)。

对照:`PairingCard` 全程直接用 `widget.accountToken`,所以没有这个问题 ——
同一应用里两种写法,错的那个恰恰是"看起来更规范"的那种(把入参缓存进 State)。

验证:
  · 新增 4 条测试(`test/account_test.dart` ⑥ 组),客户端 328 → **332/332**
  · **变异测试**:移除 `didUpdateWidget` → 「别处登录/登出后卡片要跟着变」两条**报红**;
    恢复即绿 —— 证明测的就是这条修复本身
  · `dart analyze lib test` 干净
2026-09-17 00:52:10 +08:00
PMon Dev 9e63636511 docs(eink): 用设备参数把版面算出来,确认 density 不影响决策
产品负责人指出"参数早就发过了,不该再等截图"。臣复核后确认:**参数确实够用**,
而且不必上机 —— 把两种 density 假设的逻辑尺寸喂给看板自己的代码
(widget 测试挂载),就能量出真机版面:

| 假设 | 方向 | 数值字号 | 列数 |
|---|---|---|---|
| A 572×772(若 300=densityDpi) | 竖 / 横 | 40.0 / 37.2 dp | 2 / 3 |
| B 536×724(若 300=PPI、density 2.0) | 竖 / 横 | 38.5 / 33.2 dp | 2 / 3 |

两种假设下**列数一致**、**数值字号 33~40dp**(标签的两倍以上)→ 版面合格。

⚠️ 关键澄清(避免把测算读成真机值):**widget 测试的字形是正方形的**
(每字宽 ≈ 字号),远宽于真机字体,故上表是**下限** ——
真机只会更大(长值基本能开到满档 40dp)。

结论:`densityDpi` 的精确值**不影响任何设计决策**,只用于事后核对。
设备卡里「如何确认」的措辞已从「待确认」改为「事后核对」,
并说明自检页的真正用途是内存占用与实际刷新计数。

(臣上一轮把"验证"说成了"前提",是要纠正的表述。)
2026-09-17 00:29:37 +08:00
PMon Dev b19fbae68b docs(eink): 按设备实测报告订正水墨屏目标机参数
设备报告(2026-09-16「电纸书助手」导出,实测非资料推测)显示此前的描述有两处不准,
且缺了一条**会影响设计判断**的关键事实:

1. **系统版本写错**:注释与文档此前写 `Android 11`,实为 **Android 10 (API 29)**。
   顺手核过兼容性:Kotlin 里所有 `SDK_INT` 判定(>= O / >= TIRAMISU /
   >= UPSIDE_DOWN_CAKE)都有守卫,29 上走安全分支;
   `foregroundServiceType="dataSync"` 正是 API 29 引入的,在 29 上有效;
   `POST_NOTIFICATIONS`(33) 被忽略;只用应用私有 filesDir,不涉分区存储。
2. **补上 CPU 与传感器**:MT8175B 四核 2GHz(aarch64,与 APK 的 arm64-v8a 匹配);
   ★ **传感器 0 个** —— 这条最要紧,见下。

★ 由「零传感器」推出的设计结论(已写进 docs/eink-device-neo2.md):
   没有加速度计/陀螺仪 → 系统拿不到姿态 → **自动旋转在这台机器上等于不存在**。
   所以横竖屏**只能由软件锁定**,看板里的「转屏」按钮**不是便利功能,
   而是唯一的方向入口**,**不能省** —— 省掉它,用户在竖屏机器上永远看不到横屏版面。
   现有实现(宿主用 SystemChrome 锁方向、看板只发指令)正好不依赖传感器,
   经此确认是对的,不是过度设计。

另记一个仍待实测的数:逻辑尺寸 dp。报告里的「300」若指 densityDpi → 约 572×772dp;
若只是由 1072×1448@6″ 算出的 PPI(density 取常见 2.0)→ 约 536×724dp。
**两种假设下版面决策一致**(列数由宽高比决定:竖屏 2 列 / 横屏 3 列),
故不阻塞调版;确切值用看板「自检」页一屏截图即可定案。
2026-09-17 00:25:33 +08:00
PMon Dev e370292074 fix(privacy): 把 <user> 占位符改为「产品负责人」
上一轮把自称替换成了 `<user>`,有两个副作用:

① **尖括号触发 14 个 doc-comment lint**(`unintended_html_in_doc_comment`)——
   Dart 分析器把 `///` 注释里的 `<...>` 当 HTML。脱敏前 analyze 是
   "No issues found",脱敏后变 14 条 info,属于**回归**。
   涉及:app_config / boot_report / config_migration / main / ui/eink_board /
   ui/skeleton + 6 个测试文件。

② **语义被降级**。原句是**决策记录**,改完丢了主体:
   - `(2026-09-16 产品负责人定的默认)` → `(<user>定的默认)`
     —— 从"负责人拍板"降级成"某个用户",信息量没了
   - `产品负责人那台,2026-09-15 实测` → `<user>那台` —— 指代不清
   - `产品负责人截图里的现象是` → `<user>截图里的现象是` —— 同上

改法:`<user>` → **「产品负责人」**(全仓 41 处 / 23 文件)
   · 无尖括号 → 14 个 lint 归零
   · 语义比 `<user>` 清楚:明确是「拍板的人」,而不是「某个用户」
   · 公开仓库里用中性角色名比昵称得体

验证:客户端 328/328、`dart analyze lib test` = No issues found。
diff 已核为**纯粹**(仅这一类替换,未混入其它改动)。

注:昵称本身不指向真实身份(网名 `某个网名` 才是可关联的,那条保留在 denylist 里),
故这里的取舍是「去掉个人色彩」而非「切断身份关联」。
2026-09-17 00:24:32 +08:00
PMon Dev d48d56e4c1 fix(privacy): 把 <user> 占位符改为「产品负责人」
上一轮把自称替换成了 `<user>`,有两个副作用:

① **尖括号触发 14 个 doc-comment lint**(`unintended_html_in_doc_comment`)——
   Dart 分析器把 `///` 注释里的 `<...>` 当 HTML。脱敏前 analyze 是
   "No issues found",脱敏后变 14 条 info,属于**回归**。
   涉及:app_config / boot_report / config_migration / main / ui/eink_board /
   ui/skeleton + 6 个测试文件。

② **语义被降级**。原句是**决策记录**,改完丢了主体:
   - `(2026-09-16 产品负责人定的默认)` → `(<user>定的默认)`
     —— 从"负责人拍板"降级成"某个用户",信息量没了
   - `产品负责人那台,2026-09-15 实测` → `<user>那台` —— 指代不清
   - `产品负责人截图里的现象是` → `<user>截图里的现象是` —— 同上

改法:`<user>` → **「产品负责人」**(全仓 41 处 / 23 文件)
   · 无尖括号 → 14 个 lint 归零
   · 语义比 `<user>` 清楚:明确是「拍板的人」,而不是「某个用户」
   · 公开仓库里用中性角色名比昵称得体

验证:客户端 328/328、`dart analyze lib test` = No issues found。
diff 已核为**纯粹**(仅这一类替换,未混入其它改动)。

注:昵称本身不指向真实身份(网名 `某个网名` 才是可关联的,那条保留在 denylist 里),
故这里的取舍是「去掉个人色彩」而非「切断身份关联」。
2026-09-17 00:24:32 +08:00
PMon Dev 417a3ab78b test(privacy): 护栏改全仓扫描、补判据、修四处静默漏扫,并写明判据边界
本文件此前有四处范围缺陷,每一处都是"范围悄悄变小、而输出仍然是绿的":

① 受 cwd 约束。脚本内 `git ls-files` 与 `git grep <rev> -- .` 都相对当前目录,
   而 run-all.sh:27 正是 `cd "$(dirname "$0")"` → 实际只覆盖 tests/**
   (实测 26 个文件 vs 从仓库根跑的 193 个,少 86%)。
   client/ server/ shared/ docs/ deploy/ 全在盲区,而真实环境指纹恰好都在那里。
   → 开跑先 `git rev-parse --show-toplevel`,所有 git 调用带 cwd=ROOT。

② 非 ASCII 路径。`git ls-files` 默认把路径加引号转义输出
   ("docs/xxx-\346\236\266.html"),照原样 readFileSync 必然打不开 → 被 catch 跳过。
   本仓库两个中文名文件长期不在扫描范围内。
   → 所有 git 调用带 `-c core.quotepath=false`。

③ 未跟踪文件。只扫 `ls-files`(纯受跟踪)会漏掉"刚写好、还没 add"的新文件,
   而那正是下一次提交的原料。实测踩到:一个新写的 docs/*.md 带着设备代号与
   ROM 构建指纹躲过了脱敏。
   → 加 `--others --exclude-standard`(忽略项仍排除,不引入噪音)。

④ 浅克隆。`clone --depth=N` 隐含 `--single-branch`,只带默认分支。
   → 扫描前确认分支齐全。

同时**写明判据边界**(这一条比上面四条更重要,因为它防的是"用错力"):
判据是"这条信息能不能把内容跟我本人 / 我的机器关联起来",不是"网上能不能查到"。

| 类别 | 处置 |
|---|---|
| 产品参数(机型名、型号代号、分辨率、芯片名) | **不必脱** |
| 设备实例标识(device_id 的 ANDROID_ID 后缀、设备串号) | 建议脱 |
| 云 / 容器实例(lhins-…、1Panel-…) | 该脱 |
| 指向本人的(服务器 IP、域名、SSH 私钥路径、Linux 用户名、macOS 主机名、账号邮箱) | **必须脱** |

两条硬约束,来自本轮一次返工:
  · 不得把产品参数当敏感数据脱 —— 脱了以后 README 里「设备归并」案例
    (归并前 5 台 / 归并后 4 台)就没法跟真机对照了,净负收益;
  · 不得让判据恒红 —— 通用开源项目名(searxng / meilisearch)、通用词
    (reader_default)、已经是脱敏占位符的值(/home/devuser、~/pmon)
    都不许进判据或 denylist。恒红会让整条护栏被无视,比漏报更糟。

按此边界调整规则集:
  · 新增「Linux 家目录(含用户名)」,devuser 作为既有占位符放行
  · 删除「Apple 机器标识符」「证书 DN 中的地名」「真机型号/芯片标识」——
    这三条属于产品参数,留着只会恒红
  · 邮箱保持 WARN:夹具与真实邮箱形状完全一样,规则分不开;
    真正要拦的那个邮箱属于"形状上无特征"一类,请写进 denylist(命中即 FAIL)

另修一处工具自身的坑:`--format=%(refname:short)` 里的左括号在 `sh -c` 下是
子 shell 起点 → 语法错误 → 被 catch 吞成空串(范围提示会显示"分支 0 个")。
带 () % 的 git 参数改用 execFileSync,不拼进 shell 字符串。

验证:从仓库根与从 tests/ 运行结果完全一致;范围提示如实报出文件数与分支数。
2026-09-17 00:00:55 +08:00
PMon Dev 6ccd19d771 fix(privacy): 只脱真正指向本人的数据(撤回上一轮的产品参数脱敏)
上一轮脱敏**做过头了**:把产品参数当敏感数据脱,把 README 里「设备归并」案例
(归并前 5 台 / 归并后 4 台)跟真机的对照关系毁掉了 —— 净负收益。

按最终判据("能不能把内容跟我本人 / 我的机器关联起来")重做,替换表从 31 条
收敛到 4 条,命中从 43 文件 / 400 处降到 25 文件 / 46 处:

仍要脱(指向账号资源 / 指向本人 / 设备实例标识):
  · 云实例 ID、容器编排实例名
  · 设备 ID 的稳定后缀真值(在 main 上补齐)
  · 个人 handle

撤回(此前误脱,现恢复原样):
  · 产品参数:机型名、型号代号、屏幕分辨率、芯片名、Android 版本
  · 证书 DN 里的地名 —— 它随 APK 公开分发,且文档要用它核对 apksigner 输出
  · 私有 LAN IP、公共 npm 镜像源
  · 通用开源项目名(searxng / meilisearch / market-sentiment / reader_default)
  · 已经是脱敏占位符的值(/home/devuser、~/pmon)
  · keystore 目录路径 —— `~/` 下不含用户名,且命令要能照抄执行

「护栏拦不住」的三类(形状上无可匹配特征)改由本机 denylist 装原始值:
Linux 用户名、macOS 主机名、账号邮箱。denylist 装原始值、不装结果值 ——
装结果值会把已经改对的内容也判红。

⚠️ 只改工作树。历史里的原文仍在,由 git filter-repo 连历史一起重写。

验证:tests/run-all.sh 全部套件(见下一条执行记录)。
2026-09-17 00:00:45 +08:00
PMon Dev ca678a9bb0 test(privacy): 护栏改全仓扫描、补判据、修四处静默漏扫,并写明判据边界
本文件此前有四处范围缺陷,每一处都是"范围悄悄变小、而输出仍然是绿的":

① 受 cwd 约束。脚本内 `git ls-files` 与 `git grep <rev> -- .` 都相对当前目录,
   而 run-all.sh:27 正是 `cd "$(dirname "$0")"` → 实际只覆盖 tests/**
   (实测 26 个文件 vs 从仓库根跑的 193 个,少 86%)。
   client/ server/ shared/ docs/ deploy/ 全在盲区,而真实环境指纹恰好都在那里。
   → 开跑先 `git rev-parse --show-toplevel`,所有 git 调用带 cwd=ROOT。

② 非 ASCII 路径。`git ls-files` 默认把路径加引号转义输出
   ("docs/xxx-\346\236\266.html"),照原样 readFileSync 必然打不开 → 被 catch 跳过。
   本仓库两个中文名文件长期不在扫描范围内。
   → 所有 git 调用带 `-c core.quotepath=false`。

③ 未跟踪文件。只扫 `ls-files`(纯受跟踪)会漏掉"刚写好、还没 add"的新文件,
   而那正是下一次提交的原料。实测踩到:一个新写的 docs/*.md 带着设备代号与
   ROM 构建指纹躲过了脱敏。
   → 加 `--others --exclude-standard`(忽略项仍排除,不引入噪音)。

④ 浅克隆。`clone --depth=N` 隐含 `--single-branch`,只带默认分支。
   → 扫描前确认分支齐全。

同时**写明判据边界**(这一条比上面四条更重要,因为它防的是"用错力"):
判据是"这条信息能不能把内容跟我本人 / 我的机器关联起来",不是"网上能不能查到"。

| 类别 | 处置 |
|---|---|
| 产品参数(机型名、型号代号、分辨率、芯片名) | **不必脱** |
| 设备实例标识(device_id 的 ANDROID_ID 后缀、设备串号) | 建议脱 |
| 云 / 容器实例(lhins-…、1Panel-…) | 该脱 |
| 指向本人的(服务器 IP、域名、SSH 私钥路径、Linux 用户名、macOS 主机名、账号邮箱) | **必须脱** |

两条硬约束,来自本轮一次返工:
  · 不得把产品参数当敏感数据脱 —— 脱了以后 README 里「设备归并」案例
    (归并前 5 台 / 归并后 4 台)就没法跟真机对照了,净负收益;
  · 不得让判据恒红 —— 通用开源项目名(searxng / meilisearch)、通用词
    (reader_default)、已经是脱敏占位符的值(/home/devuser、~/pmon)
    都不许进判据或 denylist。恒红会让整条护栏被无视,比漏报更糟。

按此边界调整规则集:
  · 新增「Linux 家目录(含用户名)」,devuser 作为既有占位符放行
  · 删除「Apple 机器标识符」「证书 DN 中的地名」「真机型号/芯片标识」——
    这三条属于产品参数,留着只会恒红
  · 邮箱保持 WARN:夹具与真实邮箱形状完全一样,规则分不开;
    真正要拦的那个邮箱属于"形状上无特征"一类,请写进 denylist(命中即 FAIL)

另修一处工具自身的坑:`--format=%(refname:short)` 里的左括号在 `sh -c` 下是
子 shell 起点 → 语法错误 → 被 catch 吞成空串(范围提示会显示"分支 0 个")。
带 () % 的 git 参数改用 execFileSync,不拼进 shell 字符串。

验证:从仓库根与从 tests/ 运行结果完全一致;范围提示如实报出文件数与分支数。
2026-09-17 00:00:45 +08:00
PMon Dev b32b1784f6 feat(eink): 看板加「设备自检」面板 —— adb 不通时的取数通道(1.1.4)
背景:Neo2 的 USB 调试开不了(已用 locationID 关联确认它只暴露掌阅自己的
255/255/0 传输接口,全系统唯一的 ADB 接口属于 F50)。而看板版面
(列数、字号、格高)**完全取决于逻辑尺寸 dp** —— 拿不到 wm size/density 就是瞎调。

于是把自检做进应用:把关键数据**直接画在屏幕上**,一张截图即可取数。

自检页内容:
  · 展示层自有(MediaQuery):逻辑尺寸 dp、devicePixelRatio、物理像素、方向、
    由尺寸推出的看板列数
  · 宿主提供(viewer_page):刷新间隔、已收帧数、本会话断点、最后刷新时刻、常驻内存

设计要点:
  · **静态文本表**,无动画无滚动 —— 与看板同一套墨水屏约束
  · 打开时**不显示数值网格**(一屏只放一件事)
  · 底部按钮在「自检 / 返回」间切换,仅在宿主提供回调时出现
  · 用 Expanded 等分行高,多尺寸不溢出

测试:eink_board_test 32 → 39 项(新增 7 项),客户端 321 → 328/328。
2026-09-16 23:15:29 +08:00
PMon Dev 70fb3e0dd1 test(eink): 新增水墨屏设备探针脚本(Neo2 上机前的一站式检查)
为什么单独写:看板版面(列数、字号、格高)全部取决于设备的**逻辑尺寸 dp**,
而 6″/300PPI 且系统 density 由厂商设定 —— 1072×1448 在 density 2.0 下是 536×724dp,
若厂商设成 240 就是 715×965dp,版面完全不是一回事。不查就不知道。

脚本做三件事:
  1. 按机型名(Neo/iReader)**精确挑设备**,避免误操作到 F50 上
  2. 打印机型/Android 版本/内存/ABI + 物理与覆写尺寸、density,
     并换算出逻辑 dp 与"该走几列"的预期
  3. 找到设备却无法连接时,直接打印开启 USB 调试的排查步骤

用法:bash client/scripts/eink-device-probe.sh [APK路径]

首跑实测:Neo2 在 adb 不可见(见提交信息附的 USB 描述符证据),
脚本按设计输出了排查步骤。
2026-09-16 22:51:26 +08:00
PMon Dev 6f314b69a0 feat(eink): 水墨屏版改用独立包名 com.inkwatch.eink(1.1.3)
为什么必须独立:两条分支的 versionCode 各自递增(通用版 1.0.9+9 / 水墨屏版 +13)。
共用一个 applicationId 会互相覆盖,且 versionCode 小的那个会被系统当成**降级**
而拒绝安装 —— 两个想并行的版本根本装不到同一台手机上。

改为独立包名后:两版可共存、各自升级,且 data 目录独立(配置互不干扰)。

这是本分支与主干**唯一有意的差异** —— 已在 gradle 里写明「合并主干时不要合回去」。
namespace(Kotlin 包名)保持 com.pmon.pmon_client 不动:它只影响 R 类与源码包声明,
与「装成哪个应用」无关,动它要大改 Kotlin 源码且无收益。

版本 1.1.2+12 → 1.1.3+13。
2026-09-16 21:42:45 +08:00
PMon Dev 273b398fa7 merge(main): 同步签名改造(内容相同,仅为收敛历史) 2026-09-16 21:42:29 +08:00
PMon Dev 0777e55cf5 build(android): release 改用正式签名,并禁止静默降级
改的是什么:client/android/app/build.gradle.kts 此前是 Flutter 模板原样 ——
release 一直用 signingConfigs.getByName("debug"),即公开的 AOSP 测试密钥。
三条后果且都不报错:
  ① 任何人都能签一个同签名的包冒充升级
  ② 无法上架(Play 拒绝 debug 签名)
  ③ 换正式密钥后,已装设备无法覆盖升级(不可逆)

怎么改:
- 新增 signingConfigs.release,凭据从 client/android/key.properties 读
  (该文件已在 .gitignore 中,确认 check-ignore 命中)
- buildTypes.release 指向它
- task-graph 守卫:release 构建缺 key.properties 时**明确失败**并打印修复指引
  刻意不学模板「回落 debug 签名」—— 那是一条「看着构建成功、其实签的是公开密钥」
  的静默失败路径。本仓库多次栽在静默失败上(迁移从不执行、首次部署路径无人测),
  故这里宁可让它红。

验证:
- 变异:移走 key.properties → release 构建 2 秒内失败并给出指引
- 放回 → 构建成功;apksigner 确认 DN=CN=InkWatch,指纹与 keystore 一致
  SHA-256 0d58f8b5749b6a60c8042f6f4333a0c847192bf37d44e56cdd4756f3970ad679

密钥:~/keystores/inkwatch-release.jks(仓库外,PKCS12/RSA2048,有效期至 2054),
凭据记录在同目录一个 600 权限的文件里。
⚠️ 这是本仓库唯一不可再生的东西 —— 丢了就永远无法更新已发布的应用。

新增 docs/release-and-signing.md:密钥信息、key.properties 契约、出包命令、
两个包名的分工、换签名的后果与恢复步骤。
2026-09-16 21:41:45 +08:00
PMon Dev 158ae7577e merge(main): 合入隐私扫描护栏 2026-09-16 21:12:22 +08:00
PMon Dev 8520f13741 test(privacy): 新增隐私扫描护栏,并接入全量回归
首次推送前的人工审计一次扫出六类个人数据,而且**反复漏**:
  · 第一轮漏了机型编号的**大写变体**(规则只写了小写)
  · 第二轮漏了**裸用户名**(规则只写了长形式 xxxdeMacBook-Pro-2.local)
  · 第三轮漏了真实账号邮箱 —— 因为臣用 head -10 截断了扫描输出,
    前面十条全是测试假邮箱,真邮箱被挤到看不见
三次都是"扫全之后回看"才发现的。人工审计靠不住,固化成脚本。

判据分两档(关键设计):
  · FAIL:高置信(公网 IP / .cn 域名 / /Users/<名字> / SSH 私钥路径 /
    私钥块 / 手机号 / 疑似真实密钥赋值)
  · WARN:需人判断(邮箱 —— 测试夹具与真实邮箱长得一样)。
    只列不判红,避免假阳性变成噪音、进而被整体忽略。
白名单刻意**窄**:Flutter 官方中国镜像域名、明显合成的口令值、单字母域名的假邮箱。

扫描范围:工作树(被跟踪文件)+ **全部提交** + 全部提交信息 ——
只扫工作树是不够的,`git log -p` 照样能把脱敏前的原文翻出来。

本脚本**不内置任何真实值**(内置就等于把要防的东西写进仓库);
项目特定敏感串写到本机的 ~/.config/inkwatch/privacy-denylist.txt(不入库)。

用法:node tests/privacy-scan.js(全量) / --worktree-only(快,只扫工作树)
2026-09-16 21:12:22 +08:00
PMon Dev f289b11f48 fix(privacy): 清掉误入仓库的 .git-rewrite 临时目录并加入忽略
上一轮 git add -A 时把 filter-branch 的临时目录一并提交了 ——
它里面存着**改写前的提交信息与临时工作树**,等于把脱敏前的原文又带回了仓库。

本次:删除该目录、写入 .gitignore(防止再犯),随后用它重写全部历史。
2026-09-16 21:05:34 +08:00
PMon Dev c3eb36b8f3 fix(privacy): 清掉误入仓库的 .git-rewrite 临时目录并加入忽略
上一轮 git add -A 时把 filter-branch 的临时目录一并提交了 ——
它里面存着**改写前的提交信息与临时工作树**,等于把脱敏前的原文又带回了仓库。

本次:删除该目录、写入 .gitignore(防止再犯),随后用它重写全部历史。
2026-09-16 21:05:21 +08:00
PMon Dev 55c027da58 fix(test): 脱敏后修回 device-identity 断言,并把机型编号一并脱敏
脱敏改坏了一处断言:device-identity-test 的「大小写归一」用例
  输入 deviceTag('android-ABC123')(大写,未被替换)
  期望 'abc123'(小写,已被替换)
→ 输入与期望指向了不同的标识,必然失败。改为输入 ABC123 / 期望 abc123,
  两者同指一个标识,语义(大写入、小写出)不变。

⚠️ 教训:**字符串级脱敏必然改坏成对出现的断言**(输入与期望、正例与反例)。
   脱敏后必须跑全套测试,不能只看 diff 顺眼就推。

同时把上一轮只匹配小写而漏掉的**机型编号大写变体**(25042PN24C / 24069RA21C)
一并脱敏为 MODEL-A1 / MODEL-B2 —— 型号本身是公开信息,但与设备 ID 后缀
组合起来足以指认具体设备,故一并替换。
2026-09-16 21:03:07 +08:00
PMon Dev e0bd64fe81 chore(release): 1.1.2 —— 交付包统一命名 inkwatch-arm64-<版本>.apk
配合项目定名「墨观 InkWatch」:

- client/README.md 的发布步骤改为输出 inkwatch-arm64-*.apk,
  并标注「包名与 FFI 符号仍是 pmon」(两件事,见 docs/rename-inkwatch.md)
- pubspec 版本 1.1.1+11 → 1.1.2+12

真机核验(F50):

- 界面标题「墨观 采集端」;APK 内 AndroidManifest 含「墨观」、不含 PMon
- 因未改 applicationId,覆盖安装成功 —— 本地配置与会话令牌原样保留。
  若改了包名,这一步会变成「装成另一个 App」,要重装并重新认领

生产已同步:~/pmon → ~/inkwatch。容器名与卷名(pmon_pg_data)均未变,
4 个容器 Up 18 hours healthy;从新路径 compose 照常认得 ——
这正是「只改显示名、不动 compose project name」的价值所在。
2026-09-16 12:14:58 +08:00
PMon Dev 427994853c merge(main): 合入「墨观 InkWatch」改名(分支沿用主干显示名) 2026-09-16 12:09:53 +08:00
PMon Dev 5a860cd555 feat(eink): 水墨屏专属版面 —— 横屏默认 / 极简 / 字号统一(1.1.1)
<user> 2026-09-16 的四条要求,逐条落地并各有可执行断言:

① 横屏为默认、可切竖屏
   · 新增配置 einkLandscape(默认 true);看板里的「转屏」按钮切换并落盘
   · 屏幕方向由宿主锁(SystemChrome),看板只发指令、保持"纯展示"以利测试
   · 列数按**可用空间宽高比**决定(宽→3 列 / 高→2 列),不写死方向 ——
     Flutter 只拿得到一个矩形,分屏/折叠/多窗口下方向与矩形并不一致

② 聚焦核心数据、隐藏次要信息与冗余控件
   · 在线时顶部只有「实心圆 + 设备名」:形状已表达在线,
     "在线"二字与"数据新鲜"都是冗余;一旦离线/疑似/未知才把状态与原因铺出来
   · 底部两行说明合并为一行("最后刷新 xx" + "每 N 秒自动刷新" → "xx · 每 N 秒")

③ 对比度与刷新
   · 延续零动画(静止等待图标、NoSplash、无无限动画),有源码结构断言守着
   · 支撑"永不溢出":横屏/竖屏/极窄/极矮/极宽多套尺寸全部覆盖

④ 统一字体与样式(避免字号大小不一)
   · PmonEinkText 收敛为 5 档(label 与 action 同号,靠字重而非字号区分);
     界面里**不得写就地字号**,有源码断言(`fontSize: 数字` 即红)
   · 数值改为「大数值 + 小单位」:此前 `1.42 Mb/s` 整串按 40px 渲染宽 360px,
     而 3 列时每格仅 207px → 统一比例被拖到 0.575,**一屏数值全被它压小**
   · 全屏数值共用**同一个**缩放比例(按最长那条算);
     并改用**实测字形二次收敛**替代纯线性估算(渲染期文本宽度非线性,
     实测在极窄格 + 极长值时溢出 3.7~9.3px);最外层留一层 FittedBox 兜底,
     保证"绝不裁掉内容"(正常尺寸下它不生效,同号不受影响)
   · 数值加**无损紧凑化**(`310.00` → `310`):只删数学上无信息的零尾,绝不四舍五入

实测(F50 代跑):
  · 横屏 3 列 × 2 行、大数值 + 小单位、数值明显大于标签 —— 见 dist/水墨屏走查/03
  · 竖屏**未能在 F50 干净验证**(它的 wm size/density 覆盖 + 强制旋转使 Flutter 视口
    变成近正方形,于是按设计走了 3 列)。竖屏布局由 widget 测试覆盖
    (536×724 → 2 列),待真机(Neo2)上机复核。

测试:客户端 321/321;其中 eink_board_test 32 项。
变异测试:把"统一缩放"退回"逐格 FittedBox"会被"渲染高度一致"那条抓住。

主干 main 不受影响 —— 本分支相对 main 只新增、未修改任何主干代码。
2026-09-16 01:27:49 +08:00
PMon Dev b585d780e0 feat(eink): 水墨屏专属版本(iReader Neo 2 副屏)
从主干(1.0.9)长出,**只新增、不改动主干**。本提交带过来的是
已交付的水墨屏看板 1.1.0 那批改动(下一步会在此基础上做横屏与统一字号):

  · ui/eink_board.dart     单台设备大字看板(6 大格、一屏放下)
  · ui/eink_mode.dart      全局零动画开关 + 静止等待指示
  · ui/theme.dart          PmonEink 三档明度令牌 + statusGlyph 形状化状态
  · host/app_config.dart   einkMode / einkRefreshSec(默认关,不影响彩色版)
  · 平台通道               setKeepScreenOn(副屏常亮)
  · 刷新节流               数据每秒到,只在间隔到点重建

之后本分支会继续做:横屏为默认、竖屏可切、隐藏次要信息、统一字体与样式。
主干(main)不含以上任何代码。
2026-09-16 01:06:51 +08:00
251 changed files with 47527 additions and 5501 deletions

No files matched your search

+113 -4
View File
@@ -9,7 +9,7 @@ BUSINESS_PORT=4000
REDIS_URL=redis://127.0.0.1:6379
# ---------- PostgreSQL + TimescaleDB(元数据 + 时序历史) ----------
DATABASE_URL=postgres://pmon:CHANGE_ME@127.0.0.1:5432/pmon
DATABASE_URL=postgres://inkwatch:CHANGE_ME@127.0.0.1:5432/inkwatch
PG_POOL_MAX=10
# ---------- 网关运行参数 ----------
@@ -24,9 +24,9 @@ CORS_ORIGIN=*
NODE_MAX_OLD_SPACE_MB=256
# ---------- 部署(docker compose 用) ----------
POSTGRES_USER=pmon
POSTGRES_USER=inkwatch
POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_DB=pmon
POSTGRES_DB=inkwatch
# ---------- 安全开关(方案第 21 轮;默认全关 = 兼容模式) ----------
# 之所以登记在这里:只写在 compose 注释里的开关**没人检查得到** ——
@@ -38,8 +38,117 @@ POSTGRES_DB=pmon
# ENFORCE_DEVICE_QUOTA=1 按档位限制名下设备数(免费 3 / 专业 10 / 团队 30)
# REQUIRE_DEVICE_TOKEN=1 采集端必须带有效设备令牌才能 hello(**只有网关读**)
REQUIRE_AUTH=0
ENFORCE_DEVICE_QUOTA=0
# ⚠️ 2026-10-30:配额改为**代码默认开启**(`!== '0'` 即生效),此处显式写 1 保持一致。
# 这里曾写 0 —— 于是 `cp .env.example .env` 会把配额关掉,与 README 宣称的
# "免费档 3 台会被拦"相反。默认值与模板必须同向,否则模板就是一份错误文档。
# 真要关(例如迁移期批量导入设备)才写 0。
ENFORCE_DEVICE_QUOTA=1
REQUIRE_DEVICE_TOKEN=0
# 强制登录下是否仍允许自助注册(默认关,需另设 1;配套仍需"邀请制建号"界面)
ALLOW_OPEN_REGISTER=0
# 中继单次传输上限(字节)。默认 1048576 = 1 MiB。
# 2026-10-17 生产实测:中继传 >1MB 吞吐崩塌(大文件缺陷未修),
# 故先收紧到 1 MiB —— 宁可明确拒绝,也不让用户传一半卡死。
# 局域网直连不走中继,不受此限。修好缺陷后调大(如 33554432 = 32 MiB)。
RELAY_MAX_BYTES=1048576
# 免费档的设备上限。默认 3(与 shared/permissions.js 的 PLAN_TABLE 一致)。
# ⚠️ 这个值**在 2026-10-30 之前没有任何代码读**:向导会问、管理端能改,
# 但配额一直取 PLAN_TABLE 的常量。现在由 planFor() 覆盖 free 档,
# pro / team 不受影响(那两档的数字是产品定义,不接受运维改)。
FREE_DEVICE_LIMIT=3
# ---------- 实例配置:发信与审计(也可在管理端「实例设置」里改) ----------
# 这一组**同时存在于数据库的 instance_config 表**里,首次安装由向导问一遍、
# 之后管理端可改。这里的规则与其它开关一致:**设了 = 覆盖并锁定该字段**
# (管理端会显示"被环境变量锁定"),留空 = 用库里的值。
# ⚠️ **密钥类永远不进这张表**(DB 口令、SMTP 口令、各种 TOKEN),
# 因为库会被备份、被导出、被管理端读出来。
#
# 邮件模式:
# console(库里的默认值)只打日志不发信 —— 可疑的默认值应当"少做",
# 而不是把重置令牌明文写进服务器磁盘;
# outbox 追加 JSONL 到 MAIL_OUTBOX_PATH,令牌**明文落盘**,仅本机联调;
# smtp 真的把信交给中继;连接参数见下面的 SMTP_* 段。
# 改名记录:早期文档写的是 MAIL_DELIVERY,已改名为 MAIL_MODE。
# 旧名仍生效(**仅当 MAIL_MODE 未设**),升级时请顺手改过来。
MAIL_MODE=
MAIL_FROM=
# outbox 的落点(部署细节,不进库)
MAIL_OUTBOX_PATH=
# ---------- SMTP 中继(只在 MAIL_MODE=smtp 时使用) ----------
# ⚠️ **口令只走这里,永不入库**:库会被备份、被导出、被管理端读出来,
# SMTP 口令进库等于把"要 shell 权限才拿得到"降级成"能读库就拿得到"。
#
# 布尔只认 1 / 0:写 true / yes 会被**明确报错**(不静默按默认值跑,
# 那会让人以为自己配好了)。
SMTP_HOST=
# 留空则按服务商建议值:QQ/163 → 465(隐式 TLS),其它 → 587(STARTTLS)
SMTP_PORT=
# 1 = 一上来就是 TLS(465);0 = 明文连接后升级(587)
SMTP_SECURE=
# auto(默认:服务器提供 STARTTLS 就升级)/ require(没有就不发)/ never(仅内网)
# 注意:**配了口令又拿不到 TLS 时会直接拒发**,不会把口令明文交出去。
SMTP_STARTTLS=
SMTP_USER=
# ⚠️ QQ / 163 这里要填「SMTP 授权码」,**不是邮箱登录密码**
SMTP_PASS=
# EHLO 里报的主机名;留空用容器 hostname
SMTP_HELO=
# 自签中继的 CA:PEM 内容,或挂载进容器的 PEM 文件路径
SMTP_TLS_CA=
#
# ── QQ 邮箱(示例,把它填进上面几行)──────────────────────────────
# 申请:QQ 邮箱 → 设置 → 账号 → 开启「POP3/IMAP/SMTP 服务」→ 生成授权码
# SMTP_HOST=smtp.qq.com
# SMTP_PORT=465 (587 也支持,但要 SMTP_SECURE=0 走 STARTTLS)
# SMTP_SECURE=1
# SMTP_USER=你的QQ号@qq.com
# SMTP_PASS=<16 位授权码>
# MAIL_FROM=你的QQ号@qq.com ← **必须与 SMTP_USER 一致**,否则服务端 553 拒收
# MAIL_MODE=smtp
#
# ── 163 邮箱(同理)────────────────────────────────────────────────
# 申请:163 邮箱 → 设置 → POP3/SMTP/IMAP → 开启服务 → 设置「客户端授权密码」
# SMTP_HOST=smtp.163.com / SMTP_PORT=465 / SMTP_SECURE=1
# SMTP_USER=你的163账号@163.com / SMTP_PASS=<客户端授权密码>
# MAIL_FROM=你的163账号@163.com
#
# ⚠️ 端口 25 别用:腾讯云等云主机**默认封禁出网 25**,而且明文 AUTH 会被
# QQ/163 直接拒绝。统一走 465。
# 对外地址:写在邮件落款里("—— 墨观(https://…)"),内网部署可留空
PUBLIC_URL=
# 界面标题里显示的实例名
INSTANCE_NAME=
# 审计保留天数:**留空 = 永久保留**。填了才会每 6 小时清理一次,并留一条
# "audit_pruned 删了几行"的记录(删除本身必须留痕)。
AUDIT_RETENTION_DAYS=
# ---------- 云端管理台(/admin/ 与 /api/admin/*) ----------
# **不设置 = 管理端整体 404**(不是 401:不对外宣布"这里有个管理台")。
# 生成:openssl rand -base64 48
# ⚠️ 短于 32 字符一律**视为未设置**。ADMIN_TOKEN=123456 的管理台比没有更危险 ——
# 它会让人以为已经设过了。
ADMIN_TOKEN=
# 审计里的操作者名字("谁改了档位、谁导出了账号表")。只是标签,不参与鉴权。
ADMIN_NAME=admin
# 来源 IP 白名单,逗号分隔(如 "10.0.0.0/8" 不支持网段,只支持完整 IP)。
# 留空 = 不限制 —— 此时**必须**靠网络层隔离(安全组只放行办公网 / 走 WireGuard),
# 否则管理台的口令就是唯一一道门。
ADMIN_ALLOW_IPS=
# ---------- 安装向导(/setup,首次初始化用) ----------
# 用途:在浏览器里创建第一个管理员账号、设定实例名称与基础策略。
#
# 四条纪律(与 ADMIN_TOKEN 同源):
# · **不设置 = /setup 一律 404**(不是 401:不对外宣布"这里有个安装向导")
# · **短于 32 字符同样视为未设置** —— 短令牌的向导比没有更危险
# · 只认 `Authorization: Bearer`,不认 ?token=(会进 access log/浏览器历史)
# · 初始化完成后向导**永久 404**(判据是库里的 bootstrap_state,不是内存)
#
# 生成:openssl rand -hex 24
# 初始化完成、确认管理员能登录之后,建议从 .env 删掉这一行。
INSTANCE_SETUP_TOKEN=
+260
View File
@@ -0,0 +1,260 @@
# ⛔ 2026-11-10 事故后重定范围:**当前这台 runner 不许跑本流水线**
#
# 实测宿主 spec(Preflight 打印):MemTotal 3654MB / 4 核 / overlay 40G(已用 42%)。
# Gitea 与 runner 同机同内存。两次实测结果:
# · run 16(Gradle 默认 -Xmx8G):Gradle 一起来整机饿死,35 分钟后判失败,
# Gitea :3000 TCP 能握手但 HTTP 一律 8s 超时,SSH 卡 banner,需控制台重启。
# · run 19(已限 -Xmx1536m):连「解压 1.58GB Flutter 归档」这一步就把 3.6G 吃穿,
# 同样失联 —— 说明瓶颈不只是 Gradle 堆,而是这台机器整体装不下这套工具链。
#
# 结论:**Android 出包回本机做**(M3 Max 上 1m39s 编完 debug)。
# 本文件保留为「换到 ≥8G / ≥4 核独立 runner 后可直接用」的完整配方,
# 但触发方式已收紧为仅手动,且 Preflight 阈值抬到 6000MB ——
# 在这台机器上手点也会被明确拒绝,不会再去压 Gitea。
#
# ────────────────────────────────────────────────────────────
# 墨观 InkWatch · Android APK(arm64)构建验证
#
# 目的不是出可发行包,而是回答一个问题:**核心库(Rust)能不能被交叉编译、
# 并真的打进 APK**。2026-11-10 实测发现本地 jniLibs 里躺着改名前的
# libpmon_core.so(10-30 的产物),而 Dart 侧只认 libinkwatch_core.so ——
# 也就是说"改完名没重编安卓库"这条断链一直没人发现。
#
# ── 触发 ────────────────────────────────────────────────────
# **仅手动**:Actions → Run workflow,且要求 runner ≥6000MB(见 Preflight)。
# 原先还挂了 push(paths: rust-core/** 等),事故后已摘掉 —— 在换成独立 runner
# 之前不要恢复,否则一次改 rust-core 就能把 Gitea 再压死一次。
#
# ── 环境事实(2026-11-10 探针实测,勿凭猜改)─────────────────
# dl.google.com 200 / 171ms,直连下载约 14MB/s → SDK/NDK 可直接下
# maven.google.com 连接超时 → Gradle 仓库必须走阿里云
# maven.aliyun.com 200 / 93~134ms → 用它的 google/public/gradle-plugin
# mirrors.cloud.tencent 200(gradle 分发)/ gradle-wrapper 已指向它
# repo.maven.apache.org 200 / 2s(可用但慢,作为兜底)
# deb.debian.org 200 / 2.6s,但 apt-get update 实测 4 分钟无输出 → 不用 apt
# rsproxy.cn 200 / 121ms → rustup 与 crates 走它
#
# ── 版本锚点(与 Flutter 3.47.9 / 本机对齐,改前先核对)───────
# compileSdk 36 / targetSdk 36 / minSdk 24 / NDK 28.2.13676358
# 来源:flutter_tools/gradle/.../FlutterExtension.kt
#
# ── 签名 ──────────────────────────────────────────────────
# 仓库的 app/build.gradle.kts 明确要求 release 必须有 key.properties,
# 缺失即失败(拒绝回落 debug 签名)。CI 里每次生成**一次性密钥**,
# 产物仅供验证,不可发行 —— 日志与步骤名都标了 NOT FOR RELEASE。
# 密钥材料在 job 末尾(含失败时)删除。
name: Android APK (arm64, CI-verified)
on:
# 只留手动。push 触发在 2026-11-10 两次把同机的 Gitea 压到无响应,
# 换独立 runner 之前不要恢复。
workflow_dispatch:
jobs:
apk:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
PUB_HOSTED_URL: https://pub.flutter-io.cn
RUSTUP_DIST_SERVER: https://rsproxy.cn
RUSTUP_UPDATE_ROOT: https://rsproxy.cn/rustup
ANDROID_NDK_VERSION: '28.2.13676358'
steps:
- name: Checkout
uses: actions/checkout@v4
# 2026-11-10 教训:runner 与 Gitea 同机,跑 Android 构建前必须先看清家底。
# 本机实测 3654MB / 4 核 —— 两次都把 Gitea 压到 HTTP=000、SSH 卡 banner。
# 阈值定在 6000MB:小于它直接**拒绝执行**(失败关闭),
# 因为"解压 1.58GB Flutter + 装 NDK + Gradle 编译"这套峰值就在 4G 上下,
# 与 Gitea 抢内存必输。
- name: Preflight (host resources)
run: |
set -euo pipefail
MEM_MB=$(awk '/MemTotal/{printf "%d", $2/1024}' /proc/meminfo)
AVAIL_MB=$(awk '/MemAvailable/{printf "%d", $2/1024}' /proc/meminfo)
# /proc/meminfo 在容器里通常反映宿主,正是我们要看的
echo "MemTotal=${MEM_MB}MB MemAvailable=${AVAIL_MB}MB"
df -h / /tmp | sed 's/^/ /'
CORES=$(nproc)
echo "cores=$CORES"
if [ "$MEM_MB" -lt 6000 ]; then
echo "✗ 宿主内存 ${MEM_MB}MB < 6000MB:本流水线要求独立 runner,拒绝在共享机上执行" >&2
echo " 本机(M3 Max)出包更快:cd client && bash scripts/build_android_core.sh arm64-v8a" >&2
echo " flutter build apk --release --target-platform android-arm64" >&2
echo " 这不是构建失败,是拒绝执行(失败关闭)—— 2026-11-10 已两次把 Gitea 压到需重启。" >&2
exit 1
fi
if [ "$AVAIL_MB" -lt 3000 ]; then
echo "✗ 可用内存仅 ${AVAIL_MB}MB:先释放资源再来" >&2
exit 1
fi
echo "✓ 资源检查通过"
- name: Setup Flutter (mirror archive, no jq)
run: bash scripts/ci/install-flutter.sh
# 不用 apt:探针里 apt-get update 四分钟无输出,不可靠。
# 华为云 OpenJDK 21 单文件直下(本机也是 21,与 AGP 9.1 / Kotlin 2.4 对齐)。
- name: Setup JDK 21 (NOT FOR RELEASE)
run: |
set -euo pipefail
curl -fsSL --retry 3 --retry-delay 5 --connect-timeout 20 \
-o /tmp/jdk.tar.gz \
"https://repo.huaweicloud.com/openjdk/21.0.2/openjdk-21.0.2_linux-x64_bin.tar.gz"
mkdir -p "$HOME/jdk"
tar -xzf /tmp/jdk.tar.gz -C "$HOME/jdk" --strip-components=1
rm -f /tmp/jdk.tar.gz
echo "JAVA_HOME=$HOME/jdk" >> "$GITHUB_ENV"
echo "$HOME/jdk/bin" >> "$GITHUB_PATH"
export JAVA_HOME="$HOME/jdk"
"$JAVA_HOME/bin/java" -version
- name: Setup Android SDK (platform 36 + build-tools 36 + NDK)
run: |
set -euo pipefail
SDK="$HOME/android-sdk"
mkdir -p "$SDK/cmdline-tools"
curl -fsSL --retry 3 --retry-delay 5 -o /tmp/clt.zip \
"https://dl.google.com/android/repository/commandlinetools-linux-13114758_latest.zip"
unzip -q /tmp/clt.zip -d /tmp/clt
mv /tmp/clt/cmdline-tools "$SDK/cmdline-tools/latest"
rm -rf /tmp/clt /tmp/clt.zip
echo "ANDROID_SDK_ROOT=$SDK" >> "$GITHUB_ENV"
echo "ANDROID_HOME=$SDK" >> "$GITHUB_ENV"
echo "$SDK/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
echo "$SDK/platform-tools" >> "$GITHUB_PATH"
# yes 被 sdkmanager 提前关闭会触发 SIGPIPE,故吞掉退出码
yes | "$SDK/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true
"$SDK/cmdline-tools/latest/bin/sdkmanager" --install \
"platform-tools" \
"platforms;android-36" \
"build-tools;36.0.0" \
"ndk;${ANDROID_NDK_VERSION}"
echo "ANDROID_NDK_HOME=$SDK/ndk/${ANDROID_NDK_VERSION}" >> "$GITHUB_ENV"
ls "$SDK/ndk"
- name: Setup Rust + cargo-ndk + android target
run: |
set -euo pipefail
mkdir -p "$HOME/.cargo"
printf '%s\n' \
'[source.crates-io]' \
'replace-with = "rsproxy-sparse"' \
'' \
'[source.rsproxy-sparse]' \
'registry = "sparse+https://rsproxy.cn/index/"' \
> "$HOME/.cargo/config.toml"
curl -fsSL --retry 3 --retry-delay 5 -o /tmp/rustup-init.sh https://rsproxy.cn/rustup-init.sh
sh /tmp/rustup-init.sh -y --profile minimal --default-toolchain stable
. "$HOME/.cargo/env"
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
rustup target add aarch64-linux-android
cargo install cargo-ndk --locked
cargo ndk --version
# ⚠️ 这里**故意不放 init.gradle 注入仓库**。
# run 14 实测:加了 allprojects { repositories.maven {…} } 后,Gradle 直接报
# Build was configured to prefer settings repositories over project repositories
# but repository 'maven' was added by settings file 'settings.gradle.kts'
# 因为 Flutter/AGP 把仓库模式设成 PREFER_SETTINGS —— 只认 settings 那份,
# 子在项目里再声明仓库就违规。所以镜像只加在 settings.gradle.kts 里(治本),
# 项目级依赖会自动复用那份列表,无需(也不能)另开一份。
#
# 想确认镜像是否真的被用上:看 flutter build 阶段有没有
# "Could not GET 'https://maven.google.com/…'" 这类超时。
- name: Write local.properties (flutter.sdk is required by settings.gradle.kts)
run: |
set -euo pipefail
printf 'flutter.sdk=%s\nsdk.dir=%s\n' \
"$HOME/development/flutter" "$HOME/android-sdk" \
> client/android/local.properties
cat client/android/local.properties
- name: Generate throwaway keystore (NOT FOR RELEASE)
run: |
set -euo pipefail
KS=/tmp/inkwatch-ci-release.jks
"$JAVA_HOME/bin/keytool" -genkeypair -v -keystore "$KS" -storetype JKS \
-keyalg RSA -keysize 2048 -validity 3650 -alias ci \
-storepass ci-temp-only -keypass ci-temp-only \
-dname "CN=CI TEMPORARY - NOT FOR RELEASE, OU=ci, O=inkwatch, L=NA, ST=NA, C=CN" \
>/dev/null
printf 'storeFile=%s\nstorePassword=ci-temp-only\nkeyAlias=ci\nkeyPassword=ci-temp-only\n' \
"$KS" > client/android/key.properties
echo "已生成一次性密钥(仅本次 job 有效,不入库、不可发行)"
- name: Build Rust core → jniLibs (arm64-v8a)
run: bash client/scripts/build_android_core.sh arm64-v8a
- name: Build APK (release, arm64 only)
env:
# ⚠️ 必须在 CI 里覆盖 client/android/gradle.properties 的
# `org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G`。
# 那份是给开发机的(本机内存宽裕、构建快);runner 与 Gitea 同机同内存,
# 2026-11-10 run 16 实测:Gradle 按 8G 堆起来后整机被压住,
# Gitea 的 TCP 端口能握手但 HTTP 无响应(curl 8s 超时、HTTP=000),
# SSH 也卡在 banner exchange —— 服务端不是崩了,是被饿死。
GRADLE_OPTS: >-
-Dorg.gradle.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=384m -XX:+HeapDumpOnOutOfMemoryError
-Dkotlin.daemon.jvm.options=-Xmx512m
-Dorg.gradle.workers.max=2
-Dorg.gradle.parallel=false
run: |
set -euo pipefail
cd client
flutter pub get
flutter build apk --release --target-platform android-arm64
# 关键校验:APK 里必须真有 lib/arm64-v8a/libinkwatch_core.so。
# 2026-11-10 之前这里躺着的是改名前的 libpmon_core.so,
# 构建全绿但装到机器上会加载失败 —— 静态校验拦这条。
- name: Verify APK contents
run: |
set -euo pipefail
APK="client/build/app/outputs/flutter-apk/app-release.apk"
test -f "$APK" || { echo "APK 未生成" >&2; exit 1; }
echo "=== 体积 ==="
du -h "$APK"
echo "=== sha256 ==="
sha256sum "$APK"
echo "=== 核心库 ==="
LIST="$(unzip -l "$APK")"
echo "$LIST" | grep -E 'lib/arm64-v8a/.*\.so' || true
if ! echo "$LIST" | grep -q 'lib/arm64-v8a/libinkwatch_core.so'; then
echo "✗ APK 里没有 libinkwatch_core.so —— Dart 侧会在加载阶段失败" >&2
exit 1
fi
if echo "$LIST" | grep -q 'libpmon_core.so'; then
echo "✗ APK 里出现了改名前的 libpmon_core.so(旧产物没清干净)" >&2
exit 1
fi
echo "✓ 核心库已正确打包"
# 文件名对了 ≠ 库能用。再验一次动态符号表:期望值从 ffi.rs **现取**,
# 不写死数字(改一个 FFI 就会变,写死即漂移 —— 本仓「可导出量不写副本」纪律)。
echo "=== 核心库导出符号 ==="
EXPECTED=$(grep -cE 'pub extern "C" fn inkwatch_' rust-core/src/ffi.rs || true)
unzip -p "$APK" lib/arm64-v8a/libinkwatch_core.so > /tmp/inkwatch_core.so
NM="${ANDROID_NDK_HOME:-}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-nm"
[ -x "$NM" ] || NM=nm
ACTUAL=$("$NM" -D --defined-only /tmp/inkwatch_core.so 2>/dev/null | grep -c 'inkwatch_' || true)
rm -f /tmp/inkwatch_core.so
echo "期望 $EXPECTED(rust-core/src/ffi.rs),实际 $ACTUAL"
if [ "$EXPECTED" -lt 1 ] || [ "$ACTUAL" -lt "$EXPECTED" ]; then
echo "✗ 导出符号不足:文件名对了但库不可用(strip / 编译配置有问题)" >&2
exit 1
fi
echo "✓ 导出符号齐全"
- name: Clean up key material (always)
if: always()
run: |
rm -f /tmp/inkwatch-ci-release.jks client/android/key.properties
echo "一次性密钥材料已删除"
+134
View File
@@ -0,0 +1,134 @@
# 墨观 InkWatch V4.0 · 设计系统双向护栏 CI 入口
#
# 前提:本 workflow 只有在 Gitea 实例「启用 Actions」时才会真正运行;
# 未启用时该文件仅作声明,不会触发任何检查。
# 本地复现(无需 Actions、无需 Gitea):cd <repo> && bash tests/run-guards.sh
# 不在本文件里引 CDN、不写任何凭据;Flutter 用官方 flutter-action 安装。
#
# Flutter / Dart 段说明:
# client/scripts/test.sh 默认找 $HOME/development/flutter/bin/flutter,
# 下面把 flutter 装到同一位置并把绝对路径经 FLUTTER_BIN 显式传给 test.sh,
# 以保证默认跑全量 Dart(含 token_parity_test.dart 的 CSS ↔ Dart 双向护栏)。
# SDK 取自 storage.flutter-io.cn 的 stable 归档(非 git clone),版本钉死。
# 若 runner 无法安装 Flutter:设 SKIP_FLUTTER_TESTS=1 降级 —— Dart 段会在
# 汇总表里显式标 SKIPPED (explicit),绝不静默通过。
#
# 管理台 axe 段(第 6 套件,默认不跑):
# run-guards.sh 只在 RUN_AXE=1 时执行 node tests/admin-axe-audit.mjs
# (3 视口 × 2 皮肤 × 6 页 + E-Ink 6 格 = 42 格,单跑约 45s)。
# **runner 必须自带 Chromium 系浏览器(google-chrome / microsoft-edge /
# chromium)并能联网安装 axe-core,否则保持默认不跑**;若强行启用而浏览器缺失,
# 该套件以退出码 2 失败(汇总表 FAIL,不会伪造成 PASS)。
# 两种启用方式(任选其一,均不硬编码任何凭据):
# ① 手动触发:Actions 里 Run workflow → run_axe 勾选 true;
# ② 仓库变量:Gitea 仓库 Settings → Actions → Variables 设 IW_RUN_AXE=1,
# 之后 push / pull_request 也会带上该套件。
# 若所在 Gitea 版本不支持 vars 上下文,删掉下面 job 级 env 里
# IW_RUN_AXE 那一行即可(其余逻辑不变,退回「默认不跑」)。
name: Design System Guard
on:
push:
pull_request:
workflow_dispatch:
inputs:
run_axe:
# 用字符串而非 type: boolean —— 兼容不支持 inputs.type 的旧版 Gitea
description: '启用管理台 axe 42 格矩阵(填 true 启用;需 runner 自带 Chromium/Edge)'
default: 'false'
jobs:
guard:
runs-on: ubuntu-latest
# 2026-11-09:历史上 6 次运行全红,根因两处,均已在此固化:
# ① subosito/flutter-action@v2 的 setup.sh 依赖 jq,而 runner 的
# node:20-bookworm 容器没有 jq → 首行即 "jq not found" 退出。
# 故本文件不再使用该 action,改为直取官方镜像的 tar.xz(无需 jq)。
# ② 该 runner 到 github.com 与 nodejs.org 的直连极慢/超时
# (实测 setup-node 重试 3 轮烧掉 8 分钟才靠 fallback 成功;
# flutter-action 默认 `git clone github.com/flutter/flutter` 必挂)。
# 故 Flutter 全程改走国内镜像。
# setup-node 也已删除:其 `mirror` 参数只影响 dist 下载,解析版本清单
# 时仍打 github.com/actions/node-versions,实测照样 ETIMEDOUT。
# 本 job 只用 fs/path/crypto 三个内置模块,容器自带的 node 20 足够
# (runner 的 ubuntu-latest 映射到 node:20-bookworm,见运行日志)。
timeout-minutes: 30
env:
# 仓库变量(可选);未设置时为空串,等价于「默认不跑 axe」
IW_RUN_AXE: ${{ vars.IW_RUN_AXE }}
# Flutter/Dart 官方中国镜像(下载 SDK 与 pub 包均走它,避开 github/pub.dev)
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
PUB_HOSTED_URL: https://pub.flutter-io.cn
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Report toolchain
run: |
# 不使用 setup-node:容器自带 Node,版本随镜像走,此处只记录以便追溯。
command -v node >/dev/null 2>&1 || { echo "容器内无 node,无法跑护栏脚本" >&2; exit 1; }
echo "node $(node --version) / npm $(npm --version 2>/dev/null || echo n/a)"
# schema-consistency-test.js 会 require server/shared/repository.js,
# 后者 require('pg')。本地能过是因为 node_modules 已装;干净 clone 没有。
- name: Install Node deps (pg, for schema guard)
run: |
set -euo pipefail
cd server
npm ci --omit=dev --no-audit --no-fund --registry=https://registry.npmmirror.com
node -e "require('pg'); console.log('pg ok')"
# 不用 subosito/flutter-action:它硬依赖 jq,且默认 git clone github。
# 安装逻辑抽到 scripts/ci/install-flutter.sh(与 android-build.yml 共用一份,
# 避免两处各写一遍再漂移)。
- name: Setup Flutter (mirror archive, no jq)
run: bash scripts/ci/install-flutter.sh
# clock_test / host_ffi_test 会 NativeLibrary.open 找 libinkwatch_core.so,
# 找不到就在 loading 阶段整份失败(Bad state: 未找到 libinkwatch_core.so)。
# 该 crate 只依赖 sysinfo/serde,跨平台可编;Linux 编出 .so 后
# core_lib.dart 的「仓库内构建产物」候选路径正好命中 rust-core/target/release。
- name: Build Rust core (for clock/host_ffi tests)
env:
RUSTUP_DIST_SERVER: https://rsproxy.cn
RUSTUP_UPDATE_ROOT: https://rsproxy.cn/rustup
run: |
set -euo pipefail
mkdir -p "$HOME/.cargo"
# 用 printf 而非 heredoc:YAML 的 block scalar 会带上缩进,
# 写进 config.toml 虽仍合法(TOML 忽略行首空白),但看着脏。
printf '%s\n' \
'[source.crates-io]' \
'replace-with = "rsproxy-sparse"' \
'' \
'[source.rsproxy-sparse]' \
'registry = "sparse+https://rsproxy.cn/index/"' \
> "$HOME/.cargo/config.toml"
curl -fsSL --retry 3 --retry-delay 5 -o /tmp/rustup-init.sh https://rsproxy.cn/rustup-init.sh
sh /tmp/rustup-init.sh -y --profile minimal --default-toolchain stable
. "$HOME/.cargo/env"
rustc --version
cargo build --release --manifest-path rust-core/Cargo.toml
ls -la rust-core/target/release/libinkwatch_core.so
- name: Run design system guards
run: |
# FLUTTER_BIN 由 Setup Flutter 步骤写入 GITHUB_ENV,此处优先采用
if [ -n "${FLUTTER_BIN:-}" ] && [ -x "$FLUTTER_BIN" ]; then
:
elif [ -n "${FLUTTER_ROOT:-}" ] && [ -x "$FLUTTER_ROOT/bin/flutter" ]; then
export FLUTTER_BIN="$FLUTTER_ROOT/bin/flutter"
elif command -v flutter >/dev/null 2>&1; then
export FLUTTER_BIN="$(command -v flutter)"
fi
# 管理台 axe 段:默认 0;手动触发时取 run_axe 输入;否则看仓库变量 IW_RUN_AXE
RUN_AXE="${IW_RUN_AXE:-0}"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
if [ "${{ inputs.run_axe }}" = "true" ]; then RUN_AXE=1; else RUN_AXE=0; fi
fi
export RUN_AXE
echo "RUN_AXE=$RUN_AXE(0=跳过管理台 axe 42 格矩阵,1=执行)"
bash tests/run-guards.sh
+10
View File
@@ -56,3 +56,13 @@ temp/
coverage/
.nyc_output/
*.lcov
# filter-branch 的临时工作树(曾被误提交,务必忽略)
.git-rewrite/
# ── 文档类文件只留本地(2026-10-17 定)────────────────────────────
# 除 README / LICENSE / .env.example 外,文档不进仓库(本地存档于
# ~/WorkBuddy/水墨屏工具/inkwatch-docs-local/)。除非项目运行离不开
# (如部署配置模板),那时也该放 deploy/ 并说明理由,不要放 docs/。
docs/
patch_*.py
+476 -47
View File
@@ -10,7 +10,7 @@
**主线完成度**:第 1 条已把 Rust 核心的**采集 / 传输 / 保活**三块补齐
(`collector.rs` / `transport.rs` / `keepalive.rs`),共 39 个 C ABI 导出符号
(`nm -gU target/release/libpmon_core.dylib | grep -c _pmon_` = 39)、
(`nm -gU target/release/libinkwatch_core.dylib | grep -c _inkwatch_` = 39)、
59 项 Rust 单测、79 项真实加载产物的 FFI 冒烟。剩余未接入项集中列在文末「已知边界」。
---
@@ -18,26 +18,31 @@
## 目录结构
```
pmon/
inkwatch/
├── README.md # 本文件
├── LICENSE # MIT
├── .env.example # 环境变量示例(真实 .env 不入库)
├── docs/
│ ├── inkwatch-v4-architecture.md # V4.0 方案正文(17 章 / 33 表)
│ ├── pmon-v4-架构方案-V4.0.html # 单页报告版
│ └── 全平台设备性能监控系统商业化实施方案V4.0.docx
├── shared/
│ ├── metrics-schema.js # ★ 指标字段单一真相源(协议/Rust/表列/仓储全由它派生)
│ ├── protocol.js # 协议 v1:信封校验 / 白名单 / 版本兼容 / 编码协商
│ └── codec.js # JSON / MessagePack 双模(含数值量化 + 结构指纹)
│ ├── codec.js # JSON / MessagePack 双模(含数值量化 + 结构指纹)
│ ├── accounts.js # 账号口径 / 令牌前缀 / 限流常量 / 口令强度
│ └── permissions.js # ★ 能力矩阵与档位表单一真相源(13 能力 × 3 角色 + ownerOnly)
├── server/
│ ├── gateway/ # 无状态 Socket.io 网关
│ │ ├── index.js # 入口:健康检查 / 优雅关闭 / 授权失效订阅
│ │ ├── gateway.js # 事件处理:hello / metrics / heartbeat / subscribe
│ │ └── store.js # Redis 访问层(定长缓冲 / 在线态 / 订阅 / 配对)
│ ├── business/index.js # 业务服务:账号 / 配对 / 授权 / 审计
│ ├── business/
│ │ ├── index.js # 业务服务:账号 / 配对 / 授权 / 成员 / 配额 / 审计
│ │ ├── admin.js # ★ 管理端:ADMIN_TOKEN 鉴权 + /api/admin/* + CSV 导出
│ │ └── admin/ # ★ 管理台静态页(5 页 + assets,零依赖、无 CDN)
│ └── shared/
│ └── repository.js # PostgreSQL 仓储(参数化 SQL / 授权缓存 / 跨进程失效)
│ ├── repository.js # PostgreSQL 仓储(参数化 SQL / 授权缓存 / 跨进程失效)
│ ├── admin-queries.js # ★ 运营者视角查询(全站分页/统计/存储/降采样;绝不 SELECT 凭据列)
│ ├── presence.js # ★ 在线态判定唯一入口(业务端与管理端共用,失败如实返回 null)
│ ├── mailer.js # 发信通道(outbox / console / smtp;令牌**绝不进 HTTP 响应**)
│ └── device-identity.js # "是不是同一台机"的唯一判据(hardware_id 优先 → 后缀 → 歧义不动)
├── rust-core/ # Rust 核心(采集 + 传输 + 保活,五端复用)
│ ├── src/collector.rs # 指标采集(CPU/内存/磁盘/网络/运行时长)+ 单位换算
│ ├── src/buffer.rs # 定长环形缓冲 + 指数退避(含单测)
@@ -45,16 +50,24 @@ pmon/
│ ├── src/keepalive.rs # ★ 保活三层模型 / 守护调度 / 五端自启产物生成
│ ├── src/ffi.rs # C ABI 导出(37 个符号,panic 兜底 / 空指针安全)
│ └── src/main.rs # 本地演示程序(8 段)
├── client/ # Flutter 客户端(采集端 + 查看端 + 水墨屏看板)
│ ├── lib/host/ # 宿主层:会话/配置/账号/角色/单位/采样
│ ├── lib/ui/ # 界面层(样式只许来自 ui/theme.dart,有护栏测试)
│ └── test/ # Dart 测试(含视觉令牌护栏与跨语言角色目录校验)
├── deploy/
│ ├── docker-compose.yml # 编排:nginx + gateway + business + redis + pg
│ ├── docker-compose.yml # 编排:gateway + business + redis + pg
│ ├── Dockerfile.gateway # 多阶段构建(ADR-001)
│ ├── Dockerfile.business
│ ├── nginx.conf # WSS 反代 / 长连接调优
│ ├── nginx.host.conf.example # **宿主**反代模板(WSS / 长连接 / /admin/ 路由)
│ ├── nginx-all.conf # 单容器统一入口(3500)
│ └── init.sql # TimescaleDB 超表 / 保留策略 / 降采样(含增量迁移段)
└── tests/
├── schema-consistency-test.js # ★ 指标字段三层一致性 + 协议常量/Dart 目录跨语言校验 + init.sql 建表顺序(99 项)
├── schema-consistency-test.js # ★ 指标字段三层一致性 + 协议常量/Dart 目录/能力矩阵跨语言 + init↔migrate 列类型(116 项)
├── uplink-msgpack-test.js # ★ 上行 msgpack 解包顺序契约(52 项)
├── dockerfile-coverage-test.js # ★ 镜像 COPY 覆盖性(按 require 图,19 项)
├── dockerfile-coverage-test.js # ★ 镜像 COPY 覆盖性(require 图 + **静态资源目录**,49 项)
├── permissions-test.js # ★ 能力矩阵 13×3 全枚举 + ownerOnly + 失败关闭(29 项)
├── user-lifecycle-test.js # ★★ 用户机制端到端(66 项,自起临时实例)
├── admin-api-test.js # ★★ 管理端鉴权与数据(80 项,9 个隔离实例)
├── ffi-smoke.py # ★ 真实加载 dylib 调 C ABI + 跨语言契约(79 项)
├── pairing-security-test.js # ★★ 配对授权绕过回归网(离线契约 23 / 全量 84 项)
├── rust-frame-pipeline-test.js # ★★ Rust 真实帧 → 白名单 → 时序表列(24 项,需起服务)
@@ -63,8 +76,8 @@ pmon/
├── codec-test.js # 编解码正确性 + 体积收益(64 项)
├── e2e-smoke.js # 端到端冒烟(35 项)
├── viewer-e2e-test.js # ★★ 查看端端到端:真实采集端+配对+真实 Dart CLI(21 项)
├── account-test.js # ★★ 账号端到端:账号/归属/**免配对**/历史回溯/兼容模式(65 项)
├── auth-mode-test.js # ★★ 第二步开关:强制登录/配额/设备令牌防冒充(21 项)
├── account-test.js # ★★ 账号端到端:账号/归属/**免配对**/历史回溯/兼容模式(74 项)
├── auth-mode-test.js # ★★ 第二步开关:强制登录/配额(402)/设备令牌防冒充(39 项)
├── persistence-restart.js # 持久化与重启恢复(24 项)
├── race-residue.js # 断连竞态残留(3 项)
└── leak-check.js # 泄漏专项(重连 500 次 / 内存红线,8 项)
@@ -77,7 +90,7 @@ pmon/
> ★★ `rust-frame-pipeline-test.js` 是本仓库**最重要的一道防线**:其余套件用的都是手写样本载荷,
> 测不出"采集层字段名与协议白名单漂移"——这正是当年 P0 能全绿通过的原因。
> 它用 ctypes 调 `pmon_collect_frame()` 取**真实帧**,原样上报,再查库逐项比对。
> 它用 ctypes 调 `inkwatch_collect_frame()` 取**真实帧**,原样上报,再查库逐项比对。
> ★ 标记为本轮新增。`server/shared/keepalive.js` 已移除 —— 保活按方案 3.4 下沉至
> `rust-core/src/keepalive.rs`,原 JS 骨架是无人引用的重复真相源(五端方法体全是
@@ -93,11 +106,16 @@ pmon/
cd deploy
docker compose up -d --build
docker compose ps # 5 个容器应全部 healthy
curl http://localhost/healthz # ok
docker compose ps # 4 个容器应全部 healthy
curl http://127.0.0.1:4000/health # ok
```
默认仅 HTTP,开箱可用。启用 HTTPS 见 `deploy/nginx.ssl.conf.example`。
> **接入层不在容器里**(2026-11-08 起)。原先的 nginx 容器已移除:那台机的 80/443
> 本来就被 1Panel OpenResty 占着,自带 nginx 只是多一跳。现在反代由宿主承担,
> 模板见 `deploy/nginx.host.conf.example`(裸 nginx 整份照抄;1Panel 只抄 location 段)。
> 四条承载性指令必须抄全(WebSocket 升级头 / `proxy_read_timeout 600s` /
> `proxy_buffering off` / `/relay/` 的 `proxy_request_buffering off`)——
> 少一条的表现不是 502,而是"能开页面但画面不刷新"或"文件其实落了盘"。
**无 TimescaleDB 镜像时**(国内拉取 1GB 级镜像可能极慢):`init.sql` 已做成
**扩展可缺省**,把 PG 换成普通的即可,表结构与指标列完全一致:
@@ -118,32 +136,93 @@ POSTGRES_IMAGE=postgres:15-alpine docker compose up -d --build
| 服务 | 端口 | 用途 |
|---|---|---|
| nginx | 80 | 接入层(`/socket.io/` → 网关,`/api/` → 业务) |
| gateway | 3000 | Socket.io 网关(调试直连用) |
| business | 4000 | 业务 REST(调试直连用) |
| gateway | 3000 | Socket.io 网关(宿主反代的上游) |
| business | 4000 | 业务 REST(宿主反代的上游) |
| postgres / redis | 5432 / 6379 | 仅 `127.0.0.1` 绑定 |
| ~~nginx~~ | ~~80~~ | **已移除**(2026-11-08):反代归宿主,见上面的模板 |
> 凭据经 `.env` 注入,默认值为本地开发用。生产务必覆盖 `POSTGRES_PASSWORD`。
**想一个容器跑全部?** 单容器路径(postgres + redis + gateway + business
四进程一个容器,supervisord 各自守护;数据卷同名,与四容器随时互切、
数据不动):
```bash
cd deploy
docker compose -f docker-compose.all.yml up -d --build
docker exec inkwatch-app-1 supervisorctl status # 四进程 RUNNING
```
取舍(单容器是单机运维形态):进程隔离弱于四容器、容器重启四进程齐动、
升级要重建整镜像;好处是常驻容器从四个变成一个,数据卷同名可随时互切。
### 首次部署:先过一遍初始化向导
**全新库初始化之前,除 `/health` 与 `/setup` 外所有接口一律 503**。这是刻意
的顺序约束:还没确定"这台实例归谁管"就先对外服务,最坏结果是客户端先接进来、
数据先落一半、配置还是代码默认值 —— 一个事后无从复现的中间态。
```bash
cd deploy
# 一次性引导令牌,≥32 字符;不设 = 向导根本不存在(/setup 与 /api/setup/* 全 404)
echo "INSTANCE_SETUP_TOKEN=$(openssl rand -hex 24)" >> .env
docker compose up -d --build
```
浏览器打开 `https://<你的域名>/setup`,三步:给这台实例起个名字 → 填管理员
邮箱与口令 → 粘引导令牌。完成后自动跳到 `/admin/`,向导从此永久 404。
三条要记住的:
| 事项 | 说明 |
|---|---|
| 令牌只认 `Authorization: Bearer` | 不接受 `?t=`:query 会进 nginx access log、浏览器历史、Referer |
| **只能成功一次** | 由数据库裁决(`UPDATE ... WHERE bootstrap_state <> 'ready'`)。两个请求同时到达时,第二个拿 409 |
| 完成后重启也不复活 | 判据是库里的状态,不是内存变量 |
**存量库升级不会看到这一步**:`migrate.sql` 按"`users` 表里有没有数据"把状态落成
`adopted`,直接按现有配置继续服务(升级一台跑着几十台设备的实例,先 503 五分钟
等运维点向导就是事故)。此时打开 `/setup` 可以补一个管理员,**已存在的邮箱只提升
权限、不改口令** —— 自动改口令会造成"填错成别人邮箱 = 改掉了别人的口令"。
**配置存在哪**:`instance_config`(单行表,`CHECK (id = 1)`)。管理端
「实例设置」页改完立刻生效,不必重启。`INSTANCE_NAME` / `REQUIRE_AUTH` /
`ALLOW_OPEN_REGISTER` / `ENFORCE_DEVICE_QUOTA` / `FREE_DEVICE_LIMIT` /
`RELAY_MAX_BYTES` / `MAIL_*` 这些环境变量仍然有效,且**优先级高于库**(界面上会
标成"被环境变量锁定")—— 这样配置文件与界面两个入口不会互相打架。
> ⚠️ **密钥类配置一律不入库**:`POSTGRES_PASSWORD`、`ADMIN_TOKEN`、
> `INSTANCE_SETUP_TOKEN`、SMTP 口令只留在 `.env`。库会被备份、导出、被管理端
> 读出,塞进去等于把"要 shell 权限才拿得到"降级成"能读库就拿得到"。
> ⚠️ `REQUIRE_AUTH` / `REQUIRE_DEVICE_TOKEN` 这两个开关**跨网关与业务两个进程**,
> 两边都从同一份库配置取(env 覆盖),网关每 3 秒同步一次。只改一半的后果是
> "业务端拒绝匿名请求、订阅通道照样放行" —— 半个开关比没有开关更危险。
**审计保留期**(`audit_retention_days`)也在这一页:留空 = 永久保留。填了之后业务
进程每 6 小时按批清理一次(每批 5000 行,不长时间持锁),并在删完后写一条
`audit_pruned`(记下按多少天删了几行)—— 删除本身必须留痕,否则"记录突然变少了"
在审计里没有任何解释。
### 方式 B:本地开发(不走容器)
```bash
docker run -d --name pmon-redis-dev -p 6379:6379 redis:7.4-alpine \
docker run -d --name inkwatch-redis-dev -p 6379:6379 redis:7.4-alpine \
redis-server --save "" --appendonly no
docker run -d --name pmon-pg-dev -p 5432:5432 \
-e POSTGRES_USER=pmon -e POSTGRES_PASSWORD=pmon_dev_only -e POSTGRES_DB=pmon \
docker run -d --name inkwatch-pg-dev -p 5432:5432 \
-e POSTGRES_USER=inkwatch -e POSTGRES_PASSWORD=inkwatch_dev_only -e POSTGRES_DB=inkwatch \
timescale/timescaledb:2.17.2-pg16
# 初始化表结构
docker exec -i pmon-pg-dev psql -U pmon -d pmon < deploy/init.sql
docker exec -i inkwatch-pg-dev psql -U inkwatch -d inkwatch < deploy/init.sql
```
### 启动服务
```bash
REDIS_URL=redis://127.0.0.1:6379
PG_URL=postgres://pmon:pmon_dev_only@127.0.0.1:5432/pmon
PG_URL=postgres://inkwatch:inkwatch_dev_only@127.0.0.1:5432/inkwatch
# 网关(端口 3000)—— 必须带 --expose-gc 才能周期回收堆
cd server/gateway && npm install && \
@@ -163,7 +242,7 @@ cd server/business && npm install && \
cd rust-core && cargo test --release # Rust 核心单测(59 项)
cd tests && npm install
bash run-all.sh # 一把梭:18 个套件(9 个离线 + 9 个需起服务)
bash run-all.sh # 一把梭:39 个套件(离线即可跑 + 需起服务)
# 离线套件(无需任何外部依赖)
node dockerfile-coverage-test.js # ★ 镜像 COPY 覆盖性(按 require 图)
node uplink-msgpack-test.js # ★ 上行 msgpack 解包顺序契约
@@ -171,6 +250,13 @@ node schema-consistency-test.js # ★ 指标字段三层一致性 + 协议常
node protocol-test.js # 协议契约
node codec-test.js # 编解码 + 体积
node pairing-security-test.js --offline-only # ★ 配对契约与判定顺序(源码级)
node permissions-test.js # ★ 能力矩阵 13×3 全枚举 + ownerOnly + 三条失败关闭
node user-lifecycle-test.js # ★★ 用户机制端到端(自起临时实例,只需 PG + Redis)
node admin-api-test.js # ★★ 管理端鉴权与数据(自起 9 个隔离实例)
node setup-test.js # ★★ 安装向导:四道闸 + 并发 + 存量库升级(建 4 个临时库)
node instance-config-wiring-test.js # ★ 每个实例配置字段都有人读(界面文件不算消费)
node smtp-test.js # ★★ 发信传输层(自带假 SMTP:点填充/编码/STARTTLS/465 隐式 TLS)
node smtp-live-test.js # 真实投递联调(需 SMTP_* 凭据;未配则跳过,不算失败)
python3 ffi-smoke.py # ★ 真实加载 dylib 调 C ABI
# 客户端(Dart):查看端逻辑 48 + 查看端界面 17 + 配对卡界面 6 + 自适应单位 24 + FFI 冒烟 28
cd ../client && bash scripts/test.sh # 入口脚本已负责清代理变量(否则本地 WS 会假失败)
@@ -180,20 +266,22 @@ node pairing-security-test.js # ★★ 配对授权绕过回归网(含 D
node viewer-e2e-test.js # ★★ 查看端端到端(真实 Dart CLI 订阅真实采集端)
node account-test.js # ★★ 账号体系(注册/登录/归属/免配对/向后兼容)
node auth-mode-test.js # ★★ 安全开关(就地拉起严格模式临时实例,跑完即停)
# 用户机制(user-lifecycle-test.js)已在上面的离线区:它自带实例,不依赖这两个服务
# 查看端 CLI 单独跑(真人可读输出):
# cd ../client && dart run bin/pmon_viewer.dart --account acct_demo --device <id> --seconds 8
# cd ../client && dart run bin/inkwatch_viewer.dart --account acct_demo --device <id> --seconds 8
node rust-frame-pipeline-test.js # ★★ Rust 真实帧全链路落库断言
node e2e-smoke.js # 端到端冒烟
node persistence-restart.js # 持久化与重启恢复(自带启停网关)
node race-residue.js # 断连竞态残留
ROUNDS=500 node leak-check.js # 泄漏专项(约 80s)
node perf-bench.js # ★ 性能基准(动网关热路径前后各跑一次,数字本地存档)
```
> ⚠️ 改过 `shared/metrics-schema.js` 后**必须重启网关**:白名单在进程启动时载入并缓存,
> 只改文件不重启会让"新代码 + 旧白名单"并存 —— 这正是全链路测试会报红的场景。
`ffi-smoke.py` 需要能 `import ctypes` 的 Python 3;产物缺失时它会自动尝试
`cargo build --release`。指定解释器:`PMON_PYTHON=/path/to/python3 bash run-all.sh`。
`cargo build --release`。指定解释器:`INKWATCH_PYTHON=/path/to/python3 bash run-all.sh`。
---
@@ -201,29 +289,42 @@ ROUNDS=500 node leak-check.js # 泄漏专项(约 80s)
| 验证项 | 结果 |
|-------|------|
| 指标字段一致性(三层命名 + 协议常量 + Dart 显示目录跨语言 + init.sql 建表顺序) | **99 / 99** 通过 |
| 指标字段一致性(三层命名 + 协议常量 + Dart 显示目录跨语言 + init.sql 建表顺序 + 能力矩阵跨语言 + init/migrate 列类型) | **116 / 116** 通过 |
| 上行 msgpack 链路(解包顺序 / 两形态 / 错误契约) | **52 / 52** 通过 |
| 镜像构建覆盖性(按 require 图 × 2 镜像 + 安全开关通路) | **25 / 25** 通过 |
| 镜像构建覆盖性(按 require 图 × 2 镜像 + **静态资源目录** + 安全开关通路 + 单容器端口回环) | **49 / 49** 通过 |
| FFI 冒烟(真实加载 dylib + 跨语言契约 + 空指针 + 反复建销) | **79 / 79** 通过 |
| Rust 核心单测(采集/缓冲/传输/保活/FFI 安全) | **59 / 59** 通过 |
| Rust 核心单测(采集/缓冲/传输/保活/FFI 安全) | **61 / 61** 通过 |
| 协议契约(白名单/版本/数值规整/协商) | **34 / 34** 通过 |
| 编解码(正确性 + 边界 + 体积) | **64 / 64** 通过 |
| **配对码安全**(错误码/重放/串设备/次数用尽/限流/失败关闭/审计) | **84 / 84** 通过(含离线契约 23 项) |
| **真实帧全链路**(Rust 采集 → 白名单 → 时序表列) | **24 / 24** 通过 |
| 端到端冒烟(闭环/授权/降级/清理) | **35 / 35** 通过 |
| **账号体系**(注册/登录/登出/归属/认领/免配对订阅/**历史回溯**/兼容模式) | **65 / 65** 通过 |
| **安全开关**(强制登录 / 配额上限 / 令牌轮换与防冒充 / **全新设备首次接入** / **上行通道不得绕过令牌**) | **36 / 36** 通过 |
| **客户端 Dart 测试**(查看端 49+18 / 配对卡 6 / 账号 21 / **单位自适应 17** / **格式化唯一出口 15** / **配置变更通知 11** / **视觉令牌护栏 6** / **机型采集 12** / **采样调度 3** / **历史 15** / FFI 冒烟) | **232 / 232** 通过 |
| **账号体系**(注册/登录/登出/归属/认领/免配对订阅/**历史回溯**/兼容模式) | **75 / 75** 通过 |
| **权限矩阵**(13 能力 × 3 角色全枚举 / `ownerOnly` 授权≠夺权 / 三条失败关闭 / 旧新入口一致) | **29 / 29** 通过 |
| **用户机制端到端**(注册验证 / 会话踢人 / 改密 / 找回**防枚举** / 邀请与角色 / 转移归属 / 配额真拦 / 注销冷静期 / 审计) | **66 / 66** 通过 |
| **安全开关**(强制登录 / 配额上限(402)/ **邮箱验证后按档位放开** / **配额关掉后真的回退** / 令牌轮换与防冒充 / **全新设备首次接入** / **上行通道不得绕过令牌**) | **41 / 41** 通过 |
| **云端管理端**(口令四形态失败关闭 / **短口令=未设置** / 口令不进 query / 不回显任何凭据 / CSV 公式注入 / 写动作审计 / IP 白名单 / 暴力上限 429) | **80 / 80** 通过 |
| **客户端 Dart 测试**(查看端逻辑+界面 / 配对卡 / 账号与**角色目录** / 单位自适应 / 格式化唯一出口 / 配置变更 / 视觉令牌护栏 / 机型采集 / 连接域 / 中继 / 采样调度 / 历史 / **成员卡**) | **598 / 598** 通过 |
| **查看端端到端**(真实采集端 + 配对 + 真实 CLI 查看端) | **21 / 21** 通过 |
| 持久化与重启恢复 | **24 / 24** 通过 |
| 断连竞态残留(100 轮零等待重连) | **3 / 3** 通过 |
| 泄漏专项(重连 500 次,GC 后稳态) | **8 / 8** 通过;稳态 `heap_used` **0.0% / −12.5%**,会话与订阅零残留 |
| 镜像构建 + 容器内模块解析 | 两镜像均构建成功并在容器内 `require()` 成功 |
| **Docker 全栈实跑**(nginx+gateway+business+redis+postgres 全容器化) | **18 套件 / 0 失败**,容器日志零 error |
| **Docker 全栈实跑**(nginx+gateway+business+redis+postgres 全容器化) | **35 套件 / 0 失败**,容器日志零 error |
**全量口径**:`bash tests/run-all.sh` → **18 套件 / 987 项断言全通过**
(配对安全套件的离线契约段与其全量段有 23 项重叠,去重后为 **964 项唯一断言**)。
**全量口径**:`bash tests/run-all.sh` → **35 套件 / 1965 项断言全通过 / 0 失败 / 0 跳过**
(1965 = 34 个带计数的套件自报合计;隐私扫描那套不报项数,只报结论。
含安装器 e2e 38 项与全历史扫描。配对安全套件的离线契约段与其全量段有 23 项重叠,
去重后为 **1942 项唯一断言**)。
> 顺带校了两处**标签漂移**:`run-all.sh` 里结构性守卫写 48、实跑 50,
> 镜像覆盖性写 45、实跑 49。这类漂移本身不致命,但它让"总断言数"变成
> 一个谁也复核不了的数字 —— 而本项目恰恰要求数字可验证。
> 该文件头部早就写了这条教训("标签写 416 而实跑已 598"),仍在复发,
> 故这次连头部注释一起校正。
上表的服务端套件已在 **Docker 全栈**(5 容器 healthy、经 nginx `:80` 入口)上复跑通过,
(**时代注记**:那一轮跑的是 5 容器形态。2026-11-08 移除 nginx 容器后为 4 容器 +
宿主反代;上述数字本身未因此变化,但"经 nginx `:80` 入口"这句只对当时成立。)
非仅本机进程直跑。
**真实帧实测值**(`rust-frame-pipeline-test.js` 打印,本机 M3 Max):
@@ -257,6 +358,22 @@ disk_free_gb=3251.79 net_rx_mbps=4.55 net_tx_mbps=1.71 uptime_s=106001
- ★ 每条 COPY 规则必须指向真实存在的文件(防止删文件后漏改 Dockerfile)
- ★★ **Rust 产出的真实帧**整帧 17 字段都能穿过白名单并落进对应时序列,数值逐项一致
- ★★ 帧中为 `null` 的指标(平台不可得的增强项)落库必须为 `NULL`,**不得编造 0**
- ★ 能力矩阵 **JS ↔ Dart 双向相等**(13 能力 × 3 角色 + `ownerOnly` 集合 + 档位表)
- ★ **授权 ≠ 夺权**:被授予 `owner` 角色的成员仍不能转移归属 / 删除设备
- ★ 一次性凭据**只存哈希**、原子消费(`UPDATE ... WHERE used_at IS NULL RETURNING`);
重放、跨账号使用、换人代接受邀请一律拒绝
- ★ 找回口令对**存在**与**不存在**的邮箱返回**完全相同的键**(防账号枚举)
- ★ 任何响应体都不得出现令牌本体(`pm_rst_` / `pm_vfy_` / `pm_inv_` 前缀一律不出现)
- ★ 配额用尽返回 **402** 而非 403,且 body 带 `used / quota / plan`
- ★ 邮箱未验证只能绑 **1 台**设备;验证后按档位放开(验证本身零成本)
- ★ 注销有 **30 天冷静期**:期内登录即撤销注销,期满才由清理逻辑真正删除
- ★ 管理端口令**短于 32 字符视为未设置**(404,不是"勉强能用");
**绝不接受 `?token=`**(它进 nginx 日志、浏览器历史、Referer)
- ★ 管理端任何响应都不得出现 `secret_hash` / `secret_salt` / 完整 `token_hash`
(会话与设备令牌只给哈希前 12 位)
- ★ 管理端写动作**全部落审计**(`admin_*`),**CSV 导出也记审计**(带走的是全站邮箱)
- ★ CSV 里以 `=` `+` `-` `@` 开头的用户字段必须加前导单引号 —— 否则 Excel 打开就执行
- ★ 管理台的静态资源目录**必须单独 COPY**(`*.js` 通配不跨目录,漏了只有 `/admin/` 404)
---
@@ -303,7 +420,7 @@ disk_free_gb=3251.79 net_rx_mbps=4.55 net_tx_mbps=1.71 uptime_s=106001
网关与业务服务是**两个进程**,各自持有进程内授权缓存。
业务服务 `grantAccess` 只能清自己的缓存,网关在 TTL 内仍返回旧结果 —— 用户"刚授权却看不到"。
**修法**:授权变更后经 Redis pub/sub 广播失效(`pmon:auth:invalidate`),
**修法**:授权变更后经 Redis pub/sub 广播失效(`inkwatch:auth:invalidate`),
网关订阅该频道并清本地缓存。TTL 从 5s 降到 2s 仅作兜底。
`e2e-smoke.js` 步骤 [3] 专门验证「授权后**立即**订阅即成功,未等 TTL」。
@@ -348,6 +465,11 @@ disk_free_gb=3251.79 net_rx_mbps=4.55 net_tx_mbps=1.71 uptime_s=106001
`nginx.conf` 的 upstream 也从 `127.0.0.1:3000` 改为 `gateway:3000`。
(host 网络确实能省一层 NAT,但与 compose 服务发现互斥,单机也要二选一。)
> ⚠️ **本节的 nginx 容器已于 2026-11-08 移除**(接入层改由宿主承担,见
> `deploy/nginx.host.conf.example`)。下面两条坑仍值得读 —— 它们的教训是
> "配置硬引用可选文件会让 clone 下来必崩"与"ports 是拼接而非替换",
> 这两条在宿主反代上一样成立,只是换了文件。
### 坑 B:nginx 强依赖证书导致「clone 下来必崩」
`certs/` 被 `.gitignore` 排除(私钥不能入库),但 nginx.conf 硬引用
@@ -439,8 +561,8 @@ N 毫秒后重连 / 补传这些帧)。
4. 新增 `tests/schema-consistency-test.js`(48 项):解析 Rust 源码、SQL 原文与 JS
清单做**集合相等**校验;再用一个"17 字段全有值"的样本帧过一遍白名单,
断言一个字段都不丢 —— 把这类缺陷从"线上静默"变成"提交时红灯"。
5. 新增 `tests/ffi-smoke.py`(79 项):真实加载编译产物,调 `pmon_metric_keys_json()`
与 `pmon_protocol_constants_json()` 与 JS 侧事实比对,锁住跨语言契约。
5. 新增 `tests/ffi-smoke.py`(79 项):真实加载编译产物,调 `inkwatch_metric_keys_json()`
与 `inkwatch_protocol_constants_json()` 与 JS 侧事实比对,锁住跨语言契约。
6. 新增 `tests/rust-frame-pipeline-test.js`(24 项):**用 Rust 核心真产出的那一帧**
跑完整链路并查库逐项比对 —— 手写样本永远测不出这类漂移,这是唯一能兜住的防线。
@@ -551,7 +673,7 @@ socket.on('agent:metrics', (raw) => this.safe(socket, () => this.onMetrics(socke
第③种是最隐蔽的:它既不是"连接失败"也不是"连接断开",写惯了
`try { redis.get() } catch { 503 }` 的人会以为已经覆盖了。
实测(`docker pause pmon-redis-1`):
实测(`docker pause inkwatch-redis-1`):
```
Redis 卡住 → HTTP 503 用时 1.5156s ← 有界失败关闭,不是挂到客户端超时
@@ -730,6 +852,313 @@ Node 默认上限 32MB 而该参数需 33.5MB,不给就直接抛 `ERR_CRYPTO_I
---
## 用户机制:角色 / 邀请 / 自救 / 配额(第 25 轮落地)
上一轮的账号体系只做到"你是谁"(注册登录、设备归属)。这一轮补的是**"你能做什么"**
与**"账号出事怎么办"**:在此之前,一个账号只有"能看"和"不能看"两态,忘了口令等于
账号报废,设备权限只能发配对码(拍照发过去 = 公开招租),配额只用来**报数**不生效。
### 能力矩阵只有一个真相源
`shared/permissions.js` 定义 13 个能力 × 3 个角色(viewer / manager / owner),
Dart 侧 `client/lib/host/role_catalog.dart` 是它的镜像 —— 两边**双向相等由测试锁定**
(`schema-consistency-test.js` 第 14 段逐条比对能力名、最低角色、ownerOnly 集合)。
这一条不是洁癖:界面按自己的表隐藏按钮、服务端按自己的表判权,两表一旦漂移,
用户看到的就是"按钮点了没反应"或"根本没按钮但接口允许"。
### 授权 ≠ 夺权(`ownerOnly`)
把某人提为 `owner` 角色,意味着他能管成员、轮换令牌、看审计 ——
但**不能转移归属、不能删除设备**。这两件事只认 `devices.owner_id` 本人,
因为"被授予的权力"不该包含"把授予者踢出去"。实现上不是特判,而是能力表里的
`ownerOnly: true` 标记,由 `authorize()` 统一裁决;测试里对 3 个角色 × 2 个能力
逐一断言(含"被授予 owner 的成员仍被拒"这条反直觉组合)。
### 账号自救四件事
| 场景 | 做法 | 关键取舍 |
|---|---|---|
| 怀疑令牌泄露 | 列出全部登录记录,按 12 位前缀踢掉某一条 / 一键踢其它 | 列表只给哈希前缀,**绝不回显可用凭据** |
| 想改口令 | 验原口令 → 改 → **吊销其它全部会话** | 改密即清场:旧令牌不该继续有效 |
| 忘了口令 | 邮件一次性令牌,15 分钟、用一次即焚 | 见下"防枚举" |
| 不要这个号了 | 注销 → 30 天冷静期内登录即撤销 | 期内设备变无主但数据保留,界面如实说明 |
一次性凭据(验证邮箱 / 重置口令 / 邀请)统管在 `auth_tokens` 一张表里,
**只存 sha256 哈希**,消费用 `UPDATE ... WHERE used_at IS NULL RETURNING` 保证原子
(并发两次只会有一个成功)。重放、跨账号使用、换人代接受 —— 全部明确拒绝。
### 防账号枚举
"这个邮箱注册过吗"不该被一个陌生人问出来。故 `reset/request` 对**存在**与
**不存在**的邮箱返回**完全相同的键与文案**(测试逐键比对,不是比状态码),
只有 has-more 信息的那一侧才真的发信。
### 令牌绝不进 HTTP 响应
验证/重置/邀请的令牌只走邮件通道。响应体里连前缀都不许出现 ——
测试直接断言响应 JSON 里匹配不到 `pm_rst_` / `pm_vfy_` / `pm_inv_`。
这不是洁癖:一旦回显,任何能看到响应的地方(代理日志、前端 console、崩溃上报)
都成了凭据泄露点。
发信通道三模式,**由实例配置 `mail_mode` 决定**(管理端「实例设置」可改,`MAIL_MODE`
可覆盖;旧名 `MAIL_DELIVERY` 仍兼容):
| 模式 | 行为 | 适用 |
|---|---|---|
| `console`(**库里的默认值**) | 只打日志,不发信 | 安全默认:可疑的默认值应当"少做" |
| `outbox` | 落 JSONL(`MAIL_OUTBOX_PATH`),**令牌明文落盘** | 仅本机联调,界面会标红 |
| `smtp` | **真的发信**:零依赖客户端(`server/shared/smtp.js`),支持 465 隐式 TLS / 587 STARTTLS / AUTH LOGIN·PLAIN | 生产 |
为什么默认是 `console` 而不是 `outbox`:outbox 会把重置/验证令牌以明文写进服务器磁盘,
"开箱即用的便利"不该以这个为代价。改了立刻生效,不需要重启。
邮件正文末尾会带一行落款(`—— 实例名(对外地址)`)—— 这是 `instance_name` 与
`public_url` 目前唯一的消费点,也解决"收件人分不清是哪个自建实例发的"。
#### 用 QQ / 163 邮箱发信(自建实例最常见的做法)
这两家的协议行为和通用中继不一样,`smtp.js` 里有一张 `PROVIDERS` 表专门处理:
**465 是隐式 TLS**(不是 587)、**发件人必须等于登录账号**(否则 553)、
**口令是「授权码」而不是登录密码**(否则 535)。
```bash
# QQ 邮箱:设置 → 账号 → 开启 POP3/IMAP/SMTP 服务 → 生成授权码
MAIL_MODE=smtp
SMTP_HOST=smtp.qq.com # 163 用 smtp.163.com(客户端授权密码)
SMTP_USER=你的QQ号@qq.com
SMTP_PASS=<16 位授权码> # 不是登录密码
MAIL_FROM=你的QQ号@qq.com # 必须与 SMTP_USER 一致;留空则自动取账号
# SMTP_PORT / SMTP_SECURE 可以都不设:识别到 qq/163 就按 465 + 隐式 TLS 走
```
三处容易踩的坑,都已经在代码里拦在"连上去之前",并给出人话文案:
| 坑 | 表现 | 现在的处理 |
|---|---|---|
| `MAIL_FROM` 填了 `noreply@example.com` | 服务端回 `553 Mail from must equal authorized user`,而前面每步都"成功" | `smtp_from_mismatch`,文案直接说该改成哪个值 |
| 用了登录密码 | `535`,但 535 不会告诉你该用授权码 | 认证失败时追加"需要 SMTP 授权码"的提示(企业邮不追加,它用真密码) |
| 465 端口却写 `SMTP_SECURE=0` | 连上后一直等不到问候,15 秒后超时 | `smtp_bad_config`,立即报"465 是隐式 TLS,去掉 SMTP_SECURE 或改 587" |
已验证的部分:对 `smtp.qq.com:465` 与 `smtp.163.com:465` 做过**只读探测**(TLS 握手 +
`EHLO` 能力集解析),两家都公布 `AUTH LOGIN`,证书走系统 CA 校验通过。
**真实投递**需要你自己的授权码,用 `node tests/smtp-live-test.js` 跑一封(发给账号自己)。
腾讯云等云主机**默认封禁出网 25 端口**,所以别想着用 25 明文发信:统一 465。
### 配额:402 而不是 403
额度用尽不是"没权限",是"该付钱/该删设备",故用手册里少见的 **402 Payment Required**,
body 带 `used / quota / plan` 三个字段让客户端能直接渲染进度。免费档在邮箱未验证时
收紧到 1 台 —— 验证本身是零成本的,这比"注册就送 3 台"更能挡住批量刷号。
免费档的上限**可由实例配置覆盖**(`free_device_limit`,向导与管理端都能改,`FREE_DEVICE_LIMIT`
可覆盖);`pro` / `team` 的数字是产品定义,不接受运维改 —— 覆盖规则只写在
`shared/permissions.js` 的 `planFor()` 一处。在此之前这个字段只落库没人读:
界面写"免费 3 台",改什么都不生效。
**2026-10-30 起 `ENFORCE_DEVICE_QUOTA` 默认开启**(`=0` 可关)。这是本轮唯一的
破坏性变更:之前它关着,于是 `PLAN_TABLE` 那行数字只被用来报数,免费号能挂无限设备 ——
那不是"宽松",是**机制不存在**。只有"新增"(注册/认领)会被拦,已有设备的查看不受影响。
**这个默认值一改,三套测试当场变红**(`account-test` / `device-merge-test` / `auth-mode-test`),
失败信息清一色是 `E_QUOTA_EXCEEDED` —— 看起来像配额功能坏了,其实是**测试的车没上**:
它们造数的方式是"注册 → 认领 2~8 台设备",而未验证邮箱只能绑 1 台这条规则**比档位上限更早生效**。
按"谁能表达意图"分工处置,不是把默认值改回去:
| 套件 | 处置 | 为什么 |
|---|---|---|
| 账号体系 / 设备归并 | 造数前 `markEmailVerified()`(归并另抬到 team 档) | 它们测的是归属与归并,配额只会把断言挡在更前面 —— 本该 409 的"认领别人的设备"先返回 402 |
| 安全开关 `[D]` 段 | 对照组从宿主机 `:4000` 换成**自己拉**的 `:4012`(`ENFORCE_DEVICE_QUOTA=0`) | 原写法隐含"`:4000` 一定没开配额",等于把别人的部署方式写进测试前提 |
教训是一条通用的:**改一个默认值 = 改所有测试的前提**。凡是测试里出现"另一套服务应该是什么样",
都要先问它归谁管 —— 不归测试管的,就得自己拉一个。
### 端到端证据(`user-lifecycle-test.js`,66 项)
九段全绿,其中一半是"No"型断言(必须被拒):viewer 邀请 → 403、manager 踢人 → 403、
被授予 owner 者转移归属 → 403、邀请令牌重放 → 401、代别人接受邀请 → 403、
注销后原会话 → 401、第 4 台设备 → 402。反面对照同样有:合法路径必须**能过**,
否则"全拒"也能让测试全绿。
**这套测试自带隔离实例**(随机空闲端口 + 自己的 outbox 路径 + 放宽的限流额度,跑完即停)。
原因是踩了三个坑,根因都是"被测服务的环境不归测试管":
① 写死 `:4010` 撞上上次遗留的旧进程 → 测的是改动前的代码,36 项假红;
② outbox 路径测试用 `os.tmpdir()`、服务端用 `/tmp/...` → 永不相等,整段假红;
③ 注册限流(每小时 20 次/IP)被反复重跑耗光 → 429 假红。
三者都不是产品缺陷,却各要花一轮排查。让测试掌握环境,这类"环境冒充缺陷"一次消掉。
### 还没做
- **投递结果回执**:现在只知道"中继收下了"(250),不知道对方邮箱最终是否投递成功
(退信是异步的,要收 DSN 或查服务商 API)。所以界面上只能说"已发送",不能说"已送达"。
- 邀请的**接受界面**:REST 已有(`POST /api/invites/accept`),客户端只有粘贴令牌的入口。
- 注销 30 天后的**定时清理**:`purgeExpiredDeletedAccounts()` 已实现但没有 cron 调它,
现在靠"期满后首次登录"顺手清理。
- 第三方登录、配额自助升档、审计日志的导出。
---
## 云端管理端:运营者视角的界面(第 26 轮落地)
在此之前,这个系统**没有任何运营端**。38 个业务端点全部是用户视角的 ——
每一个都带 `WHERE owner_id = 我` / `WHERE grantee_id = 我`。要看"全站有多少账号"
靠 `psql`,要改某人档位靠手写 `UPDATE`,要看谁在线靠 `docker logs`。
这不是"少个页面",而是**运营动作没有任何留痕与边界**。
### 一个必须先说清的前提:这里没有"用户上传的文件"
`uploadToken` / `downloadToken` 是**中继传输的一次性凭证**(设备 A 经 `/relay/`
把帧数据传给设备 B,服务端不落盘),不是文件托管。库里 9 张表没有任何一张跟文件有关。
所以管理端没有"文件管理"这一块 —— 真正要管的是**账号、设备与授权、配额、审计与
指标数据**,其中 `device_metrics` 是唯一会无限长大的表。
### 身份模型:一个部署一个口令,而不是 `users.is_admin`
没有新增管理员角色列,理由有三条,每条都是"另一种做法会坏在哪":
| 做法 | 坏在哪 |
|---|---|
| `users.is_admin` 列 | 谁来设第一个管理员?新库上要么手改 SQL,要么留一个"首次访问即成为管理员"的接口 —— 后者是经典的接管漏洞 |
| 管理员就是一个普通账号 | 该账号丢了 = 管理端丢了,而且只能靠改库救回 |
| 独立管理员表 | 自托管场景下运营者就一个人,建角色体系属于过度设计 |
故用部署时注入的 `ADMIN_TOKEN`(`openssl rand -base64 48`),泄露即换变量重启,
零迁移。`ADMIN_NAME` 只作为审计里的操作者标签存在,**不参与任何鉴权**。
### 四条失败关闭(每一条都有对应断言)
| 形态 | 响应 | 为什么不能是别的 |
|---|---|---|
| 没设 `ADMIN_TOKEN` | **404** | 401 等于对全世界宣布"这里有个管理台,只是你没带口令" |
| 设了但短于 32 字符 | **404** | 与其给人"已经设过了"的错觉,不如当它不存在。`ADMIN_TOKEN=123456` 的管理端比没有更危险 |
| 口令错 | 401,不回显任何片段 | 回显前缀等于把猜测空间从 2^256 缩到 2^48 |
| 口令放 `?token=` | 401 | query 会进 nginx access log、浏览器历史、Referer |
| 来源 IP 不在 `ADMIN_ALLOW_IPS` | 404 | 连"口令错了"都不说 |
口令比较走**定长摘要 + `timingSafeEqual`**(先各自 sha256 成 32 字节再比),
否则按响应时间能逐字节试出来。失败次数有上限(默认 10 次/分钟/IP,超出 429),
且**锁定期间正确口令同样被挡** —— 安全优先于便利,管理端本就是单人内网工具。
### 五个页面
| 页面 | 内容 |
|---|---|
| 总览 | 账号/设备/在线数、近 14 天新增趋势、各表存储占用 |
| 账号 | 搜索(邮箱/显示名)、按档位与状态筛选、排序、分页;详情抽屉含自有设备 / 被授权设备 / 登录记录 / 订阅 / 近期审计 |
| 设备 | 搜索、在线态、授权数与令牌数;详情含授权成员、采集端令牌(**只给哈希前 12 位**)、指标行数与时间范围 |
| 审计 | 按事件(支持前缀,如 `admin_` 一次看全部管理动作)/账号/设备筛选、分页 |
| 指标数据 | 任意设备 × 任意区间 × 任意指标列,指定降采样粒度,画折线 + 看原始桶 |
写动作只有五个:改档位、封禁/解封、人工标记邮箱已验证、踢全部会话、解除设备归属。
**每一个都落审计**(`admin_*`),审计写不进就报错 —— 管理端动了别人的账号,
不能"做了但没留痕"。**CSV 导出同样记审计**:它带走的是全站邮箱。
### 三个在这一轮挖出来的真缺陷
**① `GET /api/audit` 匿名可读全站审计(已删除)**
它没有挂任何鉴权中间件,实测匿名请求即 `HTTP 200`,直接返回
`account_id` / `device_id` / `detail`。一个运营者接口被放在了用户接口的路由表里,
而且全仓库 grep 只有它自己在用。已改为 `/api/admin/audit`(带口令 + 分页筛选),
原路径现在 404 —— **不许为了"兼容"加回来**,审计里能看出谁在什么时候连了哪台设备。
**② 存储统计报错 99.9%(已修)**
`storage()` 原用 `pg_total_relation_size('device_metrics')`,在该库上报出
**32 KB**,而实际数据是 **2802 MB** —— 因为 TimescaleDB 把数据放在 chunk 里,
父表本身几乎是空的。这个页面存在的唯一意义就是回答"空间去哪了",
报错方向比报错数字更糟。现先探 `to_regclass('timescaledb_information.chunks')`,
有扩展就把所有 chunk 累加(两套 SQL 分开写:那张表在普通 PG 上**解析阶段**就报错,
用 `CASE` 包起来没用)。超表的 `reltuples` 也不可信(2802 MB 报 0 行),
故补一次精确 `count(*)` —— 但它是唯一会无限长大的表,加 3 秒上限,
超时退回"未统计",宁可显示得少也不拖住页面。
**③ `COPY server/business/*.js` 不含子目录(已修 + 加护栏)**
管理台静态页在 `server/business/admin/` 子目录里,globs 的 `*` **不跨目录**。
漏了这行的表现是一组"看起来都正常"的组合:镜像构建成功、容器健康、
`/api/admin/*` 全通,**只有 `/admin/` 一片 404**。而原有的
Dockerfile 覆盖性测试**抓不到它** —— 它按 require 图检查,静态文件不被任何模块
require,在那张图里是零信号。现该测试新增一段「静态资源目录必须被 COPY」,
并修正了 `expandGlob` 对目录的处理(原先把目录名本身放进集合,
新加的那段会永远为真,变成假绿)。变异自证:删掉那行 COPY → 7 个文件全部点名报出。
### 渲染期才看得见的两处
前三个是接口层的,测试能抓;下面两个只有**真在浏览器里打开**才会露出来 ——
语法检查、`node --check`、接口断言全部通过,页面上照样是错的。
**④ 总览「当前在线」用采样算法(已修)**
原实现取 `SELECT device_id FROM devices LIMIT 500` 再逐台 `EXISTS`。这个库里有
**1.3 万台**设备(大量是压测留下的),`LIMIT 500` 拿到的是物理顺序最前的一批,
真正连着的那台**很可能不在里面** —— 页面于是显示「当前在线 0」。危险的不是数字错,
而是它会被读成**确定结论**:运营者会去排查"为什么全掉线了"。旁边那行小字
「采样 500 台」在视觉上等于不存在。
现改为 `SCAN` 数 `presence:*` 键(网关连上 `SET`、断开 `DEL`),与设备表规模无关,
也不必逐台往返;用 `SCAN` 而非 `KEYS` 是因为网关转发链路依赖同一个 Redis 实例,
管理台刷一下总览不该让所有人掉帧。Redis 不可用时返回 `null` 并显示「未知」,
**绝不回落成 0**。
**⑤ 审计「明细」列被卡片从右边硬裁(已修)**
明细是 `JSON.stringify(detail)`,一串没有空格的 JSON。`white-space: normal`
只在空格/连字符处断行,找不到断点 → 整串作为不可断词把表格撑宽 → 再被
`.card { overflow: hidden }` 从右边缘切掉,**既没有省略号、也看不出被切了**
(截图里 `{"ip":"::ffff:172.19.0.1","reason":"not_found","attempts_left":4,"b`
就断在卡片边缘)。修法是包一层带 `max-width` 的 `div` 做省略号 + `title` 悬停看全文:
`table-layout: auto` 下给 `td` 设 `max-width` 并不可靠,包 div 才稳。
> 方法上的一条:**四页分别截图,不能"总览正常"就推断其余三页正常** ——
> 它们是四条独立的渲染路径(`pageAccounts` / `pageDevices` / `pageAudit` /
> `pageMetrics`),出错也只会在自己那页出错。
> 另:为验证登录后的页面,曾临时加过一个同源跳板页(写 `sessionStorage` 再跳转,
> 因为无头浏览器每次都是新进程)。**这一类页面用完必须删干净** ——
> 它把口令硬编码在页面里,留在镜像里就是一个不需要口令的登录跳板。
### 部署
静态页由**业务服务** `express.static` 提供(不是 nginx 直接托管),
这样单容器与多容器两套部署的路径完全一致,不必把 admin 目录分别 COPY 进两种
nginx 镜像。nginx 只需一条路由:
```nginx
location ^~ /admin/ { proxy_pass http://inkwatch_business; ... }
```
⚠️ 单容器那套(`nginx-all.conf`)**必须显式写这条**:它的 `location /` 会把一切
兜进网关,漏了的表现是"管理台 404,`/api/admin/*` 也 404",排查时极易误判成
"管理端没挂载"。
⚠️ 改完 `nginx.conf` 记得 `docker exec inkwatch-nginx-1 nginx -s reload`。
文件是挂载进去的,但**运行中的 nginx 不会自己读** —— 实测时 `up -d business`
只重启了业务服务,`/admin/` 一直 404,reload 后立刻变 200。这一条在容器日志里
没有任何提示,只能靠"改了配置怎么没生效"这个念头想到。
**如何不把它暴露到公网**(这一条是运营者的责任,代码只管路由):
1. 安全组只放行办公网 / 家庭固定 IP,或干脆只允许 WireGuard 内网访问;
2. 或者设 `ADMIN_ALLOW_IPS=`(完整 IP,不支持网段),非白名单一律 404;
3. 两样都不做时,管理台的口令就是**唯一一道门** —— 那就把 `ADMIN_TOKEN` 当生产密钥对待。
### 端到端证据(`admin-api-test.js`,80 项)
自带 9 个隔离实例(每个"口令形态"独占一个进程,口令是启动期环境变量,
共用一个进程测不出来)。一半是"No"型断言:没设口令 404、短口令 404、
错口令 401、口令走 query 401、IP 不在白名单 404、非法档位 400、
非法指标列 400、区间反了 400、未知导出类型 404、连错 5 次后 429。
另一类是"不许漏出来":任何响应都不得出现 `secret_hash` / `secret_salt` /
完整 `token_hash`(会话只给 12 位前缀);CSV 首三字节必须是 `EF BB BF`;
用户可控字段以 `=` 开头时必须加前导单引号 —— 否则 Excel / Numbers 打开导出文件
会**执行**这个公式。
> 这一条首跑时是红的,原因是我用 `fetch().text()` 读响应体 —— 而 WHATWG 规范
> 规定 UTF-8 解码**默认剥掉 BOM**,于是"文件里有没有 BOM"用它根本测不出来。
> 改用 `arrayBuffer()` 比前三字节才通过。又一次「量不出数据先怀疑量法本身」。
---
## 显示层:单位自适应与历史回溯(第 23 轮落地,含两处真机才暴露的缺陷)
用户真机反馈三条:网速单位切换"效果还行,但内存和磁盘没做到自适应"、"采集速度看起来太快、
@@ -903,8 +1332,8 @@ socket I/O、平台差异落地、界面。
| 组成 | 说明 |
|---|---|
| `bin/pmon_agent.dart` | 无界面常驻采集进程(方案 8.3「采集核心作为独立进程」),可 `dart compile exe` 成小可执行文件 |
| `bin/pmon_viewer.dart` | ★ 无界面**查看端** CLI:连真实网关、走真实配对、订阅并打印快照/实时帧/断点/能力集,退出码反映结果 |
| `bin/inkwatch_agent.dart` | 无界面常驻采集进程(方案 8.3「采集核心作为独立进程」),可编成小可执行文件(构建命令见 `client/scripts/build_cli.sh`) |
| `bin/inkwatch_viewer.dart` | ★ 无界面**查看端** CLI:连真实网关、走真实配对、订阅并打印快照/实时帧/断点/能力集,退出码反映结果 |
| `lib/host/link.dart` | ★ 宿主 socket 适配层:真 socket 驱动 Rust 状态机(喂事件、执行动作) |
| `lib/host/viewer.dart` | ★ 查看端宿主层:独立 socket + 配对 REST + 60 帧窗口 + 断点判定 + 能力集 + 离线明示(**纯 Dart**) |
| `lib/host/metric_display.dart` | 指标显示目录(标签/单位/分组/是否可画/不可得原因,**纯 Dart**) |
@@ -916,7 +1345,7 @@ socket I/O、平台差异落地、界面。
| `lib/ui/account_card.dart` | 账号卡(登录/注册/登出;未登录时明说只能看他人授权的设备) |
| `lib/host/link.dart` | 采集端 `agent:hello` 携带设备令牌(认领后自动注入并重连) |
| `lib/main.dart` | 双角色外壳:底部导航「采集 / 查看」;查看页懒创建(切页签不中断采集) |
| `lib/host/ffi_bindings.dart` | C ABI 绑定(37 个;另有 `pmon_next_backoff_ms` / `pmon_backoff_reset` 两个仅供 Rust 自测使用,客户端未绑定) |
| `lib/host/ffi_bindings.dart` | C ABI 绑定(37 个;另有 `inkwatch_next_backoff_ms` / `inkwatch_backoff_reset` 两个仅供 Rust 自测使用,客户端未绑定) |
| `test/viewer_test.dart` | 查看端逻辑 48 项(断点 / 能力集 / 错误码文案 / 快照 / 实时流 / 配对,**全部离线,注入假 socket**) |
| `test/viewer_page_test.dart` | 查看端**界面渲染** 17 项(空状态 / 设备切换器 / 卡片取舍 / 断点提示 / 离线横幅 / 配对错误,真实构建 widget 树) |
| `test/viewer_fakes.dart` | 两套查看端测试共用的假件(假 socket / 假策略 / 假 REST) |
@@ -955,7 +1384,7 @@ engine.io 自身的 ping/pong 承担。该决策已由端到端测试钉住
## 已知边界
- **Android 采集端已落地并实测联调**:`aarch64-linux-android` 交叉编译、Kotlin 前台服务
与常驻通知(`PmonCollectorService.kt`)、平台通道(`PmonChannel.kt`,TrafficStats /
与常驻通知(`InkwatchCollectorService.kt`)、平台通道(`InkwatchChannel.kt`,TrafficStats /
ANDROID_ID / filesDir)、`BOOT_COMPLETED` 接收器(`BootReceiver.kt`)均已实现,
模拟器与真机均能连上网关上报(服务端可见 `android-*` 设备在线)。
仍待补:**正式签名与 AAB 产物**、各厂商 ROM 的自启引导文案核对、Android 侧查看端 UI 验收。
+199 -55
View File
@@ -10,14 +10,14 @@ macOS + Android 双端,Flutter 单代码库,**一个应用两个角色**(
```
client/
├── bin/
│ ├── pmon_agent.dart # 无界面常驻采集进程(方案 8.3「独立进程」)
│ ├── pmon_viewer.dart # ★ 无界面查看端 CLI(真实链路验证 + 可脚本化)
│ └── pmon_install.dart # 安装器:注册/卸载/自检/重启自启(非沙箱 CLI)
│ ├── 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 符号绑定
│ │ ├── pmon_core.dart # 句柄封装(采集器/传输/守护)
│ │ ├── inkwatch_core.dart # 句柄封装(采集器/传输/守护)
│ │ ├── app_config.dart # 配置持久化(零依赖,写容器内 JSON)
│ │ ├── install_paths.dart # 安装路径与 `~` 展开(含一个真实坑的回归注释)
│ │ ├── timing.dart # 时序常量(断点阈值/窗口长度,宿主与界面共用)
@@ -80,20 +80,24 @@ dart analyze
# 全部客户端测试(117 项)—— 必须走这个入口,它负责清代理变量
bash scripts/test.sh
# FFI 冒烟测试(真实加载 libpmon_core,共 28 项)
# 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/pmon_agent.dart --server http://127.0.0.1:3000 --device-id my-mac
dart run bin/pmon_agent.dart --duration 15 # 跑 15 秒后退出
dart run bin/pmon_agent.dart --print-plan # 只打印自启注册计划
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 调用)
dart compile exe bin/pmon_agent.dart -o build/pmon-agent
# ⚠️ 构建命令只有一处:scripts/build_cli.sh(Dart 3.13 起 `dart compile exe`
# 遇到带 build hooks 的包会直接失败,见脚本文件头)
bash scripts/build_cli.sh
```
### ⚠️ 联调必须清代理变量
@@ -106,7 +110,7 @@ dart compile exe bin/pmon_agent.dart -o build/pmon-agent
```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/pmon_agent.dart --duration 15
dart run bin/inkwatch_agent.dart --duration 15
```
## 查看端(订阅别人的设备)
@@ -257,7 +261,7 @@ subscribe,旧设备的帧会一直推过来(移动端流量翻倍),`subs
### ★ 覆盖安装残留:配置必须过校验(`host/config_migration.dart`)
<user>定位到:**覆盖安装**(在原应用上更新、保留旧数据)后滑动卡顿,
产品负责人定位到:**覆盖安装**(在原应用上更新、保留旧数据)后滑动卡顿,
**清除全部应用数据后卡顿消失**,全新机全新装也不卡。
覆盖安装保留的正是私有目录里的两个文件:`config.json` 与 `viewer_cache.json`。
@@ -290,7 +294,7 @@ subscribe,旧设备的帧会一直推过来(移动端流量翻倍),`subs
| 规则 | 理由 |
|---|---|
| `config.json` 加 `config_version`;`load()` 是配置**唯一入口**,规范化与迁移都在这里 | 没有版本号就无法区分"字段本来没有"与"旧版本用别的语义写的" |
| 采样间隔**夹到方案规定的 0.5–5 秒**(`docs/inkwatch-v4-architecture.md` 6.1),越界回落 1000ms | 唯一能压满 UI 线程的字段,必须拦 |
| 采样间隔**夹到方案规定的 0.5–5 秒**,越界回落 1000ms | 唯一能压满 UI 线程的字段,必须拦 |
| 地址只在**明显非法**(解析不出/无 host/非 http(s))时回落编译期默认值 | `io.io('')` 是真连不出去的坏值;回落是无损的 |
| `device_id` 只拦**结构上不可能**的值(含空格/中文/超长) | 它会污染服务端主键;合法值一律不动 |
| **凭据(account_id / 令牌 / 邮箱)一律不校验** | 见下 |
@@ -352,7 +356,7 @@ android-newmodel-y-abc123(09:14 起)
### ★ 同一台机不得出现两条:设备身份归并(服务端)
光"不再漂移"不够 —— 已经漂移出来的记录还在。<user>截图里的现象是
光"不再漂移"不够 —— 已经漂移出来的记录还在。产品负责人截图里的现象是
**同一台小米占了两条**(新 id 在线、旧 id 永远离线、曲线断在切换处),
"可查看 5 台"里有一台是重复的。
@@ -375,7 +379,7 @@ android-newmodel-y-abc123(09:14 起)
有 `MAX_MERGE_FRAMES` 上限,超限**拒绝并报错**,不静默截断
(截断会造成"一半历史在新、一半在旧",比不合并更难查)。
**生产上的实际效果**(<user>那台,2026-09-15 实测):
**生产上的实际效果**(产品负责人那台,2026-09-15 实测):
```
归并前:5 台 含 android-oldmodel-x-abc123(272 帧)与 android-newmodel-y-abc123(103 帧)
@@ -420,8 +424,8 @@ android-newmodel-y-abc123(09:14 起)
```bash
cd client
dart run bin/pmon_viewer.dart --account acct_demo --list # 列我被授权的设备
dart run bin/pmon_viewer.dart --account acct_demo --device <id> \
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
```
@@ -438,17 +442,17 @@ dart run bin/pmon_viewer.dart --account acct_demo --device <id> \
| 令牌 | 内容 |
|---|---|
| `PmonColors` | 中性四色(page/surface/line/surfaceMuted)+ 文字三级 + 主色 + **状态语义色** |
| `PmonSpace` / `PmonRadius` | 间距 4/8/12/16/20/24;圆角 8/12/16/pill(**刻度有限**,免得同一决策有三种写法) |
| `PmonText` | title / section / body / bodyMuted / caption / **metric(等宽数字)** |
| `PmonCard` | standard(白面 + 发丝线,**不用阴影**)/ notice / selected |
| `buildPmonTheme()` | 唯一的 `ThemeData` 装配点(含 AppBar / 输入框 / 菜单 / SnackBar / NavigationBar) |
| `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. **语义优先于色值**:写 `PmonColors.online` 而不是 `Colors.green`;
2. **语义优先于色值**:写 `InkwatchColors.online` 而不是 `Colors.green`;
3. **颜色只用于表意**:整屏以中性灰为底,彩色只出现在状态、主操作、告警上。
### 改版顺手修掉的三个真缺陷 + 两处"界面在说谎"
@@ -480,7 +484,7 @@ dart run bin/pmon_viewer.dart --account acct_demo --device <id> \
取不到就退回主机名,**绝不编一个假机型** —— 与服务端"读不到报 null、不报 0"是同一条诚实边界。
### 设备机型的三级降级(安卓 `PmonChannel.kt` 的 `deviceModelName()`)
### 设备机型的三级降级(安卓 `InkwatchChannel.kt` 的 `deviceModelName()`)
`Build.MODEL` 在国内 ROM 上常常是**型号编号**(小米系 `MODEL-A1`、`MODEL-B2`),
用户看到编号根本认不出是哪台;而 ZTE F50 这类 `MODEL` 本身就是名字。
@@ -523,9 +527,9 @@ macOS 侧另有一路(`system_profiler` → `sysctl hw.model` → 主机名)
|---|---|
| `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=PMON_LOG_JANK=true`) |
| ✅ 应用内 `SchedulerBinding.addTimingsCallback` | release 同样有效 → `lib/platform/frame_jank_log.dart`(`--dart-define=INKWATCH_LOG_JANK=true`) |
### 信息密度:三条原则(<user>反馈"过于密集"后立的规矩)
### 信息密度:三条原则(产品负责人反馈"过于密集"后立的规矩)
| 原则 | 落地 |
|---|---|
@@ -556,7 +560,7 @@ macOS 侧另有一路(`system_profiler` → `sysctl hw.model` → 主机名)
### 加载态:骨架屏 + 三条"不闪不跳"的规矩
<user>反馈「刚进入软件,显示的采集界面是灰色的,应该是在加载数据中,但是给人的体验感很差」。
产品负责人反馈「刚进入软件,显示的采集界面是灰色的,应该是在加载数据中,但是给人的体验感很差」。
旧版加载态是**一排灰色 `—`**,两个毛病:没说什么在等(用户读成"是不是坏了/没权限"),
而且形状与真实内容不一致(数据到位时整页重排,视觉上又是一次"跳")。
@@ -646,7 +650,7 @@ SocketException: Connection failed (OS Error: Operation not permitted, errno = 1
验证签名里确实带上了:
```bash
codesign -d --entitlements - build/macos/Build/Products/Release/pmon_client.app \
codesign -d --entitlements - build/macos/Build/Products/Release/inkwatch_client.app \
| grep -oE 'network\.[a-z]+|app-sandbox' | sort -u
# 期望输出: app-sandbox network.client
```
@@ -654,13 +658,93 @@ codesign -d --entitlements - build/macos/Build/Products/Release/pmon_client.app
### 沙箱下的文件写入
`app-sandbox` 下进程的 `HOME` 指向应用容器
(`~/Library/Containers/com.pmon.pmonClient/Data`),故配置读写用
`$HOME/Library/Application Support/pmon_client/config.json` 即天然落在容器内,**无需额外 entitlement**。
(`~/Library/Containers/com.inkwatch.eink/Data`),故配置读写用
`$HOME/Library/Application Support/inkwatch_client/config.json` 即天然落在容器内,**无需额外 entitlement**。
> ⚠️ 但**写 `~/Library/LaunchAgents/` 不在容器内**,沙箱应用无法直接落盘自启 plist。
> 自启注册应由**非沙箱的 CLI 采集端**(`bin/pmon_agent.dart` 编译出的可执行文件)
> 自启注册应由**非沙箱的 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 秒内重新拉起、用户主动退出不重启、崩溃循环有退避」。
@@ -673,28 +757,29 @@ codesign -d --entitlements - build/macos/Build/Products/Release/pmon_client.app
| 登录/重启后自动恢复 | `RunAtLoad = true` |
| 崩溃循环有界 | launchd 自身节流 + 退避策略在 `rust-core` 的 `GuardianScheduler` |
plist 的**内容**由 Rust 核心 `pmon_keepalive_plan_autostart()` 生成,
plist 的**内容**由 Rust 核心 `inkwatch_keepalive_plan_autostart()` 生成,
安装器只负责落盘与执行 —— 不要在 Dart 侧再写一份 plist 模板。
### 安装 / 卸载 / 自检
```bash
# 1) 编译两个独立可执行文件(非沙箱,才能写 LaunchAgents 与调 launchctl)
dart compile exe bin/pmon_agent.dart -o build/pmon-agent
dart compile exe bin/pmon_install.dart -o build/pmon-install
# 构建命令只此一处,别在这里再抄一遍(Dart 3.13 起 `dart compile exe` 会因
# build hooks 失败;脚本会同时产出 build/inkwatch-agent 与 build/inkwatch-install)
bash scripts/build_cli.sh
# 2) 注册自启(落位到 ~/Library/Application Support/pmon_client/bin,写 plist,bootstrap)
./build/pmon-install install --server http://127.0.0.1:3000 --device-id my-mac
# 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/pmon-install verify
./build/pmon-install status # 注册态 + 进程 + 配置 + 最近日志
./build/pmon-install restart # kickstart(验证保活用)
./build/pmon-install uninstall # 停止并移除自启
./build/inkwatch-install verify
./build/inkwatch-install status # 注册态 + 进程 + 配置 + 最近日志
./build/inkwatch-install restart # kickstart(验证保活用)
./build/inkwatch-install uninstall # 停止并移除自启
```
自启项的命令行极简(`<exe> --collector`),网关地址与设备标识**从配置读**
(`~/Library/Application Support/pmon_client/config.json`),改配置即可,不必改注册项。
(`~/Library/Application Support/inkwatch_client/config.json`),改配置即可,不必改注册项。
### ⚠️ `launchctl bootstrap` 在受限环境不可用
@@ -706,8 +791,8 @@ dart compile exe bin/pmon_install.dart -o build/pmon-install
请在**自己的终端**(非沙箱)执行一次确认:
```bash
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.pmon.collector.plist
launchctl print gui/$(id -u)/com.pmon.collector | grep -E "state|pid"
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.inkwatch.collector.plist
launchctl print gui/$(id -u)/com.inkwatch.collector | grep -E "state|pid"
```
### 沙箱与自启的分工(设计约束)
@@ -721,7 +806,7 @@ macOS 沙箱应用**写不了 `~/Library/LaunchAgents/`**(该目录不在应
### 架构:采集跑在「前台服务持有的 FlutterEngine」里
```
PmonCollectorService(前台服务,常驻通知)
InkwatchCollectorService(前台服务,常驻通知)
└─ 创建 FlutterEngine → 执行 Dart main() → 放进 FlutterEngineCache
│
MainActivity ──provideFlutterEngine()─────┘ 复用同一个引擎
@@ -747,31 +832,34 @@ adb reverse tcp:3000 tcp:3000
| 组件 | 版本 |
|---|---|
| 模拟器 AVD | `pmon_arm64`(Android 16 / API 36 / google_apis / arm64-v8a) |
| 模拟器 AVD | `inkwatch_arm64`(Android 16 / API 36 / google_apis / arm64-v8a) |
| NDK | 30.0.16248370 |
| 前台服务类型 | `dataSync`(应用内启动)/ `specialUse`(开机自启) |
创建 AVD:
```bash
avdmanager create avd -n pmon_arm64 -k "system-images;android-36;google_apis;arm64-v8a"
emulator -avd pmon_arm64 -no-snapshot-load -no-audio -no-boot-anim
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 pmon/client
cd inkwatch/client
bash scripts/build_android_core.sh # 若 rust-core 有改动
flutter build apk --release --target-platform android-arm64 \
--dart-define=PMON_DEFAULT_SERVER=http://203.0.113.10 # ← 编译期默认服务器地址
--dart-define=INKWATCH_DEFAULT_SERVER=http://203.0.113.10 # ← 编译期默认服务器地址
cp build/app/outputs/flutter-apk/app-release.apk \
../dist/pmon-android-arm64-$(grep -E '^version:' pubspec.yaml | cut -d' ' -f2 | cut -d+ -f1).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 有 `libpmon_core.so` → 另两个 ABI 装上去直接崩。带上约 19MB |
| **交付包不带 `--dart-define=PMON_LOG_JANK=true`** | 那是卡顿取证的探针,只在本地排查时开 |
| **必须带 `--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 相同而拒绝覆盖安装 |
@@ -790,6 +878,60 @@ grep -cF "203.0.113.10" /tmp/chk/lib/arm64-v8a/libapp.so # 用 -cF,别用 -q
> `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/` —— **那个目录只在数据目录为空时执行**,
@@ -818,7 +960,7 @@ docker compose run --rm migrate # 或 docker compose up -d --force-recrea
1. **复用缓存引擎时 `configureFlutterEngine` 不会被调用**
→ 通道必须在 `provideFlutterEngine` 里也注册一次。否则开机自启路径(服务先建引擎)
下 Dart 一调通道就 `MissingPluginException`。通道逻辑已抽到 `PmonChannel.kt`,服务与界面共用。
下 Dart 一调通道就 `MissingPluginException`。通道逻辑已抽到 `InkwatchChannel.kt`,服务与界面共用。
2. **`Platform.localHostname` 在 Android 上返回 `"localhost"`**
→ 直接当 device_id 会让**所有安卓设备撞成同一个 ID**,而 device_id 是全系统主键。
@@ -849,17 +991,19 @@ docker compose run --rm migrate # 或 docker compose up -d --force-recrea
|---|---|
| 静态分析 | `No issues found!` |
| 客户端测试(FFI 21 + 路径 9) | **全部通过** |
| macOS 客户端构建 | `pmon_client.app` 43.8MB,注入核心库后独立启动成功 |
| 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 帧落库** |
| 安装器自检 | `pmon-install verify` **全绿**(launchd 项如实标注环境限制) |
| 安装器自检 | `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` **282/282**(查看端 49+17+12、配对卡 6、账号 21、自适应单位 17、**格式化唯一出口 15**、**历史 15**、**视觉令牌护栏 6**、**骨架屏 5**、**启动路径护栏 8**、**配置规范化护栏 19**、**启动诊断 12**、**设备归并告知 6**、**空闲采样 3**、**机型降级 12**、FFI 28…) |
| **客户端 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!** |
+9 -5
View File
@@ -24,7 +24,7 @@ val keystoreProps = Properties().apply {
}
android {
namespace = "com.pmon.pmon_client"
namespace = "com.inkwatch.eink"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
@@ -45,10 +45,14 @@ android {
}
defaultConfig {
// ⚠️ 这是**主干(通用版)**的包名,已装机不可更改语义,见 docs/rename-inkwatch.md。
// 水墨屏专属版(feature/eink 分支)刻意用**另一个** applicationId,
// 否则两个版本在同一台手机上会互相覆盖(且 versionCode 小的会被当成降级)。
applicationId = "com.pmon.pmon_client"
// ⚠️ **水墨屏专属版**(feature/eink 分支)的包名 —— 与主干(通用版,
// com.inkwatch.eink)**刻意不同**:
// 两条分支的 versionCode 各自递增,共用一个包名会互相覆盖,
// 且 versionCode 小的那个会被系统当成**降级**而拒绝安装。
// 独立包名 = 两个版本可在同一台手机上共存、各自升级。
// 这是本分支与主干**唯一有意的差异**,合并主干时不要把它合回去。
// 见 docs/release-and-signing.md 第四节。
applicationId = "com.inkwatch.eink"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
@@ -46,7 +46,7 @@
dataSync 对应"数据同步/上传"用途;specialUse 用于开机自启场景 ——
Android 15(API 35)+ 起禁止从 BOOT_COMPLETED 拉起 dataSync 类型的前台服务。 -->
<service
android:name=".PmonCollectorService"
android:name=".InkwatchCollectorService"
android:exported="false"
android:stopWithTask="false"
android:foregroundServiceType="dataSync|specialUse">
@@ -1,4 +1,4 @@
package com.pmon.pmon_client
package com.inkwatch.eink
import android.content.BroadcastReceiver
import android.content.Context
@@ -10,11 +10,11 @@ import android.util.Log
* 开机自启接收器(方案 8.2:BOOT_COMPLETED 广播 + 前台服务)。
*
* Android 的自启是**构建期清单声明 + 运行期广播**,无法像桌面端那样动态注册 ——
* 声明内容来自 rust-core 的 `pmon_keepalive_plan_autostart()`,不要在此另写一套。
* 声明内容来自 rust-core 的 `inkwatch_keepalive_plan_autostart()`,不要在此另写一套。
*
* ⚠️ 平台限制(务必如实告知用户,不要虚假承诺):
* · Android 15 (API 35) 起,**禁止**从 BOOT_COMPLETED 拉起 `dataSync` 类型的前台服务。
* 故此处以 `specialUse` 类型启动(见 PmonCollectorService.startForegroundCompat)。
* 故此处以 `specialUse` 类型启动(见 InkwatchCollectorService.startForegroundCompat)。
* · 国内 ROM(小米/华为/OPPO/vivo 等)普遍有「自启动管理」,默认拦截第三方自启,
* 需引导用户手动放行(文案见 rust-core 的 runtime_guidance)。
* · 拉起失败(SecurityException / ForegroundServiceStartNotAllowedException)不应崩溃,
@@ -23,7 +23,7 @@ import android.util.Log
class BootReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "PmonBoot"
private const val TAG = "InkwatchBoot"
}
override fun onReceive(context: Context, intent: Intent) {
@@ -35,7 +35,7 @@ class BootReceiver : BroadcastReceiver() {
Log.i(TAG, "收到 $action,尝试恢复采集服务")
try {
PmonCollectorService.start(context.applicationContext, fromBoot = true)
InkwatchCollectorService.start(context.applicationContext, fromBoot = true)
} catch (e: Exception) {
// 不崩溃:自启失败属可接受降级,用户手动打开 App 即可恢复
Log.w(TAG, "自启被系统或 ROM 拦截(${Build.MANUFACTURER}/${Build.MODEL}):${e.message}")
@@ -1,4 +1,4 @@
package com.pmon.pmon_client
package com.inkwatch.eink
import android.Manifest
import android.app.Activity
@@ -11,6 +11,7 @@ import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import android.view.WindowManager
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
@@ -30,11 +31,11 @@ import io.flutter.plugin.common.MethodChannel
* 故把注册逻辑放进本类,**谁持有引擎谁注册**;Activity 附加后会用带 Activity 的
* 实例覆盖一次(权限类方法需要 Activity,无 Activity 时如实返回 false 而不是假装成功)。
*/
class PmonChannel(private val ctx: Context) {
class InkwatchChannel(private val ctx: Context) {
companion object {
const val NAME = "com.pmon/foreground"
private const val TAG = "PmonChannel"
const val NAME = "com.inkwatch/foreground"
private const val TAG = "InkwatchChannel"
const val REQ_NOTIF = 9001
const val REQ_BATT = 9002
}
@@ -55,14 +56,14 @@ class PmonChannel(private val ctx: Context) {
Log.i(TAG, "方法调用: ${call.method}")
when (call.method) {
"start" -> {
PmonCollectorService.start(ctx.applicationContext, fromBoot = false)
InkwatchCollectorService.start(ctx.applicationContext, fromBoot = false)
result.success(true)
}
"stop" -> {
PmonCollectorService.stop(ctx.applicationContext)
InkwatchCollectorService.stop(ctx.applicationContext)
result.success(true)
}
"isRunning" -> result.success(PmonCollectorService.running)
"isRunning" -> result.success(InkwatchCollectorService.running)
// Android 上 Dart 的 HOME 不可靠,配置必须落在应用私有目录
"filesDir" -> result.success(ctx.filesDir.absolutePath)
@@ -88,6 +89,26 @@ class PmonChannel(private val ctx: Context) {
}
}
// 水墨屏副屏:保持屏幕常亮。
// 墨水屏静止不耗电(画面物理保持),常亮代价极小;而息屏后副屏就没用了。
// 必须在 UI 线程改 window flag(Channel 回调不保证在主线程)。
"setKeepScreenOn" -> {
val act = activity
val on = call.argument<Boolean>("on") ?: false
if (act == null) {
result.success(false)
return
}
act.runOnUiThread {
if (on) {
act.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
act.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
result.success(true)
}
"isIgnoringBatteryOptimizations" -> {
val pm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
result.success(pm.isIgnoringBatteryOptimizations(ctx.packageName))
@@ -1,4 +1,4 @@
package com.pmon.pmon_client
package com.inkwatch.eink
import android.app.Notification
import android.app.NotificationChannel
@@ -36,26 +36,26 @@ import io.flutter.embedding.engine.dart.DartExecutor
* · 应用内启动 → dataSync(数据上传)
* · 开机自启 → specialUse(Android 15+ 禁止从 BOOT_COMPLETED 拉起 dataSync 类型)
*/
class PmonCollectorService : Service() {
class InkwatchCollectorService : Service() {
companion object {
private const val TAG = "PmonService"
private const val CHANNEL_ID = "pmon_collector"
private const val TAG = "InkwatchService"
private const val CHANNEL_ID = "inkwatch_collector"
private const val NOTIF_ID = 1001
const val ENGINE_CACHE_KEY = "pmon_engine"
const val ENGINE_CACHE_KEY = "inkwatch_engine"
/** 由 BootReceiver 置位:开机自启场景需换用 specialUse 类型 */
const val EXTRA_FROM_BOOT = "from_boot"
const val ACTION_START = "com.pmon.pmon_client.START"
const val ACTION_STOP = "com.pmon.pmon_client.STOP"
const val ACTION_START = "com.inkwatch.eink.START"
const val ACTION_STOP = "com.inkwatch.eink.STOP"
/** 供 Activity/方法通道查询服务是否在跑 */
var running = false
private set
fun start(context: Context, fromBoot: Boolean = false) {
val i = Intent(context, PmonCollectorService::class.java).apply {
val i = Intent(context, InkwatchCollectorService::class.java).apply {
action = ACTION_START
putExtra(EXTRA_FROM_BOOT, fromBoot)
}
@@ -67,13 +67,13 @@ class PmonCollectorService : Service() {
}
fun stop(context: Context) {
val i = Intent(context, PmonCollectorService::class.java).apply { action = ACTION_STOP }
val i = Intent(context, InkwatchCollectorService::class.java).apply { action = ACTION_STOP }
context.startService(i) // 交给服务自己 stopSelf,避免直接 stopService 丢失通知清理
}
}
private var engine: FlutterEngine? = null
private var channel: PmonChannel? = null
private var channel: InkwatchChannel? = null
override fun onBind(intent: Intent?): IBinder? = null
@@ -128,7 +128,7 @@ class PmonCollectorService : Service() {
// ⚠️ 必须在执行 Dart 入口**之前**注册通道。
// 开机自启路径下没有 Activity,Dart 侧 main() 一跑就会调 filesDir/start 等方法;
// 若等 Activity 再注册,中间这段时间的调用会抛 MissingPluginException。
val c = PmonChannel(applicationContext)
val c = InkwatchChannel(applicationContext)
c.attach(e)
channel = c
@@ -1,4 +1,4 @@
package com.pmon.pmon_client
package com.inkwatch.eink
import android.content.Context
import android.util.Log
@@ -23,13 +23,13 @@ import io.flutter.embedding.engine.FlutterEngineCache
class MainActivity : FlutterActivity() {
companion object {
private const val TAG = "PmonActivity"
private const val TAG = "InkwatchActivity"
}
private var channel: PmonChannel? = null
private var channel: InkwatchChannel? = null
private fun channelFor(context: Context): PmonChannel {
val c = channel ?: PmonChannel(context).also { channel = it }
private fun channelFor(context: Context): InkwatchChannel {
val c = channel ?: InkwatchChannel(context).also { channel = it }
c.activity = this
return c
}
@@ -48,7 +48,7 @@ class MainActivity : FlutterActivity() {
*/
override fun provideFlutterEngine(context: Context): FlutterEngine {
val cache = FlutterEngineCache.getInstance()
cache.get(PmonCollectorService.ENGINE_CACHE_KEY)?.let { cached ->
cache.get(InkwatchCollectorService.ENGINE_CACHE_KEY)?.let { cached ->
Log.i(TAG, "复用已缓存的 FlutterEngine(服务先起)")
channelFor(context).attach(cached)
return cached
@@ -57,7 +57,7 @@ class MainActivity : FlutterActivity() {
Log.i(TAG, "缓存为空 → 由 Activity 创建引擎并写缓存(避免服务再建第二个)")
val engine = FlutterEngine(context)
channelFor(context).attach(engine)
cache.put(PmonCollectorService.ENGINE_CACHE_KEY, engine)
cache.put(InkwatchCollectorService.ENGINE_CACHE_KEY, engine)
return engine
}
+13
View File
@@ -11,6 +11,19 @@ pluginManagement {
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
// 阿里云镜像放在官方仓库之前(2026-11-10 加)。
// 起因:Gitea runner 上 maven.google.com 连接超时,而 AGP/Kotlin 插件
// 就在 google() 里 —— 若不放镜像,CI 上每个插件都要先超时一轮。
// 本地开发机也走这里,只是更快;官方仓库留作兜底,等价可回退。
//
// ⚠️ 这份列表是**唯一**的仓库来源:Flutter/AGP 把模式设成
// PREFER_SETTINGS(只认 settings 的仓库,项目级再声明就报错),
// 所以不要再用 init.gradle 或子项目 build.gradle 另加仓库。
// 2026-11-10 run 14 踩过:加了 init.gradle 的 allprojects 注入,
// 构建直接以 "prefer settings repositories over project repositories" 失败。
maven("https://maven.aliyun.com/repository/google")
maven("https://maven.aliyun.com/repository/public")
maven("https://maven.aliyun.com/repository/gradle-plugin")
google()
mavenCentral()
gradlePluginPortal()
@@ -5,26 +5,31 @@
/// 完全相同的宿主层(`lib/host/link.dart`),不复制一份。
///
/// 用法:
/// dart run bin/pmon_agent.dart --server http://127.0.0.1:3000 --device-id my-mac
/// dart run bin/pmon_agent.dart --duration 30 # 跑 30 秒后自动退出(冒烟/CI 用)
/// dart run bin/pmon_agent.dart --print-plan # 只打印本机自启注册计划(不联网)
/// dart run bin/inkwatch_agent.dart --server http://127.0.0.1:3000 --device-id my-mac
/// dart run bin/inkwatch_agent.dart --duration 30 # 跑 30 秒后自动退出(冒烟/CI 用)
/// dart run bin/inkwatch_agent.dart --print-plan # 只打印本机自启注册计划(不联网)
///
/// 编译成独立可执行文件(可放进 LaunchAgent / systemd / 计划任务):
/// dart compile exe bin/pmon_agent.dart -o build/pmon-agent
/// client/scripts/build_cli.sh # 产出 build/inkwatch-agent 与 build/inkwatch-install
///
/// ⚠️ 不要再照抄 `dart compile exe`:Dart 3.13 起遇到带 build hooks 的包
/// (本项目的 `objective_c`)会直接报错并退出,见 `scripts/build_cli.sh` 的文件头。
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:pmon_client/host/app_config.dart';
import 'package:pmon_client/host/link.dart';
import 'package:pmon_client/host/pmon_core.dart';
import 'package:inkwatch_client/host/agent_lock.dart';
import 'package:inkwatch_client/host/app_config.dart';
import 'package:inkwatch_client/host/autostart_label.dart';
import 'package:inkwatch_client/host/link.dart';
import 'package:inkwatch_client/host/inkwatch_core.dart';
const _usage = '''
墨观 采集端(无界面)
用法: pmon-agent [选项]
用法: inkwatch-agent [选项]
选项:
--collector 以采集端身份运行(LaunchAgent/systemd 使用;等价于默认行为)
@@ -36,30 +41,73 @@ const _usage = '''
--quiet 只输出错误与关键事件
--help 显示本帮助
配置位置: ~/Library/Application Support/pmon_client/config.json(macOS)
配置位置: ~/Library/Application Support/inkwatch_client/config.json(macOS)
''';
Future<int> main(List<String> argv) async {
/// 进程入口 —— **退出码必须显式写进 `exitCode`**。
///
/// ⚠️ 这不是风格问题,是本仓库实测过的坑(Dart SDK 3.13.3):
///
/// | 写法 | `dart run` | `dart compile exe` |
/// |---|---|---|
/// | `int main() { return 3; }` | **0** | **0** |
/// | `Future<int> main() async { return 3; }` | **0** | **0** |
/// | `void main() { exitCode = 3; }` | 3 | 3 |
///
/// 即**本 SDK 上 main 的返回值完全不设进程退出码**,只有显式赋值才生效。
/// 对本进程后果极重:plist 用 `KeepAlive.SuccessfulExit=false`,launchd 靠
/// 「非零退出」判定崩溃;退出码被吞成 0 ⇒ **采集端崩了也不会被拉起**,
/// 用户只看到历史断档,且没有任何报错。
///
/// 故:函数体抽成 [runAgent](返回码语义清晰、可被测试直接调),
/// `main` 只做一件事 —— 把返回值落到 [exitCode]。
Future<void> main(List<String> argv) async {
exitCode = await runAgent(argv);
}
/// 采集端主逻辑 —— **返回码语义见下方各 `return` 处的注释**。
///
/// 归纳:只有「请求没被满足」才非零;「用户主动停」「抢不到锁」「正常到期」
/// 一律 0(理由逐条写在退出点上,别改成"失败就非零"的一刀切)。
Future<int> runAgent(List<String> argv) async {
final args = _parseArgs(argv);
if (args['help'] == true) {
stdout.writeln(_usage);
return 0;
return 0; // 帮助是正常完成,不是失败
}
final core = PmonCore.instance;
final core = InkwatchCore.instance;
// ── 仅打印自启计划:用于人工核对 plist/unit/reg 内容 ──
if (args['print-plan'] == true) {
final exe = Platform.resolvedExecutable;
final plan = core.planAutostart(exePath: exe, label: 'com.pmon.collector');
// label 的唯一真相源是 `host/autostart_label.dart`(不要在 bin/ 里再写一份)
final plan = core.planAutostart(exePath: exe, label: kDefaultAutostartLabel);
stdout.writeln('平台: ${core.platformName}(后台常驻 ${core.canRunInBackground ? "支持" : "不支持"})');
stdout.writeln(const JsonEncoder.withIndent(' ').convert(plan));
if ((plan['error'] as Map?)?['reason'] != null) {
stdout.writeln('\n注意: ${plan['error']['reason']}');
// 请求的"计划"没拿到(如 iOS 不支持、路径非法)→ 必须非零。
// 退出 0 会让调用脚本以为拿到了计划,进而去读一个空产物。
return 1;
}
return 0;
}
// ── 单实例保护:同一台机器上只允许一个采集进程 ──
// 与 GUI 内建采集共用同一把锁(见 host/agent_lock.dart)。
// 两个进程同时采集会双写同一 device_id:各自 seq 从 1 数起,
// 服务端的 seq 去重窗口会把两条流混在一起判重 → 丢帧或重复帧,且不报错。
final agentLock = await AgentLock.tryAcquire();
if (agentLock == null) {
stderr.writeln('已有采集进程在运行,本进程退出(避免双写同一设备导致去重误判)。');
stderr.writeln('查状态: inkwatch-install status 重启: inkwatch-install restart');
// ⚠️ 必须返回 0,**这是刻意为之,别改成非零**。
// launchd 的 KeepAlive.SuccessfulExit=false 语义下,非零退出会被当成崩溃
// 并立刻重启 → 两个进程互踢,变成无限重启循环(比"没起来"更糟)。
return 0;
}
final serverArg = args['server'] as String?;
final deviceArg = args['device-id'] as String?;
@@ -122,6 +170,8 @@ Future<int> main(List<String> argv) async {
stdout.writeln('\n收到中断信号,正在优雅退出…');
await agent.stop();
await sigint.cancel();
// 0 = 用户主动停。若这里给非零,launchd(SuccessfulExit=false)会把它
// 当成崩溃立刻再拉起来 —— 用户"停不掉",表现成"关不掉的进程"。
exit(0);
});
@@ -138,12 +188,12 @@ Future<int> main(List<String> argv) async {
}
await agent.stop();
await sigint.cancel();
return 0;
return 0; // `--duration` 正常到期(冒烟/CI 的正常路径),不是失败
}
// 常驻:等进程被杀
await Completer<void>().future;
return 0;
return 0; // 理论不可达:只有信号处理(exit)或 kill 能结束常驻形态
}
/// 极简参数解析(不引入 args 包,减少客户端依赖面)
+769
View File
@@ -0,0 +1,769 @@
/// 墨观 客户端安装器 —— 注册/卸载/查看开机自启
///
/// 为什么安装器是独立 CLI 而不是 GUI 的一部分:
/// macOS 沙箱应用**写不了 `~/Library/LaunchAgents/`**(该目录不在应用容器内)。
/// 故自启注册必须由**非沙箱**进程完成 —— 就是这个 CLI,
/// 编译成独立可执行文件后运行(构建方式见 `scripts/build_cli.sh`,
/// **不要**照抄历史上的 `dart compile exe`:Dart 3.13 起它会因 build hooks 直接失败)。
/// GUI 只负责显示状态。
///
/// 自启产物的**内容**(plist 文本、安装/卸载命令)由 Rust 核心生成
/// (`inkwatch_keepalive_plan_autostart`),本文件只负责落盘与执行 ——
/// 不要在 Dart 侧再写一份 plist 模板。
///
/// 用法:
/// inkwatch-install install # 注册自启并立即启动
/// inkwatch-install install --no-start # 只注册,不立即启动
/// inkwatch-install verify # 自检(含实跑采集端,受限环境可用)
/// inkwatch-install status # 查看注册态 / 进程 / 配置 / 日志
/// inkwatch-install restart # 重启采集端(验证保活用)
/// inkwatch-install uninstall # 停止并移除自启
/// 可选:`--server <url>` `--device-id <id>` `--label <id>` `--agent-bin <path>`
library;
import 'dart:convert';
import 'dart:io';
import 'package:inkwatch_client/host/app_config.dart';
import 'package:inkwatch_client/host/app_paths.dart';
import 'package:inkwatch_client/host/autostart_label.dart';
import 'package:inkwatch_client/host/autostart_runner.dart';
import 'package:inkwatch_client/host/install_paths.dart';
import 'package:inkwatch_client/host/inkwatch_core.dart';
import 'package:path/path.dart' as p;
// label 的唯一真相源是 `host/autostart_label.dart` —— GUI 的 `ResidentService`
// 查状态时传的是**同一个常量**。曾经两处各写一份字面量,漂移后的症状是
// "明明装了、卡片却显示未注册"(问的不是同一个自启项)。
const _defaultLabel = kDefaultAutostartLabel;
Future<void> main(List<String> argv) async {
// ⚠️ 必须显式写 `exitCode`:本 Dart SDK **不再把 `int main()` 的返回值当退出码**
// (实测:`Future<int> main` / `int main` 返回 3,进程退出码都是 0)。
// 也就是说,只把 `_install()` 的返回值改成非零,shell / 部署脚本 / CI
// 依然看到 0 ——「安装器不再撒谎」这项修复会**到不了进程边界**,
// 现场表现与修复前完全一样。故这里把返回值统一落到 `exitCode` 上。
exitCode = await runCli(argv);
}
/// 子命令分派 —— 返回码语义:0 成功 / 非 0 失败。
///
/// 抽成公开函数有两个理由:
/// 1. `exitCode` 的设置只发生在 [main] 一处,测试可以直接调本函数拿返回值;
/// 2. 谁再改 `main` 的签名,也不会把退出码语义悄悄弄丢。
Future<int> runCli(List<String> argv) async {
if (argv.isEmpty || argv.first == 'help' || argv.first == '--help') {
stdout.writeln('用法: inkwatch-install <install|uninstall|status|restart> [选项]');
return 0;
}
final cmd = argv.first;
final opts = _parseArgs(argv.skip(1).toList());
final label = (opts['label'] as String?) ?? _defaultLabel;
try {
switch (cmd) {
case 'install':
return await _install(opts, label);
case 'uninstall':
return await _uninstall(label);
case 'status':
// `--json` 供 GUI 解析(判据仍是 statusSnapshot 那一处,见其注释)
return await _status(label, json: opts['json'] == true);
case 'restart':
return await _restart(label);
case 'verify':
return await _verify(opts, label);
default:
stderr.writeln('未知子命令: $cmd');
return 1;
}
} catch (e) {
stderr.writeln('执行失败: $e');
return 1;
}
}
/* ────────────────────────── install ────────────────────────── */
Future<int> _install(Map<String, Object> opts, String label) async {
final core = InkwatchCore.instance;
stdout.writeln('墨观 客户端安装(平台 ${core.platformName})');
if (!core.canRunInBackground) {
stderr.writeln('当前平台不支持后台常驻自启,安装取消。');
return 1;
}
// 1) 落位:采集端可执行文件 + 核心库放同一目录
// (core_lib.dart 会先找「可执行文件同目录」,这样自启项无需任何环境变量)
final srcAgent = (opts['agent-bin'] as String?) ??
p.join(Directory.current.path, 'build', 'inkwatch-agent');
if (!File(srcAgent).existsSync()) {
stderr.writeln('找不到采集端可执行文件: $srcAgent');
stderr.writeln('请先执行: client/scripts/build_cli.sh '
'(产物 build/inkwatch-agent;Dart 3.13 起 `dart compile exe` 已因 build hooks 失效)');
return 1;
}
final srcLib = (opts['core-lib'] as String?) ??
Platform.environment['INKWATCH_CORE_LIB'] ??
p.join(Directory.current.path, '..', 'rust-core', 'target', 'release',
coreLibName);
if (!File(srcLib).existsSync()) {
stderr.writeln('找不到核心库: $srcLib');
stderr.writeln('请先执行: (cd ../rust-core && cargo build --release)');
return 1;
}
await Directory(installBinDir).create(recursive: true);
await File(srcAgent).copy(installedAgentPath);
final libDst = p.join(installBinDir, p.basename(srcLib));
await File(srcLib).copy(libDst);
await Process.run('chmod', ['755', installedAgentPath]);
stdout.writeln(' ✓ 已落位: $installedAgentPath');
stdout.writeln(' ✓ 已落位: $libDst');
// 1b) 采集端自带的**运行期动态库**:`dart build cli` 的产物布局是
// `<bundle>/bin/<exe>` + `<bundle>/lib/<libs>`(Dart 3.13 起 `dart compile exe`
// 因 build hooks 失效,见 scripts/build_cli.sh 的文件头)。
// 只拷 exe 会让缺库失败**推迟到运行期**,而且报的是 dlopen 错,
// 与"装不上"完全不像 —— 这种"很晚才炸"的失败正是要提前消灭的。
// 依据:bundle/lib 是 SDK 为该可执行文件声明的运行期依赖集(不是构建残留目录),
// 故按扩展名收动态库,**不整目录拷贝**(避免把无关产物带进安装目录)。
// 实测(2026-10-27):当前两个 CLI 已走到的代码路径并不 dlopen objective_c.dylib,
// 所以这一步是"自包含保险",不是修复一个正在发生的失败 —— 代价只是一次拷贝。
final runtimeLibs = _runtimeLibsNextTo(srcAgent);
for (final lib in runtimeLibs) {
final dst = p.join(installBinDir, p.basename(lib.path));
await lib.copy(dst);
stdout.writeln(' ✓ 已落位运行期库: $dst');
}
// 2) 配置(自启项只传 --collector,网关与设备标识从配置读)
final prev = await ConfigStore.load();
final merged = AppConfig(
serverUrl: (opts['server'] as String?) ?? prev.serverUrl,
deviceId: (opts['device-id'] as String?)?.trim().isNotEmpty == true
? (opts['device-id'] as String).trim()
: (prev.deviceId.isNotEmpty ? prev.deviceId : _defaultDeviceId()),
autoStart: true,
samplingIntervalMs: prev.samplingIntervalMs,
);
await ConfigStore.save(merged);
stdout.writeln(' ✓ 配置: ${ConfigStore.path}');
stdout.writeln(' server=${merged.serverUrl} device_id=${merged.deviceId}');
// 3) 向 Rust 核心要自启计划(产物内容由核心生成,本文件不写模板)
final plan = core.planAutostart(exePath: installedAgentPath, label: label);
if (plan['error'] != null) {
stderr.writeln('核心返回不支持: ${plan['error']['reason']}');
return 1;
}
for (final f in (plan['files'] as List?) ?? []) {
final path = expandTilde(f['path'] as String);
await Directory(p.dirname(path)).create(recursive: true);
await File(path).writeAsString(f['content'] as String, flush: true);
stdout.writeln(' ✓ 已写入: $path');
}
// 4) 执行安装命令(先 bootout 再 bootstrap,避免重复注册报 already loaded)
//
// 成败语义由 Rust 的 AutoStartPlan 随命令给出(allow_failure),这里只照做。
// 历史缺陷(2026-10-25 修):此处原先"非零只打印一行就继续",循环结束后
// **无条件**打印「安装完成」并 return 0 —— 于是 bootstrap / systemctl enable
// 全失败,安装器照样宣布成功。任何自动化(部署脚本、CI、GUI 的一键安装按钮)
// 都无法据此判断成败。假成功比失败更糟:用户信了绿勾就不再自查。
final outcome = await runAutostartCommands(
(plan['install_commands'] as List?) ?? const [],
);
_printAutostartOutcome(outcome);
var hardFailures = outcome.hardFailures;
// 本平台**有没有进程探针**是平台属性,与本次是否等待无关 —— 先取一次,
// 后面既用于"要不要等进程",也用于安装成功后该给用户哪一句实话。
final probeSupport = await probeAgentProcess();
if (opts['no-start'] != true) {
final probe = await _waitForAgent(const Duration(seconds: 3));
if (!probe.available) {
// 「查不了」不能算成「没起来」:非 macOS/Linux 上可能没有 pgrep,
// 那时把安装判成失败等于告诉用户"装了但没起",而实际什么都没查过。
// 只如实警告,不把不确定变成失败(也不变成成功)。
stdout.writeln(' ⚠ 本平台无法判定采集端进程(${probe.error}),'
'请用 status 或系统工具确认');
} else if (!probe.running) {
hardFailures++;
stderr.writeln(' ✗ 未检测到采集端进程(已等 3 秒)');
} else {
stdout.writeln(' ✓ 采集端已运行(pid ${probe.pids.join(",")})');
}
}
if (hardFailures > 0) {
stderr.writeln('\n安装失败:$hardFailures 项必成步骤未成功。');
stderr.writeln('排查:inkwatch-install status');
stderr.writeln('(macOS 上若为 launchd 域不可访问,属受限环境,'
'需在自己终端重新执行 install)');
return 1;
}
// 安装成功、但本平台**给不出"进程已在跑"的证据**时,必须补一句平台实话。
//
// 为什么非补不可(2026-10-27 定):三个平台的「装完是否立刻在跑」并不一样,
// 而 Windows(`reg add` + `schtasks /SC ONLOGON`)**根本不会立刻启动**。
// 界面/命令行若只说「安装完成」,用户会一直等一个不会出现的东西;
// 更糟的是上游(GUI 的常驻卡)曾把"安装返回 0"读成"已在跑",于是把自己
// 的采集停掉 —— 结果是两个都没在采、界面还很平静。
// 这行字把「启动」从承诺降级成事实描述。
if (!probeSupport.available) {
stdout.writeln(' 说明:已注册 · ${launchSemanticsNoteFor(currentPlatform)}');
}
stdout.writeln('\n安装完成。排查用:inkwatch-install status');
stdout.writeln('提示:本项为「登录即起 + 崩溃自恢复」,系统重启后同样生效。');
return 0;
}
/* ────────────────────────── uninstall ────────────────────────── */
Future<int> _uninstall(String label) async {
final core = InkwatchCore.instance;
// 卸载命令同样来自核心(与 plist 配套,避免两处不一致)
final plan = core.planAutostart(exePath: installedAgentPath, label: label);
if (plan['error'] != null) {
stderr.writeln('核心返回: ${plan['error']['reason']}');
return 1;
}
final outcome = await runAutostartCommands(
(plan['uninstall_commands'] as List?) ?? const [],
);
_printAutostartOutcome(outcome);
final hardFailures = outcome.hardFailures;
final stopped = await _confirmStopped();
if (hardFailures > 0 || stopped == false) {
stderr.writeln('\n卸载未完成。');
if (hardFailures > 0) stderr.writeln(' · $hardFailures 项必成命令失败');
if (stopped == false) stderr.writeln(' · 采集端进程仍在运行(已等 5 秒)');
stderr.writeln('排查:inkwatch-install status');
return 1;
}
if (stopped == null) {
// 探针不可用:不能据此宣布"已停止",也不能判失败(那会把环境限制说成缺陷)
stdout.writeln(' ⚠ 已移除自启项,但本平台无法判定采集端进程是否已退出');
} else {
stdout.writeln(' ✓ 已停止并移除自启项');
}
stdout.writeln('(采集端文件与配置保留在 $installRoot,如不需要可自行删除)');
return 0;
}
/* ────────────────────────── status ────────────────────────── */
/// 四类判据的**唯一**采集处:自启注册态 / 进程 / 配置 / 日志。
///
/// 为什么抽成"取数据的函数 + 两个打印器":
/// · GUI(`lib/host/resident_card.dart`)要的是**可解析**的状态,人要看的是
/// 分段文本 —— 若各写一套采集逻辑,必然出现"卡片说在跑、CLI 说没起",
/// 而这种两张嘴的事在这个项目里已经翻过车(配对码/指标字段/路径各两次以上)。
/// · 故采集只在这里做一次,`_status()` 只决定"打印成什么样"。
/// 返回的键与 `ResidentStatus.fromJson` 严格对应(GUI 侧解析的那一端)。
Future<Map<String, Object?>> statusSnapshot(String label) async {
// 自启注册态:产物路径来自 app_paths(唯一真相源);launchd 是否**已加载**
// 只有 macOS 能问,别的平台如实给 null("问不到"≠"没加载")。
final unitPath = launchAgentPlistPath(label);
final unitExists = unitPath != null && File(unitPath).existsSync();
bool? loaded;
final detail = <String>[];
if (unitPath != null && Platform.isMacOS) {
final uid = (await Process.run('id', ['-u'])).stdout.toString().trim();
final r = await Process.run('/bin/zsh', ['-c', 'launchctl print gui/$uid/$label 2>&1']);
final out = r.stdout.toString();
loaded = r.exitCode == 0;
if (out.isNotEmpty) {
for (final line in out.split('\n')) {
if (RegExp(r'^\s*(state|pid|program|last exit code|runs) =').hasMatch(line)) {
detail.add(line.trim());
}
}
}
}
final probe = await probeAgentProcess();
final cfg = await ConfigStore.load();
// TODO(单一真相源 / 第 15 条收尾):这个 `/tmp/<label>.err.log` 是**唯一**还没收敛的
// 路径——Rust 的 `macos_launchagent_plist` 把 `StandardErrorPath` 写成同一形状,
// 也就是说这条事实在 Rust 与 Dart 各写一份(改一处就"status 找错日志文件")。
// 正解(未做,要动 Rust 结构 + FFI 出口):让 `AutoStartPlan` 多返回
// `stderr_log_path`,Dart 侧**只读不算** —— 即改为读 `AutoStartPlan.stderr_log_path`,
// **勿再在本地拼 `/tmp/<label>.err.log`**(与 `allow_failure` 同款:语义由 Rust 给,
// Dart 只消费)。**不在本轮做**:需改 Rust 数据结构 + FFI 出口,超出"client 路径"边界。
// 另注:非 macOS 上 `/tmp` 未必存在(Windows 一定没有),但那里也没有 launchd
// `StandardErrorPath` 这回事,故该字段在采集端日志上本就无意义,如实显示"(无)"。
final logPath = '/tmp/$label.err.log';
return {
'label': label,
'platform': Platform.operatingSystem,
'autostart': {
// 非 launchd 平台(Windows/Linux)没有"这个路径",如实给 null
'unit_path': unitPath,
'registered': unitExists,
'loaded': loaded,
'detail_lines': detail,
// 启动语义说明:**只在给不出"进程在跑"证据时**才有值(当前即 Windows)。
// 放在 autostart 里是因为它描述的是"这个自启项装完之后会怎样",
// 而不是进程当下的状态。GUI 侧若不消费此字段,行为与之前完全一致。
'note': probe.available ? null : launchSemanticsNoteFor(currentPlatform),
},
'process': {
'running': probe.running,
'pids': probe.pids,
// 「查不了」≠「没在跑」:GUI 侧据此显示"无法判定"而不是"未运行"。
// 只增字段,不改 `running`/`pids` 的既有语义(GUI 已按这两个字段解析)。
'probe_available': probe.available,
'probe_tool': probe.tool,
'probe_error': probe.error,
},
'config': {
'path': ConfigStore.path,
'exists': File(ConfigStore.path).existsSync(),
'server_url': cfg.serverUrl,
'device_id': cfg.deviceId,
},
'log': {
'path': logPath,
'exists': File(logPath).existsSync(),
},
'installed': {
'agent_path': installedAgentPath,
'agent_exists': File(installedAgentPath).existsSync(),
},
};
}
/// `inkwatch-install status [--json]`
///
/// `--json` 只改变**输出格式**,不新增判据(全部来自 [statusSnapshot])。
/// 存在的意义:GUI 要显示状态就得可解析 —— 让它去正则人话输出,等于把
/// 一份判据解释两遍。
Future<int> _status(String label, {bool json = false}) async {
final snap = await statusSnapshot(label);
if (json) {
// 一行完整 JSON(GUI 侧取最后一行 `{...}` 解析):前面可能夹别的打印,
// 故这里保证"JSON 就是最后一行"。
stdout.writeln(jsonEncode(snap));
return 0;
}
final autostart = (snap['autostart'] as Map).cast<String, Object?>();
final process = (snap['process'] as Map).cast<String, Object?>();
final config = (snap['config'] as Map).cast<String, Object?>();
final log = (snap['log'] as Map).cast<String, Object?>();
stdout.writeln('=== 自启注册 ===');
final unitPath = autostart['unit_path'] as String?;
if (unitPath == null) {
stdout.writeln(' · 本平台无 launchd 注册项(Windows 走注册表 / Linux 走 systemd)');
} else {
stdout.writeln(' plist: ${autostart['registered'] == true ? unitPath : "(不存在,未注册)"}');
}
if (Platform.isMacOS) {
final loaded = autostart['loaded'];
if (loaded != true) {
stdout.writeln(' launchd: 未加载');
} else {
for (final line in (autostart['detail_lines'] as List?) ?? const []) {
stdout.writeln(' launchd: $line');
}
}
}
// 启动语义说明(Windows 上"装了但不会立刻起"这件事必须显式说出来)
final autostartNote = autostart['note'] as String?;
if (autostartNote != null) {
stdout.writeln(' 说明: $autostartNote');
}
stdout.writeln('=== 进程 ===');
final pids = ((process['pids'] as List?) ?? const []).map((e) => '$e').toList();
if (process['probe_available'] == false) {
// 如实说"无法判定",不要拿"未运行"冒充结论(那会让人去重装)
stdout.writeln(' 采集端: 无法判定(${process['probe_error']})');
} else {
stdout.writeln(' 采集端: ${pids.isEmpty ? "未运行" : "pid ${pids.join(",")}"}');
}
stdout.writeln('=== 配置 ===');
stdout.writeln(' ${config['path']}');
stdout.writeln(' ${jsonEncode({'server_url': config['server_url'], 'device_id': config['device_id']})}');
stdout.writeln('=== 最近日志(stderr)===');
final logPath = (log['path'] as String?) ?? '';
if (log['exists'] != true) {
stdout.writeln(' (无)');
} else {
final lines =
(await File(logPath).readAsLines()).where((l) => l.trim().isNotEmpty).toList();
for (final l in lines.length > 12 ? lines.sublist(lines.length - 12) : lines) {
stdout.writeln(' $l');
}
}
return 0;
}
/* ────────────────────────── restart ────────────────────────── */
Future<int> _restart(String label) async {
// 注意顺序:先判平台再执行 `id` —— Windows 上没有 id 命令,
// 原先把它放在平台判断之前,会先抛 ProcessException 而不是给出明确提示。
if (!Platform.isMacOS) {
stderr.writeln('restart 目前仅实现 macOS(launchctl kickstart)');
return 1;
}
final uid = (await Process.run('id', ['-u'])).stdout.toString().trim();
final r = await Process.run('/bin/zsh', ['-c', 'launchctl kickstart -k gui/$uid/$label 2>&1']);
if (r.exitCode != 0) {
stderr.writeln(' ✗ kickstart 失败 → exit ${r.exitCode} '
'${(r.stdout as String).trim()}');
return 1;
}
stdout.writeln(' ✓ kickstart 已提交');
final probe = await _waitForAgent(const Duration(seconds: 3));
if (!probe.available) {
stdout.writeln(' ⚠ 本平台无法判定采集端进程(${probe.error})');
return 0;
}
if (!probe.running) {
stderr.writeln(' ✗ 3 秒内未见到采集端进程');
return 1;
}
stdout.writeln(' 采集端: pid ${probe.pids.join(",")}');
return 0;
}
/* ────────────────────────── verify ────────────────────────── */
/// 自检:把"人工该查的项目"全部自动查掉。
///
/// 存在的意义:`launchctl bootstrap` 需要连上用户 launchd 的 gui 域,
/// 在 CI / 受限环境里可能不可用(本机实测:连最小 plist 也报 I/O error 5)。
/// 但**除 launchd 之外的所有环节都可以自动验证**,其中最有价值的是最后一项:
/// 真的把采集端拉起来、看它有没有连上并上报。
/// 故 verify 不把"launchd 未加载"判为失败,只判为"环境不支持/待人工确认"。
Future<int> _verify(Map<String, Object> opts, String label) async {
var failed = 0;
void check(String name, bool ok, [String detail = '']) {
stdout.writeln(' ${ok ? "✓" : "✗"} $name${detail.isEmpty ? "" : " ← $detail"}');
if (!ok) failed++;
}
final core = InkwatchCore.instance;
stdout.writeln('=== 1. 平台与落位 ===');
check('平台支持后台常驻', core.canRunInBackground, core.platformName);
check('采集端可执行文件存在', File(installedAgentPath).existsSync(), installedAgentPath);
final libPath = p.join(installBinDir, coreLibName);
check('核心库与可执行文件同目录', File(libPath).existsSync(), libPath);
check('核心库架构与本机一致', await _archMatches(libPath), '期望 ${await _hostArch()}');
stdout.writeln('=== 2. 配置 ===');
final cfg = await ConfigStore.load();
check('配置文件存在', File(ConfigStore.path).existsSync(), ConfigStore.path);
check('已配置网关地址', cfg.serverUrl.isNotEmpty, cfg.serverUrl);
check('已配置设备标识', cfg.deviceId.isNotEmpty, cfg.deviceId);
stdout.writeln('=== 3. 自启产物(内容由 Rust 核心生成)===');
// 路径口径来自 `host/app_paths.dart`(唯一真相源)—— 不要在这里再拼一遍
// `~/Library/LaunchAgents/...`(合并前 :184 与 :271 各写了一份,改一处漏一处)。
final plistPath = launchAgentPlistPath(label);
if (plistPath == null) {
stdout.writeln(' · 本平台无 launchd 产物(Windows 走注册表 / Linux 走 systemd),跳过');
} else {
final plistExists = File(plistPath).existsSync();
check('plist 已写入', plistExists, plistPath);
if (plistExists) {
final lint = await Process.run('plutil', ['-lint', plistPath]);
check('plist 语法合法', lint.exitCode == 0, (lint.stdout as String).trim());
final body = await File(plistPath).readAsString();
check('含"仅崩溃重启"语义', body.contains('SuccessfulExit') && body.contains('<false/>'));
check('含快速重启节流(ThrottleInterval=1)',
body.contains('ThrottleInterval') && body.contains('<integer>1</integer>'));
check('可执行文件路径正确', body.contains(installedAgentPath));
}
}
stdout.writeln('=== 4. launchd 注册态 ===');
if (plistPath == null || !Platform.isMacOS) {
stdout.writeln(' · 非 macOS,跳过');
} else {
final uid = (await Process.run('id', ['-u'])).stdout.toString().trim();
final r = await Process.run('/bin/zsh', ['-c', 'launchctl list 2>/dev/null | grep -c "$label" || true']);
final loaded = (r.stdout as String).trim() != '0';
if (loaded) {
final pr = await Process.run('/bin/zsh', ['-c', 'launchctl print gui/$uid/$label 2>&1']);
final pidLine = RegExp(r'\bpid = (\d+)').firstMatch(pr.stdout as String)?.group(1);
check('launchd 已加载', true, pidLine != null ? 'pid=$pidLine' : '未运行');
} else {
stdout.writeln(' · launchd 未加载 —— 本环境可能无法访问用户 launchd 域'
'(连最小 plist 也会报 I/O error 5)。'
'请在**自己的终端**执行下面这条确认:');
stdout.writeln(' launchctl bootstrap gui/\$(id -u) "$plistPath"');
}
}
stdout.writeln('=== 5. 实跑采集端(最关键的一项)===');
final dur = int.tryParse((opts['duration'] as String?) ?? '8') ?? 8;
// 注意:不要加 --quiet —— quiet 模式只输出异常行,会把要断言的日志行屏蔽掉
final run = await Process.run(
installedAgentPath,
['--collector', '--duration', '$dur'],
environment: {
...Platform.environment,
// 清掉代理:否则 localhost 也会走代理导致握手失败
'HTTP_PROXY': '', 'HTTPS_PROXY': '', 'ALL_PROXY': '',
'http_proxy': '', 'https_proxy': '', 'all_proxy': '',
'NO_PROXY': 'localhost,127.0.0.1,::1',
},
workingDirectory: '/', // 模拟 launchd:cwd 不是仓库目录
);
final out = '${run.stdout}${run.stderr}';
check('采集端能加载核心库', out.contains('核心库已加载'), _tail(out));
check('采集端能连上网关', out.contains('已连接网关'));
final frames = RegExp(r'已上报 (\d+) 帧').firstMatch(out)?.group(1);
check('采集端能上报指标', frames != null && int.parse(frames) > 0,
frames != null ? '已上报 $frames 帧' : '未见总计行');
check('上报过程无异常', !out.contains('异常') && !out.contains('连接失败'), _tail(out));
stdout.writeln('');
if (failed == 0) {
stdout.writeln('自检通过(launchd 环节见上)。');
return 0;
}
stdout.writeln('自检发现 $failed 项问题。');
return 1;
}
String _tail(String s) {
final lines = s.trim().split('\n');
return lines.isEmpty ? '' : lines.last;
}
Future<String> _hostArch() async {
final r = await Process.run('uname', ['-m']);
return (r.stdout as String).trim();
}
/// 用 file 命令核对动态库架构是否与本机一致
/// (xdylib 架构错配是真实踩过的坑:x64 Flutter + arm64 库会报 incompatible architecture)
Future<bool> _archMatches(String libPath) async {
if (!File(libPath).existsSync()) return false;
final r = await Process.run('file', ['-b', libPath]);
final desc = r.stdout as String;
final host = await _hostArch(); // arm64 / x86_64
return desc.contains(host);
}
/* ────────────────────────── 工具 ────────────────────────── */
/// 打印一组自启命令的执行情况。
///
/// 判定逻辑在 `host/autostart_runner.dart`(可被单测覆盖),这里只负责呈现。
void _printAutostartOutcome(AutostartRunOutcome outcome) {
for (final c in outcome.commands) {
final text = c.spec.text;
if (c.succeeded) {
stdout.writeln(' ✓ $text${c.stdoutText.isEmpty ? '' : ' → ${c.stdoutText}'}');
} else if (c.isToleratedFailure) {
stdout.writeln(' · $text → exit ${c.exitCode}(可失败,不计错)'
'${c.stderrText.isEmpty ? '' : ' ${c.stderrText}'}');
} else {
stdout.writeln(' ✗ $text → exit ${c.exitCode}'
'${c.stderrText.isEmpty ? '' : ' ${c.stderrText}'}');
}
}
}
/// 轮询等待采集端进程出现,最多等 [timeout]。
///
/// 用 `Stopwatch` 而非 `DateTime.now()`:这里虽是短任务,但"时钟语义"
/// 在本仓已经因墙钟踩过坑(NTP 回拨让重连挂死),不要再引入第二处口径。
Future<AgentProcessProbe> _waitForAgent(Duration timeout) async {
final sw = Stopwatch()..start();
while (true) {
final probe = await probeAgentProcess();
// 探针不可用就立刻返回:再等下去也不会出现"可判定"的状态,
// 只会把"查不了"拖成一次假超时(然后被当成"没起来")。
if (!probe.available || probe.running || sw.elapsed >= timeout) return probe;
await Future<void>.delayed(const Duration(milliseconds: 400));
}
}
/// 进程探测结果 —— **「查不了」与「没在跑」必须分开**。
///
/// 为什么不能只返回 `List<String>`:Linux 精简体 / Windows 上可能根本没有
/// `pgrep`,那时返回空列表就等于宣称"采集端没在跑" —— 用户会去重装,
/// 而真问题是"本平台没有探针"。本项目对"静默把不确定说成确定"零容忍,
/// 故把可用性显式带出来,让上层如实显示"无法判定"。
class AgentProcessProbe {
const AgentProcessProbe._({
required this.available,
required this.pids,
required this.tool,
required this.error,
});
/// 探到了([pids] 为空表示确实没在跑)
factory AgentProcessProbe.found(List<String> pids, String tool) =>
AgentProcessProbe._(available: true, pids: pids, tool: tool, error: '');
/// 本平台无法判定(不是"没在跑")
factory AgentProcessProbe.unavailable(String tool, String error) =>
AgentProcessProbe._(available: false, pids: const [], tool: tool, error: error);
final bool available;
final List<String> pids;
final String tool;
final String error;
bool get running => pids.isNotEmpty;
}
/// 「安装之后进程会不会立刻跑」—— **平台各异,这行字就是如实说明它**。
///
/// 代码依据(自启命令的唯一真相源 `rust-core/src/keepalive.rs` 的 `install_commands`):
/// · macOS `launchctl bootstrap gui/<uid> <plist>` + plist `RunAtLoad=true` → **立刻启动**
/// · Linux `systemctl --user enable --now <svc>`(`--now`) → **立刻启动**
/// · Windows `reg add`(HKCU Run 项) + `schtasks /SC ONLOGON` → **只在下次登录时启动**
/// (Run 项语义就是"登录即起";计划任务是 `/SC ONLOGON`,职责是兜崩溃重启,
/// 不是"立刻跑一次"。整个 Windows 分支里没有任何一条"现在启动进程"的命令。)
///
/// 为什么需要这行字:Windows 上用户点完「安装并启动」,进程**本来就不会起来**。
/// 界面若只写「已安装」,用户会一直等一个不会出现的东西;更糟的是上游曾把
/// "安装返回 0"读成"已在跑",进而停掉自己的采集 —— 两个都没在采却看不出来。
///
/// ⚠️ **这是"平台 → 启动语义"知识的第二份**(第一份在 Rust 的 `install_commands` 里)。
/// 这里不得不写,因为**不能去嗅探命令字符串**(本项目明令禁止:判
/// `cmd.contains('bootout')` 之类,那是把同一份语义写在第二处)。
/// 正解与 `stderr_log_path` 同款:让 `AutoStartPlan` 返回 `starts_immediately: bool`,
/// Dart 只读不算。**本轮未做**(要改 Rust 结构 + FFI 出口,超出 client 边界),
/// 故先集中在这一个函数里,别在别处再写第三份。
String launchSemanticsNoteFor(InkwatchPlatform plat) => switch (plat) {
InkwatchPlatform.windows => '仅在下次登录时启动',
_ => '安装命令本应已使进程立即启动,但本平台无法探测其是否在运行',
};
/// 采集端进程探针(**只认本机这套安装**下的采集端)
///
/// ⚠️ 必须按「落位后的绝对路径 + --collector」匹配,不能只匹配 `inkwatch-agent --collector`:
/// 开发机上常另有一份采集端在跑(例如用户自己装过的正式版),只按名字匹配会把
/// **别人家的进程**算进来,于是
/// · `install` 明明没起来却因为"看到进程"报成功(假成功);
/// · `uninstall` 明明已摘干净却因为"进程仍在运行"返回非零(假失败)。
/// 两种都是"用错事实下判断",比报错更难查。
/// 路径取自 `install_paths.dart`(唯一真相源),这里不再拼任何路径。
Future<AgentProcessProbe> probeAgentProcess() async {
if (Platform.isWindows) {
// TODO(多端前置 / 见 team-lead 多端清单):Windows 没有 pgrep。
// 可行做法是 `powershell -NoProfile -Command "Get-CimInstance Win32_Process | ..."`
// (`tasklist` 只给进程名、拿不到命令行,匹配不到 `--collector`)。
// **本轮刻意不写这条未经真机验证的分支**:写了只会"看起来能判定",
// `-like` 匹配一旦写错就是又一次"用错事实下判断"。故明确报"无法判定"。
return AgentProcessProbe.unavailable('', 'Windows 尚无进程探针(待多端扩面实现)');
}
// 用 RegExp.escape 把路径变成字面量模式:pgrep -f 收的是 ERE,
// 家目录里出现 `.` 之类字符时不该被当通配符(否则仍会匹配到别人)。
final pattern = RegExp.escape('$installedAgentPath --collector');
try {
// **不经 shell**:Linux 上 `/bin/zsh` 通常不存在(合并前的
// `/bin/zsh -c 'pgrep ...'` 在 Linux 上会直接抛 ProcessException,
// 于是 install / uninstall / status 全部崩掉),且路径含空格或引号时
// 也不该交给 shell 去解析。
final r = await Process.run('pgrep', ['-f', pattern]);
if (r.exitCode == 1) {
// pgrep 的 1 = 没有匹配(这是"确实没在跑"的**唯一**可信来源)
return AgentProcessProbe.found(const [], 'pgrep');
}
if (r.exitCode != 0) {
return AgentProcessProbe.unavailable(
'pgrep', 'exit ${r.exitCode}: ${(r.stderr as String).trim()}');
}
final pids = (r.stdout as String)
.split('\n')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
return AgentProcessProbe.found(pids, 'pgrep');
} on ProcessException catch (e) {
return AgentProcessProbe.unavailable('pgrep', '本平台没有可用的 pgrep:${e.message}');
}
}
/// 取「与该可执行文件配套的运行期动态库」。
///
/// 两种产物形态都要覆盖(见 `scripts/build_cli.sh`):
/// ① **bundle 形态**(权威):`<bundle>/bin/<exe>` + `<bundle>/lib/<libs>`
/// ② **扁平形态**(GUI 候选链用的 `build/inkwatch-agent`):exe 与库平铺在同一目录
/// 只认动态库扩展名 —— **不整目录拷贝**:安装目录里每多一个来路不明的文件,
/// 日后排查就多一个干扰项。核心库([coreLibName])由调用方单独落位,这里排除掉,
/// 免得同一次安装把它拷两遍、日志里出现两条"已落位"。
List<File> _runtimeLibsNextTo(String exePath) {
final exeDir = Directory(p.dirname(exePath));
final dirs = <Directory>[
Directory(p.join(exeDir.path, '..', 'lib')), // bundle 布局
exeDir, // 扁平布局
];
const exts = ['.dylib', '.so', '.dll'];
final core = coreLibName;
final seen = <String>{};
final out = <File>[];
for (final dir in dirs) {
if (!dir.existsSync()) continue;
for (final entity in dir.listSync()) {
if (entity is! File) continue;
final lower = entity.path.toLowerCase();
if (!exts.any(lower.endsWith)) continue;
final base = p.basename(entity.path);
if (base == core) continue;
if (!seen.add(base)) continue;
out.add(entity);
}
}
return out;
}
/// 默认设备标识:主机名(与服务端一眼能对上是哪台机器)
String _defaultDeviceId() => Platform.localHostname;
/// 卸载后确认采集端确实退出。
///
/// 返回语义刻意是 `bool?`:
/// `true` 已确认退出
/// `false` 5 秒内仍能看到进程 → 卸载未完成
/// `null` **本平台无法判定**(探针不可用)—— 既不能宣布成功,也不能判失败;
/// 这一支由调用方打印明确的 ⚠,而不是拿不确定当结论。
Future<bool?> _confirmStopped() async {
for (var i = 0; i < 10; i++) {
final probe = await probeAgentProcess();
if (!probe.available) return null;
if (!probe.running) return true;
await Future<void>.delayed(const Duration(milliseconds: 500));
}
return false;
}
Map<String, Object> _parseArgs(List<String> argv) {
final out = <String, Object>{};
for (var i = 0; i < argv.length; i++) {
final a = argv[i];
if (!a.startsWith('--')) continue;
final key = a.substring(2);
final next = i + 1 < argv.length ? argv[i + 1] : null;
if (next != null && !next.startsWith('--')) {
out[key] = next;
i++;
} else {
out[key] = true;
}
}
return out;
}
@@ -3,16 +3,16 @@
/// 为什么要有它:查看端的行为(订阅 → 首屏快照 → 实时流 → 断点 → 离线明示)
/// 在真机上要靠肉眼盯界面才能确认,既慢又没法写进 CI。这个 CLI 把同一条链路
/// 用命令行跑一遍并打印可核对的事实(帧数、服务器时间戳、断点数、被隐藏的指标),
/// 退出码直接反映结果 —— 与 `bin/pmon_agent.dart` 同一思路。
/// 退出码直接反映结果 —— 与 `bin/inkwatch_agent.dart` 同一思路。
///
/// 它同时是一个**独立进程形态**的样例:只 import host/(纯 Dart),
/// 不 import 任何 Flutter,因此可在无图形会话的机器上跑(方案 8.3)。
///
/// 用法:
/// cd pmon/client
/// dart run bin/pmon_viewer.dart --account acct_demo --list
/// dart run bin/pmon_viewer.dart --account acct_demo --device dev_xxx --seconds 10
/// dart run bin/pmon_viewer.dart --account acct_demo --device dev_xxx --pair 123456
/// cd inkwatch/client
/// dart run bin/inkwatch_viewer.dart --account acct_demo --list
/// dart run bin/inkwatch_viewer.dart --account acct_demo --device dev_xxx --seconds 10
/// dart run bin/inkwatch_viewer.dart --account acct_demo --device dev_xxx --pair 123456
///
/// 退出码:
/// 0 订阅后收到至少一帧(链路通)
@@ -25,8 +25,8 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:pmon_client/host/metric_display.dart';
import 'package:pmon_client/host/viewer.dart';
import 'package:inkwatch_client/host/metric_display.dart';
import 'package:inkwatch_client/host/viewer.dart';
const _usage = '''
墨观 查看端验证工具
-393
View File
@@ -1,393 +0,0 @@
/// 墨观 客户端安装器 —— 注册/卸载/查看开机自启
///
/// 为什么安装器是独立 CLI 而不是 GUI 的一部分:
/// macOS 沙箱应用**写不了 `~/Library/LaunchAgents/`**(该目录不在应用容器内)。
/// 故自启注册必须由**非沙箱**进程完成 —— 就是这个 CLI,
/// 由 `dart compile exe` 编译成独立可执行文件后运行。
/// GUI 只负责显示状态。
///
/// 自启产物的**内容**(plist 文本、安装/卸载命令)由 Rust 核心生成
/// (`pmon_keepalive_plan_autostart`),本文件只负责落盘与执行 ——
/// 不要在 Dart 侧再写一份 plist 模板。
///
/// 用法:
/// pmon-install install # 注册自启并立即启动
/// pmon-install install --no-start # 只注册,不立即启动
/// pmon-install verify # 自检(含实跑采集端,受限环境可用)
/// pmon-install status # 查看注册态 / 进程 / 配置 / 日志
/// pmon-install restart # 重启采集端(验证保活用)
/// pmon-install uninstall # 停止并移除自启
/// 可选:`--server <url>` `--device-id <id>` `--label <id>` `--agent-bin <path>`
library;
import 'dart:convert';
import 'dart:io';
import 'package:pmon_client/host/app_config.dart';
import 'package:pmon_client/host/install_paths.dart';
import 'package:pmon_client/host/pmon_core.dart';
import 'package:path/path.dart' as p;
const _defaultLabel = 'com.pmon.collector';
Future<int> main(List<String> argv) async {
if (argv.isEmpty || argv.first == 'help' || argv.first == '--help') {
stdout.writeln('用法: pmon-install <install|uninstall|status|restart> [选项]');
return 0;
}
final cmd = argv.first;
final opts = _parseArgs(argv.skip(1).toList());
final label = (opts['label'] as String?) ?? _defaultLabel;
try {
switch (cmd) {
case 'install':
return await _install(opts, label);
case 'uninstall':
return await _uninstall(label);
case 'status':
return await _status(label);
case 'restart':
return await _restart(label);
case 'verify':
return await _verify(opts, label);
default:
stderr.writeln('未知子命令: $cmd');
return 1;
}
} catch (e) {
stderr.writeln('执行失败: $e');
return 1;
}
}
/* ────────────────────────── install ────────────────────────── */
Future<int> _install(Map<String, Object> opts, String label) async {
final core = PmonCore.instance;
stdout.writeln('墨观 客户端安装(平台 ${core.platformName})');
if (!core.canRunInBackground) {
stderr.writeln('当前平台不支持后台常驻自启,安装取消。');
return 1;
}
// 1) 落位:采集端可执行文件 + 核心库放同一目录
// (core_lib.dart 会先找「可执行文件同目录」,这样自启项无需任何环境变量)
final srcAgent = (opts['agent-bin'] as String?) ??
p.join(Directory.current.path, 'build', 'pmon-agent');
if (!File(srcAgent).existsSync()) {
stderr.writeln('找不到采集端可执行文件: $srcAgent');
stderr.writeln('请先执行: dart compile exe bin/pmon_agent.dart -o build/pmon-agent');
return 1;
}
final srcLib = (opts['core-lib'] as String?) ??
Platform.environment['PMON_CORE_LIB'] ??
p.join(Directory.current.path, '..', 'rust-core', 'target', 'release',
Platform.isMacOS ? 'libpmon_core.dylib' : 'libpmon_core.so');
if (!File(srcLib).existsSync()) {
stderr.writeln('找不到核心库: $srcLib');
stderr.writeln('请先执行: (cd ../rust-core && cargo build --release)');
return 1;
}
await Directory(installBinDir).create(recursive: true);
await File(srcAgent).copy(installedAgentPath);
final libDst = p.join(installBinDir, p.basename(srcLib));
await File(srcLib).copy(libDst);
await Process.run('chmod', ['755', installedAgentPath]);
stdout.writeln(' ✓ 已落位: $installedAgentPath');
stdout.writeln(' ✓ 已落位: $libDst');
// 2) 配置(自启项只传 --collector,网关与设备标识从配置读)
final prev = await ConfigStore.load();
final merged = AppConfig(
serverUrl: (opts['server'] as String?) ?? prev.serverUrl,
deviceId: (opts['device-id'] as String?)?.trim().isNotEmpty == true
? (opts['device-id'] as String).trim()
: (prev.deviceId.isNotEmpty ? prev.deviceId : _defaultDeviceId()),
autoStart: true,
samplingIntervalMs: prev.samplingIntervalMs,
);
await ConfigStore.save(merged);
stdout.writeln(' ✓ 配置: ${ConfigStore.path}');
stdout.writeln(' server=${merged.serverUrl} device_id=${merged.deviceId}');
// 3) 向 Rust 核心要自启计划(产物内容由核心生成,本文件不写模板)
final plan = core.planAutostart(exePath: installedAgentPath, label: label);
if (plan['error'] != null) {
stderr.writeln('核心返回不支持: ${plan['error']['reason']}');
return 1;
}
for (final f in (plan['files'] as List?) ?? []) {
final path = expandTilde(f['path'] as String);
await Directory(p.dirname(path)).create(recursive: true);
await File(path).writeAsString(f['content'] as String, flush: true);
stdout.writeln(' ✓ 已写入: $path');
}
// 4) 执行安装命令(先 bootout 再 bootstrap,避免重复注册报 already loaded)
for (final c in (plan['install_commands'] as List?) ?? []) {
final r = await Process.run('/bin/zsh', ['-c', '$c']);
final msg = (r.stdout as String).trim();
if (r.exitCode != 0) {
final err = (r.stderr as String).trim();
// bootstrap 已加载时 bootout 会返回非零,属预期,不算失败
stdout.writeln(' · $c → exit ${r.exitCode} ${err.isEmpty ? msg : err}');
} else {
stdout.writeln(' ✓ $c${msg.isEmpty ? '' : ' → $msg'}');
}
}
if (opts['no-start'] != true) {
await Future<void>.delayed(const Duration(milliseconds: 800));
final pids = await _agentPids();
if (pids.isEmpty) {
stdout.writeln(' ⚠ 未检测到采集端进程,请查看 status 与日志');
} else {
stdout.writeln(' ✓ 采集端已运行(pid ${pids.join(",")})');
}
}
stdout.writeln('\n安装完成。排查用:pmon-install status');
stdout.writeln('提示:本项为「登录即起 + 崩溃自恢复」,系统重启后同样生效。');
return 0;
}
/* ────────────────────────── uninstall ────────────────────────── */
Future<int> _uninstall(String label) async {
final core = PmonCore.instance;
// 卸载命令同样来自核心(与 plist 配套,避免两处不一致)
final plan = core.planAutostart(exePath: installedAgentPath, label: label);
if (plan['error'] != null) {
stderr.writeln('核心返回: ${plan['error']['reason']}');
return 1;
}
for (final c in (plan['uninstall_commands'] as List?) ?? []) {
final r = await Process.run('/bin/zsh', ['-c', '$c']);
stdout.writeln(' ✓ $c → exit ${r.exitCode}');
}
final ok = await _confirmStopped();
stdout.writeln(ok ? ' ✓ 已停止并移除自启' : ' ⚠ 仍有进程存活,请手动检查');
stdout.writeln('(采集端文件与配置保留在 $installRoot,如不需要可自行删除)');
return 0;
}
/* ────────────────────────── status ────────────────────────── */
Future<int> _status(String label) async {
final uid = (await Process.run('id', ['-u'])).stdout.toString().trim();
stdout.writeln('=== 自启注册 ===');
final plistPath = p.join(homeDir, 'Library', 'LaunchAgents', '$label.plist');
stdout.writeln(' plist: ${File(plistPath).existsSync() ? plistPath : "(不存在,未注册)"}');
if (Platform.isMacOS) {
final r = await Process.run('/bin/zsh', ['-c', 'launchctl print gui/$uid/$label 2>&1']);
final out = r.stdout.toString();
if (r.exitCode != 0) {
stdout.writeln(' launchd: 未加载');
} else {
for (final line in out.split('\n')) {
if (RegExp(r'^\s*(state|pid|program|last exit code|runs) =').hasMatch(line)) {
stdout.writeln(' launchd:${line.trim().replaceFirst(RegExp(r'^'), ' ')}');
}
}
}
}
stdout.writeln('=== 进程 ===');
final pids = await _agentPids();
stdout.writeln(' 采集端: ${pids.isEmpty ? "未运行" : "pid ${pids.join(",")}"}');
stdout.writeln('=== 配置 ===');
final cfg = await ConfigStore.load();
stdout.writeln(' ${ConfigStore.path}');
stdout.writeln(' ${jsonEncode(cfg.toJson())}');
stdout.writeln('=== 最近日志(stderr)===');
final log = File('/tmp/$label.err.log');
if (!log.existsSync()) {
stdout.writeln(' (无)');
} else {
final lines = (await log.readAsLines()).where((l) => l.trim().isNotEmpty).toList();
for (final l in lines.length > 12 ? lines.sublist(lines.length - 12) : lines) {
stdout.writeln(' $l');
}
}
return 0;
}
/* ────────────────────────── restart ────────────────────────── */
Future<int> _restart(String label) async {
final uid = (await Process.run('id', ['-u'])).stdout.toString().trim();
if (!Platform.isMacOS) {
stderr.writeln('restart 目前仅实现 macOS(launchctl kickstart)');
return 1;
}
final r = await Process.run('/bin/zsh', ['-c', 'launchctl kickstart -k gui/$uid/$label 2>&1']);
stdout.writeln(' kickstart → exit ${r.exitCode} ${(r.stdout as String).trim()}');
await Future<void>.delayed(const Duration(seconds: 2));
final pids = await _agentPids();
stdout.writeln(' 采集端: ${pids.isEmpty ? "未运行" : "pid ${pids.join(",")}"}');
return 0;
}
/* ────────────────────────── verify ────────────────────────── */
/// 自检:把"人工该查的项目"全部自动查掉。
///
/// 存在的意义:`launchctl bootstrap` 需要连上用户 launchd 的 gui 域,
/// 在 CI / 受限环境里可能不可用(本机实测:连最小 plist 也报 I/O error 5)。
/// 但**除 launchd 之外的所有环节都可以自动验证**,其中最有价值的是最后一项:
/// 真的把采集端拉起来、看它有没有连上并上报。
/// 故 verify 不把"launchd 未加载"判为失败,只判为"环境不支持/待人工确认"。
Future<int> _verify(Map<String, Object> opts, String label) async {
var failed = 0;
void check(String name, bool ok, [String detail = '']) {
stdout.writeln(' ${ok ? "✓" : "✗"} $name${detail.isEmpty ? "" : " ← $detail"}');
if (!ok) failed++;
}
final core = PmonCore.instance;
stdout.writeln('=== 1. 平台与落位 ===');
check('平台支持后台常驻', core.canRunInBackground, core.platformName);
check('采集端可执行文件存在', File(installedAgentPath).existsSync(), installedAgentPath);
final libPath = p.join(installBinDir,
Platform.isMacOS ? 'libpmon_core.dylib' : (Platform.isWindows ? 'pmon_core.dll' : 'libpmon_core.so'));
check('核心库与可执行文件同目录', File(libPath).existsSync(), libPath);
check('核心库架构与本机一致', await _archMatches(libPath), '期望 ${await _hostArch()}');
stdout.writeln('=== 2. 配置 ===');
final cfg = await ConfigStore.load();
check('配置文件存在', File(ConfigStore.path).existsSync(), ConfigStore.path);
check('已配置网关地址', cfg.serverUrl.isNotEmpty, cfg.serverUrl);
check('已配置设备标识', cfg.deviceId.isNotEmpty, cfg.deviceId);
stdout.writeln('=== 3. 自启产物(内容由 Rust 核心生成)===');
final plistPath = p.join(homeDir, 'Library', 'LaunchAgents', '$label.plist');
final plistExists = File(plistPath).existsSync();
check('plist 已写入', plistExists, plistPath);
if (plistExists) {
final lint = await Process.run('plutil', ['-lint', plistPath]);
check('plist 语法合法', lint.exitCode == 0, (lint.stdout as String).trim());
final body = await File(plistPath).readAsString();
check('含"仅崩溃重启"语义', body.contains('SuccessfulExit') && body.contains('<false/>'));
check('含快速重启节流(ThrottleInterval=1)',
body.contains('ThrottleInterval') && body.contains('<integer>1</integer>'));
check('可执行文件路径正确', body.contains(installedAgentPath));
}
stdout.writeln('=== 4. launchd 注册态 ===');
if (!Platform.isMacOS) {
stdout.writeln(' · 非 macOS,跳过');
} else {
final uid = (await Process.run('id', ['-u'])).stdout.toString().trim();
final r = await Process.run('/bin/zsh', ['-c', 'launchctl list 2>/dev/null | grep -c "$label" || true']);
final loaded = (r.stdout as String).trim() != '0';
if (loaded) {
final pr = await Process.run('/bin/zsh', ['-c', 'launchctl print gui/$uid/$label 2>&1']);
final pidLine = RegExp(r'\bpid = (\d+)').firstMatch(pr.stdout as String)?.group(1);
check('launchd 已加载', true, pidLine != null ? 'pid=$pidLine' : '未运行');
} else {
stdout.writeln(' · launchd 未加载 —— 本环境可能无法访问用户 launchd 域'
'(连最小 plist 也会报 I/O error 5)。'
'请在**自己的终端**执行下面这条确认:');
stdout.writeln(' launchctl bootstrap gui/\$(id -u) "$plistPath"');
}
}
stdout.writeln('=== 5. 实跑采集端(最关键的一项)===');
final dur = int.tryParse((opts['duration'] as String?) ?? '8') ?? 8;
// 注意:不要加 --quiet —— quiet 模式只输出异常行,会把要断言的日志行屏蔽掉
final run = await Process.run(
installedAgentPath,
['--collector', '--duration', '$dur'],
environment: {
...Platform.environment,
// 清掉代理:否则 localhost 也会走代理导致握手失败
'HTTP_PROXY': '', 'HTTPS_PROXY': '', 'ALL_PROXY': '',
'http_proxy': '', 'https_proxy': '', 'all_proxy': '',
'NO_PROXY': 'localhost,127.0.0.1,::1',
},
workingDirectory: '/', // 模拟 launchd:cwd 不是仓库目录
);
final out = '${run.stdout}${run.stderr}';
check('采集端能加载核心库', out.contains('核心库已加载'), _tail(out));
check('采集端能连上网关', out.contains('已连接网关'));
final frames = RegExp(r'已上报 (\d+) 帧').firstMatch(out)?.group(1);
check('采集端能上报指标', frames != null && int.parse(frames) > 0,
frames != null ? '已上报 $frames 帧' : '未见总计行');
check('上报过程无异常', !out.contains('异常') && !out.contains('连接失败'), _tail(out));
stdout.writeln('');
if (failed == 0) {
stdout.writeln('自检通过(launchd 环节见上)。');
return 0;
}
stdout.writeln('自检发现 $failed 项问题。');
return 1;
}
String _tail(String s) {
final lines = s.trim().split('\n');
return lines.isEmpty ? '' : lines.last;
}
Future<String> _hostArch() async {
final r = await Process.run('uname', ['-m']);
return (r.stdout as String).trim();
}
/// 用 file 命令核对动态库架构是否与本机一致
/// (xdylib 架构错配是真实踩过的坑:x64 Flutter + arm64 库会报 incompatible architecture)
Future<bool> _archMatches(String libPath) async {
if (!File(libPath).existsSync()) return false;
final r = await Process.run('file', ['-b', libPath]);
final desc = r.stdout as String;
final host = await _hostArch(); // arm64 / x86_64
return desc.contains(host);
}
/* ────────────────────────── 工具 ────────────────────────── */
/// 采集端进程 pid(排除本进程与 shell)
Future<List<String>> _agentPids() async {
final r = await Process.run('/bin/zsh', ['-c', 'pgrep -f "pmon-agent --collector" || true']);
return (r.stdout as String)
.split('\n')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
}
/// 默认设备标识:主机名(与服务端一眼能对上是哪台机器)
String _defaultDeviceId() => Platform.localHostname;
Future<bool> _confirmStopped() async {
for (var i = 0; i < 10; i++) {
if ((await _agentPids()).isEmpty) return true;
await Future<void>.delayed(const Duration(milliseconds: 500));
}
return false;
}
Map<String, Object> _parseArgs(List<String> argv) {
final out = <String, Object>{};
for (var i = 0; i < argv.length; i++) {
final a = argv[i];
if (!a.startsWith('--')) continue;
final key = a.substring(2);
final next = i + 1 < argv.length ? argv[i + 1] : null;
if (next != null && !next.startsWith('--')) {
out[key] = next;
i++;
} else {
out[key] = true;
}
}
return out;
}
+63
View File
@@ -0,0 +1,63 @@
// 中继客户端自检 CLI —— 用**客户端自己的中继函数**打真网关,验证「最后一公里」。
//
// 为什么要有它:服务端 relay 的 30 项单测覆盖不到"客户端的 open/upload/download
// 三个函数连真网关能不能用",而这段恰恰是历史批注「中继端到端未打通」的疑点。
// 本 CLI 被 tests/relay-client-e2e.js 调用,输出一行 JSON 供断言。
//
// 用法:
// dart run bin/relay_check.dart --server http://127.0.0.1:3000 --size 8192
// 输出(最后一行):
// {"ok":true,"bytes":8192,"sha":"…","ms":123}
// {"ok":false,"reason":"…"}
import 'dart:convert';
import 'dart:io';
import 'package:inkwatch_client/host/lan_service.dart';
Future<void> main(List<String> args) async {
var server = 'http://127.0.0.1:3000';
var size = 8192;
for (var i = 0; i < args.length - 1; i++) {
if (args[i] == '--server') server = args[i + 1];
if (args[i] == '--size') size = int.tryParse(args[i + 1]) ?? size;
}
final sw = Stopwatch()..start();
final src = File('/tmp/relay_check_src.bin');
final dst = File('/tmp/relay_check_dst.bin');
try {
if (dst.existsSync()) dst.deleteSync();
final payload = List<int>.generate(size, (i) => (i * 31 + 7) % 256);
src.writeAsBytesSync(payload);
final ch = await openLanRelay(
serverBase: server,
deviceId: 'relay-check-${DateTime.now().millisecondsSinceEpoch}',
deviceToken: 'relay-check-token',
size: size,
);
// 下载端先挂起(服务端 GET 会等到上传端对接)
final downF = downloadViaRelay(channel: ch.download, savePath: dst.path);
await Future<void>.delayed(const Duration(milliseconds: 150));
await uploadViaRelay(channel: ch.upload, path: src.path);
final got = await downF;
final bytes = dst.readAsBytesSync();
final same = bytes.length == size;
sw.stop();
stdout.writeln(jsonEncode({
'ok': same,
'bytes': bytes.length,
'sha': got.sha256,
'ms': sw.elapsedMilliseconds,
if (!same) 'reason': '字节数不符',
}));
exit(same ? 0 : 1);
} catch (e) {
sw.stop();
stdout.writeln(jsonEncode({'ok': false, 'reason': '$e'}));
exit(1);
} finally {
if (src.existsSync()) src.deleteSync();
}
}
+506 -44
View File
@@ -39,6 +39,38 @@ Future<HttpReply> defaultAuthedGet(Uri url, String token) async {
}
}
/// 带鉴权的"任意方法"请求(POST / PATCH / DELETE 带 body)。
///
/// 与 [AuthedGet] 分开的理由:Dart 的函数类型一旦改签名,所有既有假实现(测试里的
/// 闭包)全部失效。新加一个类型,老代码一行不用动。
typedef AuthedSend = Future<HttpReply> Function(
String method,
Uri url,
String token,
Map<String, Object?>? body,
);
Future<HttpReply> defaultAuthedSend(
String method,
Uri url,
String token,
Map<String, Object?>? body,
) async {
final c = HttpClient()..connectionTimeout = const Duration(seconds: 5);
try {
final req = await c.openUrl(method, url);
req.headers.set('authorization', 'Bearer $token');
if (body != null) {
req.headers.contentType = ContentType.json;
req.write(jsonEncode(body));
}
final res = await req.close().timeout(const Duration(seconds: 8));
return HttpReply(res.statusCode, await res.transform(utf8.decoder).join());
} finally {
c.close(force: true);
}
}
/// 一次认证操作的结果
class AuthResult {
const AuthResult({
@@ -83,7 +115,10 @@ class MergedDevice {
if (j is! Map) return null;
final id = j['device_id'] as String?;
if (id == null || id.isEmpty) return null;
return MergedDevice(deviceId: id, frames: (j['frames'] as num?)?.toInt() ?? 0);
return MergedDevice(
deviceId: id,
frames: (j['frames'] as num?)?.toInt() ?? 0,
);
}
}
@@ -93,17 +128,40 @@ class AccountInfo {
required this.userId,
required this.email,
this.displayName,
this.plan = 'free',
this.deviceQuota = 3,
this.ownedDeviceCount = 0,
this.plan,
this.planName,
this.deviceQuota,
this.ownedDeviceCount,
this.emailVerified = false,
});
final String userId;
final String email;
final String? displayName;
final String plan;
final int deviceQuota;
final int ownedDeviceCount;
/// 档位**机器键**(`free` / `pro` / `team`)。
///
/// 只可用于逻辑分支(如"免费档隐藏审计入口"),**绝不允许进任何用户可见文本** ——
/// 中文档位名只认 [planName](服务端下发的展示名)。
final String? plan;
/// 档位中文名,服务端 `/me` **顶层** `plan_name` 字段。
///
/// 为什么单独一个字段:`plan` 是不可展示的机器键;而档位中文名不能由客户端
/// 按配额反推(`free_device_limit` 支持实例级覆盖,反推必然算错)。
/// **不给默认值**:缺失即 null,界面显示「未知档位」。
final String? planName;
/// 设备配额 / 已拥有设备数。
///
/// **可空是刻意的**:服务端没给就是 null,界面据此**整行不渲染**。
/// 若在这里填 `?? 3` / `?? 0`,界面会显示一个"看着正常、实则编造"的数字,
/// 且无法与真实值区分 —— 本项目明令禁止在解析层填默认值。
final int? deviceQuota;
final int? ownedDeviceCount;
/// 邮箱是否已验证。未验证不是"不能用":额度被收紧到 1 台设备,且不能邀请他人。
final bool emailVerified;
static AccountInfo? fromJson(Map<String, dynamic> j) {
final id = j['user_id'];
@@ -112,17 +170,94 @@ class AccountInfo {
userId: '$id',
email: (j['email'] as String?) ?? '',
displayName: j['display_name'] as String?,
plan: (j['plan'] as String?) ?? 'free',
deviceQuota: (j['device_quota'] as num?)?.toInt() ?? 3,
ownedDeviceCount: (j['owned_device_count'] as num?)?.toInt() ?? 0,
// 缺字段就是 null —— 见 [plan] 的注释:机器键不许有默认值兜底
plan: j['plan'] as String?,
// 配额同理:缺失即 null(界面整行不渲染),不许 `?? 3` / `?? 0`
deviceQuota: (j['device_quota'] as num?)?.toInt(),
ownedDeviceCount: (j['owned_device_count'] as num?)?.toInt(),
emailVerified: j['email_verified'] == true,
);
}
String get label => displayName?.isNotEmpty == true ? displayName! : email;
}
/// 服务端错误码 → 用户可读文案。
/// 一条登录记录(`GET /api/accounts/sessions`)。
///
/// [id] 是令牌哈希的**前 12 位**,不是令牌本身 —— 服务端不会把可用凭据再发一遍。
class SessionInfo {
const SessionInfo({
required this.id,
required this.lastUsedAt,
required this.createdAt,
this.deviceLabel,
this.current = false,
});
final String id;
final DateTime lastUsedAt;
final DateTime createdAt;
/// 客户端自报的机型/平台,仅用于辨认;服务端不拿它做判定
final String? deviceLabel;
final bool current;
static SessionInfo? fromJson(Map<String, dynamic> j) {
final id = j['id'];
if (id is! String || id.isEmpty) return null;
final lu = (j['last_used_at'] as num?)?.toInt();
final cr = (j['created_at'] as num?)?.toInt();
return SessionInfo(
id: id,
lastUsedAt: DateTime.fromMillisecondsSinceEpoch(lu ?? 0),
createdAt: DateTime.fromMillisecondsSinceEpoch(cr ?? 0),
deviceLabel: (j['device_label'] as String?)?.trim().isEmpty == true
? null
: j['device_label'] as String?,
current: j['current'] == true,
);
}
String get displayLabel {
final d = deviceLabel;
if (d != null && d.isNotEmpty) return d;
return current ? '当前设备' : '未标注设备';
}
}
/// 设备成员(`GET /api/devices/:id/members`)。
class MemberInfo {
const MemberInfo({
required this.accountId,
required this.role,
this.email,
this.displayName,
});
final String accountId;
final String role;
final String? email;
final String? displayName;
static MemberInfo? fromJson(Map<String, dynamic> j) {
final id = j['account_id'];
if (id == null) return null;
return MemberInfo(
accountId: '$id',
role: (j['role'] as String?) ?? 'viewer',
email: j['email'] as String?,
displayName: j['display_name'] as String?,
);
}
String get label {
if (displayName != null && displayName!.isNotEmpty) return displayName!;
if (email != null && email!.isNotEmpty) return email!;
return accountId;
}
}
/// 服务端错误码 → 用户可读文案。///
/// 与配对文案分家的原因:账号链路的错误语义完全不同("口令不对"和"配对码不对"
/// 不是一回事),但**共用同一套翻译函数会漏掉语义**;故这里各自成表,
/// 未收录的码再回落到 `pairingErrorText` 的兜底分支(服务端 reason 透传)。
@@ -141,9 +276,7 @@ String accountErrorText(String? code, [String? reason]) {
case 'E_LOGIN_REQUIRED':
return '该操作需要先登录账号。';
case 'E_DEVICE_TAKEN':
return reason != null && reason.isNotEmpty
? reason
: '这台设备已归属其它账号,无法认领。';
return reason != null && reason.isNotEmpty ? reason : '这台设备已归属其它账号,无法认领。';
case 'E_DEVICE_NOT_FOUND':
// 认领路径上出现它只可能是**服务端版本较旧**:旧版要求设备先在库,
// 而 `REQUIRE_DEVICE_TOKEN=1` 时 hello 会被令牌校验提前拦下(设备永远入不了库)
@@ -164,26 +297,29 @@ class AccountApi {
RestGet? restGet,
RestPost? restPost,
AuthedGet? authedGet,
}) : _restGet = restGet ?? defaultRestGet,
_restPost = restPost ?? defaultRestPost,
_authedGet = authedGet ?? defaultAuthedGet;
AuthedSend? authedSend,
}) : _restGet = restGet ?? defaultRestGet,
_restPost = restPost ?? defaultRestPost,
_authedGet = authedGet ?? defaultAuthedGet,
_authedSend = authedSend ?? defaultAuthedSend;
final String businessUrl;
final RestGet _restGet;
final RestPost _restPost;
final AuthedGet _authedGet;
final AuthedSend _authedSend;
/// 注册并自动登录(服务端注册即签发会话,省一次往返)
Future<AuthResult> register({
required String email,
required String secret,
String? displayName,
}) =>
_auth('/api/accounts/register', {
'email': email,
'secret': secret,
if (displayName != null && displayName.isNotEmpty) 'display_name': displayName,
});
}) => _auth('/api/accounts/register', {
'email': email,
'secret': secret,
if (displayName != null && displayName.isNotEmpty)
'display_name': displayName,
});
Future<AuthResult> login({required String email, required String secret}) =>
_auth('/api/accounts/login', {'email': email, 'secret': secret});
@@ -192,7 +328,9 @@ class AccountApi {
try {
final res = await _restPost(Uri.parse('$businessUrl$path'), body);
final j = res.json;
if ((res.status == 200 || res.status == 201) && j != null && j['token'] is String) {
if ((res.status == 200 || res.status == 201) &&
j != null &&
j['token'] is String) {
return AuthResult(
ok: true,
token: j['token'] as String,
@@ -203,21 +341,23 @@ class AccountApi {
}
final code = j?['error'] as String?;
final msg = j?['message'] as String?;
return AuthResult(ok: false, errorCode: code, message: accountErrorText(code, msg));
} catch (e) {
return AuthResult(
ok: false,
message: '无法连接业务服务($businessUrl):$e',
errorCode: code,
message: accountErrorText(code, msg),
);
} catch (e) {
return AuthResult(ok: false, message: '无法连接业务服务($businessUrl):$e');
}
}
/// 登出(服务端把该令牌标记吊销,立即失效;失败也要让本地清掉令牌)
Future<bool> logout(String token) async {
try {
final res = await _restPost(Uri.parse('$businessUrl/api/accounts/logout'), {
'token': token,
});
final res = await _restPost(
Uri.parse('$businessUrl/api/accounts/logout'),
{'token': token},
);
return res.status == 200;
} catch (_) {
return false;
@@ -227,7 +367,9 @@ class AccountApi {
Future<AccountInfo?> me(String token) async {
try {
final res = await _authedGet(
Uri.parse('$businessUrl/api/accounts/me'), token);
Uri.parse('$businessUrl/api/accounts/me'),
token,
);
if (res.status != 200) return null;
final j = res.json;
final acc = j?['account'];
@@ -239,8 +381,19 @@ class AccountApi {
email: info.email,
displayName: info.displayName,
plan: info.plan,
// ★ 档位中文名只从这里取,且**是顶层 `plan_name`**,不是 `plan.name` ——
// 服务端把机器键 `plan` 放在 `account` 里,中文展示名另挂顶层。
// 读不到就是 null(界面显示「未知档位」):**绝不回落机器键**,
// 也绝不按配额反推(`free_device_limit` 支持实例级覆盖)。
planName: j?['plan_name'] as String?,
// 下面两个 `?? info.xxx` **不是**"缺失时编一个值":
// 它只在"`account` 视图里也带了同名值"时才取它(顶层为准),
// 两处都没有 → null,界面整行不渲染。
// ⚠️ 别把它和 `?? 3` / `?? 0` 归成一类而一并删掉。
deviceQuota: (j?['device_quota'] as num?)?.toInt() ?? info.deviceQuota,
ownedDeviceCount: (j?['owned_device_count'] as num?)?.toInt() ?? 0,
ownedDeviceCount: (j?['owned_device_count'] as num?)?.toInt() ??
info.ownedDeviceCount,
emailVerified: info.emailVerified,
);
} catch (_) {
return null;
@@ -252,18 +405,37 @@ class AccountApi {
///
/// 返回 (设备列表, 在线态是否可信, 请求是否成功, 身份类型)。
/// `identity` 让界面能说清"当前是登录态还是仅授权态"。
Future<({List<Map<String, dynamic>> devices, bool presenceKnown, bool ok, String identity})>
myDevices({String? token, String? legacyAccountId}) async {
Future<
({
List<Map<String, dynamic>> devices,
bool presenceKnown,
bool ok,
String identity,
})
>
myDevices({String? token, String? legacyAccountId}) async {
try {
if (token != null && token.isNotEmpty) {
final res = await _authedGet(
Uri.parse('$businessUrl/api/accounts/me/devices'), token);
Uri.parse('$businessUrl/api/accounts/me/devices'),
token,
);
if (res.status == 401) {
// 令牌失效:如实上报,让界面提示重新登录(而不是显示"没有设备")
return (devices: const <Map<String, dynamic>>[], presenceKnown: false, ok: false, identity: 'expired');
return (
devices: const <Map<String, dynamic>>[],
presenceKnown: false,
ok: false,
identity: 'expired',
);
}
if (res.status != 200) {
return (devices: const <Map<String, dynamic>>[], presenceKnown: false, ok: false, identity: 'error');
return (
devices: const <Map<String, dynamic>>[],
presenceKnown: false,
ok: false,
identity: 'error',
);
}
final j = res.json!;
return (
@@ -274,14 +446,27 @@ class AccountApi {
);
}
// 兼容路径(未登录):与旧客户端行为一致
final res = await _restGet(Uri.parse(
'$businessUrl/api/accounts/${Uri.encodeComponent(legacyAccountId ?? '')}/devices'));
final res = await _restGet(
Uri.parse(
'$businessUrl/api/accounts/${Uri.encodeComponent(legacyAccountId ?? '')}/devices',
),
);
if (res.status != 200) {
return (devices: const <Map<String, dynamic>>[], presenceKnown: false, ok: false, identity: 'anon');
return (
devices: const <Map<String, dynamic>>[],
presenceKnown: false,
ok: false,
identity: 'anon',
);
}
final j = res.json;
if (j == null) {
return (devices: const <Map<String, dynamic>>[], presenceKnown: false, ok: false, identity: 'anon');
return (
devices: const <Map<String, dynamic>>[],
presenceKnown: false,
ok: false,
identity: 'anon',
);
}
return (
devices: _deviceList(j['devices']),
@@ -290,7 +475,12 @@ class AccountApi {
identity: 'legacy',
);
} catch (_) {
return (devices: const <Map<String, dynamic>>[], presenceKnown: false, ok: false, identity: 'error');
return (
devices: const <Map<String, dynamic>>[],
presenceKnown: false,
ok: false,
identity: 'error',
);
}
}
@@ -314,12 +504,18 @@ class AccountApi {
}) async {
try {
final res = await _restPost(
Uri.parse('$businessUrl/api/devices/${Uri.encodeComponent(deviceId)}/token'),
Uri.parse(
'$businessUrl/api/devices/${Uri.encodeComponent(deviceId)}/token',
),
{'token': token},
);
final j = res.json;
if (res.status == 200 && j?['device_token'] is String) {
return (ok: true, deviceToken: j!['device_token'] as String, message: null);
return (
ok: true,
deviceToken: j!['device_token'] as String,
message: null,
);
}
final code = j?['error'] as String?;
return (
@@ -371,4 +567,270 @@ class AccountApi {
return AuthResult(ok: false, message: '认领失败:$e');
}
}
// ══════════ 用户机制(2026-10-30):会话 / 改密 / 验证 / 注销 / 成员 ══════════
//
// 统一返回 `(ok, message)` 这种记录类型而不是抛异常:这些操作全部是
// "用户点了按钮"的路径,失败必须变成一句人话,而不是让界面白屏。
/// 我的登录设备列表。失败返回空列表 —— 界面据此显示"读不到",不假装"没有登录"。
Future<List<SessionInfo>> sessions(String token) async {
try {
final res = await _authedGet(
Uri.parse('$businessUrl/api/accounts/sessions'),
token,
);
if (res.status != 200) return const [];
final raw = res.json?['sessions'];
final out = <SessionInfo>[];
if (raw is List) {
for (final e in raw) {
if (e is Map<String, dynamic>) {
final s = SessionInfo.fromJson(e);
if (s != null) out.add(s);
}
}
}
return out;
} catch (_) {
return const [];
}
}
Future<({bool ok, String? message})> kickSession(String token, String id) =>
_send(
'DELETE',
'/api/accounts/sessions/${Uri.encodeComponent(id)}',
token,
null,
okText: '已退出该设备',
);
/// 退出其它设备(保留当前这条 —— 否则用户会被自己踢下线)
Future<({bool ok, String? message, int revoked})> logoutOtherSessions(
String token,
) async {
final r = await _sendRaw(
'DELETE',
'/api/accounts/sessions?keep_current=1',
token,
null,
);
final revoked = (r.$1?['revoked'] as num?)?.toInt() ?? 0;
return (ok: r.$2, message: r.$3, revoked: revoked);
}
Future<({bool ok, String? message})> changeSecret(
String token,
String oldSecret,
String newSecret,
) => _send('POST', '/api/accounts/secret', token, {
'old_secret': oldSecret,
'new_secret': newSecret,
}, okText: '口令已修改,其它设备已退出');
Future<({bool ok, String? message, bool alreadyVerified, bool delivered})>
requestVerify(String token) async {
final r = await _sendRaw(
'POST',
'/api/accounts/verify/request',
token,
const {},
);
return (
ok: r.$2,
message: r.$3,
alreadyVerified: r.$1?['already_verified'] == true,
// 发信是否真的成功:outbox/console 模式下服务端会如实回 false,
// 界面必须照说("邮件已发出"这类话不能凭乐观猜)
delivered: r.$1?['delivered'] == true,
);
}
/// 验证码走邮件;这里只负责提交(不需要登录态,令牌本身即凭据)
Future<({bool ok, String? message})> confirmVerify(String code) => _send(
'POST',
'/api/accounts/verify/confirm',
null,
{'token': code},
okText: '邮箱已验证',
);
/// 申请重置(**不需要登录**)。服务端对不存在的邮箱同样返回成功 —— 防账号枚举。
Future<({bool ok, String? message})> requestReset(String email) => _send(
'POST',
'/api/accounts/reset/request',
null,
{'email': email},
okText: '如果该邮箱存在,重置邮件已发出(15 分钟内有效)',
);
Future<({bool ok, String? message})> confirmReset(
String code,
String newSecret,
) => _send('POST', '/api/accounts/reset/confirm', null, {
'token': code,
'secret': newSecret,
}, okText: '口令已重置,所有设备已退出');
/// 注销(30 天冷静期)。返回的 [unownedDevices] 必须展示给用户 ——
/// "你的设备会变成无人可看"是这个操作的真实后果,不能说成"数据已清空"。
Future<({bool ok, String? message, List<String> unownedDevices})>
deleteAccount(String token, String secret) async {
final r = await _sendRaw('DELETE', '/api/accounts/me', token, {
'secret': secret,
});
final raw = r.$1?['unowned_devices'];
final list = <String>[];
if (raw is List) {
for (final e in raw) {
if (e is String) list.add(e);
}
}
return (ok: r.$2, message: r.$3, unownedDevices: list);
}
/// 设备成员列表。`myRole` 决定界面要不要显示"邀请"按钮。
Future<
({
bool ok,
String? myRole,
String? ownerEmail,
List<MemberInfo> members,
String? message,
})
>
members(String token, String deviceId) async {
try {
final res = await _authedGet(
Uri.parse(
'$businessUrl/api/devices/${Uri.encodeComponent(deviceId)}/members',
),
token,
);
final j = res.json;
if (res.status != 200) {
return (
ok: false,
myRole: null,
ownerEmail: null,
members: const <MemberInfo>[],
message: accountErrorText(
j?['error'] as String?,
j?['message'] as String?,
),
);
}
final raw = j?['members'];
final list = <MemberInfo>[];
if (raw is List) {
for (final e in raw) {
if (e is Map<String, dynamic>) {
final m = MemberInfo.fromJson(e);
if (m != null) list.add(m);
}
}
}
final owner = j?['owner'];
return (
ok: true,
myRole: j?['my_role'] as String?,
ownerEmail: owner is Map ? owner['email'] as String? : null,
members: list,
message: null,
);
} catch (e) {
return (
ok: false,
myRole: null,
ownerEmail: null,
members: const <MemberInfo>[],
message: '读取成员失败:$e',
);
}
}
/// 邀请成员。服务端有两种结果:对方已注册 → 直接授权;未注册 → 发邀请码。
Future<({bool ok, String? mode, String? message})> inviteMember(
String token,
String deviceId,
String email,
String role,
) async {
final r = await _sendRaw(
'POST',
'/api/devices/${Uri.encodeComponent(deviceId)}/members',
token,
{'email': email, 'role': role},
);
return (ok: r.$2, mode: r.$1?['mode'] as String?, message: r.$3);
}
Future<({bool ok, String? message})> setMemberRole(
String token,
String deviceId,
String accountId,
String role,
) => _send(
'PATCH',
'/api/devices/${Uri.encodeComponent(deviceId)}/members/${Uri.encodeComponent(accountId)}',
token,
{'role': role},
okText: '角色已更新',
);
Future<({bool ok, String? message})> removeMember(
String token,
String deviceId,
String accountId,
) => _send(
'DELETE',
'/api/devices/${Uri.encodeComponent(deviceId)}/members/${Uri.encodeComponent(accountId)}',
token,
null,
okText: '已移除该成员',
);
Future<({bool ok, String? message})> acceptInvite(
String token,
String code,
) => _send('POST', '/api/invites/accept', token, {
'token': code,
}, okText: '已接受邀请');
/// 便捷包装:把 body 交给 [_sendRaw],失败时按错误码翻译成人话
Future<({bool ok, String? message})> _send(
String method,
String path,
String? token,
Map<String, Object?>? body, {
String okText = '已完成',
}) async {
final r = await _sendRaw(method, path, token, body);
if (r.$2) return (ok: true, message: okText);
return (ok: false, message: r.$3);
}
/// 返回 (响应 JSON, 是否成功, 失败文案)。未登录路径走 [_restPost],
/// 登录路径走 [_authedSend] —— 令牌永远只走 Authorization 头。
Future<(Map<String, dynamic>?, bool, String?)> _sendRaw(
String method,
String path,
String? token,
Map<String, Object?>? body,
) async {
try {
final uri = Uri.parse('$businessUrl$path');
final HttpReply res = (token == null || token.isEmpty)
? await _restPost(uri, body ?? const {})
: await _authedSend(method, uri, token, body);
final j = res.json;
final ok = res.status >= 200 && res.status < 300;
if (ok) return (j, true, null);
final code = j?['error'] as String?;
return (j, false, accountErrorText(code, j?['message'] as String?));
} catch (e) {
return (null, false, '无法连接业务服务:$e');
}
}
}
+248
View File
@@ -0,0 +1,248 @@
/// 采集端单实例锁 —— 防「GUI 内采集」与「常驻 agent」双写同一设备
///
/// ── 这个文件要防的是什么 ──────────────────────────────────────────
/// 桌面端有两种合法的采集形态:
/// · GUI 内建采集(`main.dart` 的 ClientAgent,关窗即停)
/// · 无界面常驻进程(`bin/inkwatch_agent.dart`,由 LaunchAgent/systemd 拉起)
///
/// 两者都往同一个 `device_id` 上报。一旦同时运行:
/// 各自的 seq 都从 1 开始独立递增 → 服务端的 seq 去重窗口
/// (按 deviceId+sessionId 滚动)会把**两条流混在一起判重** →
/// 要么把正常帧误判成重复而丢帧,要么让重复帧漏网而入库。
/// 后果是历史曲线出现台阶与毛刺,且**不报任何错**。
///
/// 实测确认过:加这个锁之前,代码里**没有任何**跨进程保护
/// (`InkwatchCore.instance` 只是进程内单例,对另一个进程毫无约束)。
///
/// ── 职责划分 ────────────────────────────────────────────────────
/// · agent 侧:启动时 `tryAcquire()`,拿不到就**正常退出(exit 0)**。
/// 注意必须是 0:launchd 的 `KeepAlive.SuccessfulExit=false` 语义下,
/// 非零退出会被当成崩溃而触发重启 → 两个进程互踢,无限重启循环。
/// · GUI 侧:启动采集前查**两层**判据,命中任一层就不启动自己的采集
/// (只做查看),并明确告知用户 —— 而不是静默地少采一半数据,更不是
/// 静默地双写:
/// ① 文件锁 [`AgentLock.isHeldByOther`] —— 管**同形态**(两个 GUI、两个 agent)
/// ② 进程探测 [`probeInstalledAgent`] —— **仅 macOS**,管跨形态。
/// 原因见下:沙箱 GUI 与 agent 锁的**不是同一个文件**,①在那里恒为 false。
///
/// ── 为什么用文件锁而不是别的 ──────────────────────────────────────
/// · 不依赖平台:`dart:io` 的 `FileLock` 在 macOS/Linux/Windows 都可用,
/// 不必为三个平台各写一套(命名管道 / 互斥体 / pid 文件)。
/// · 不会因进程被 kill -9 而留下"僵尸锁":记录锁由内核随 fd 释放。
/// pid 文件方案做不到这点 —— 死进程的 pid 文件会永久阻塞下一次启动。
///
/// ⚠️ 已知边界:POSIX 的记录锁是**按进程**的,同进程内重复加锁会成功。
/// 所以本模块只能判"**别的**进程是否在采集",判不了"本进程自己有没有"。
/// 这正好是要判的场景(GUI 判外部 agent),但也意味着**单测覆盖不到跨进程冲突**
/// —— 那条路径只能靠 GUI 与 agent 的实际联调验证,见
/// `client/test/agent_lock_test.dart` 的说明。
library;
import 'dart:io';
import 'package:path/path.dart' as p;
import 'app_config.dart';
import 'app_paths.dart';
/// 锁文件路径。
///
/// 放在 `ConfigStore.dir`(与 config.json 同目录)—— 不另算一份路径。
/// 本项目"同一份路径写两处"已经翻车多次,安装路径该由一处派生。
String get agentLockPath => p.join(ConfigStore.dir, 'agent.lock');
/// 采集端单实例锁。持有期间其它进程拿不到。
class AgentLock {
AgentLock._(this._raf, this.path);
final RandomAccessFile _raf;
final String path;
/// 尝试独占获取。**非阻塞**:已被别的进程持有则返回 `null`,不抛异常、不等待。
///
/// 用 `FileLock.exclusive`(非阻塞)而非 `blockingExclusive`:
/// 后者会让第二个采集进程一直挂着等,用户看到的是"卡住不动"。
static Future<AgentLock?> tryAcquire({String? path}) async {
final target = path ?? agentLockPath;
try {
await File(target).parent.create(recursive: true);
// append 模式:不截断已有内容(锁文件本身不承载数据,
// 但用 write 会因为 O_TRUNC 在每次检测时把文件清空)
final raf = await File(target).open(mode: FileMode.append);
try {
await raf.lock(FileLock.exclusive);
} on FileSystemException {
await raf.close();
return null;
}
return AgentLock._(raf, target);
} on FileSystemException {
// 目录建不出来 / 文件打不开(权限问题)→ 视同"拿不到",
// 由调用方决定降级策略;不要在这里抛,否则启动路径直接崩。
return null;
}
}
/// 是否有**别的进程**正在采集。
///
/// 能立刻拿到独占锁 → 没人持有 → `false`。拿不到 → 有人在采 → `true`。
/// 拿完立刻释放,不改变任何状态。
static Future<bool> isHeldByOther({String? path}) async {
final target = path ?? agentLockPath;
final f = File(target);
if (!await f.exists()) return false;
RandomAccessFile? raf;
try {
raf = await f.open(mode: FileMode.append);
await raf.lock(FileLock.exclusive);
await raf.unlock();
return false;
} on FileSystemException {
return true;
} finally {
try {
await raf?.close();
} catch (_) {
// 关闭失败不影响判定结果
}
}
}
/// 释放锁。进程退出时内核也会释放,这里显式释放是为了
/// 「同一进程内先停后开」的场景(GUI 里 Stop → Start)。
Future<void> release() async {
try {
await _raf.unlock();
} catch (_) {
// 已被释放(或 fd 已失效)→ 不必再报
}
try {
await _raf.close();
} catch (_) {
// 同上
}
}
}
/* ─────────────── 第二层:进程探测(仅 macOS 沙箱需要)─────────────── */
/// 探针结论 —— **四态**,因为"不适用"与"没在跑"必须分开(见 [probeInstalledAgent])。
enum AgentPresence {
/// 确认有采集端进程在跑
running,
/// 确认没有(pgrep 退出码 1 是**唯一**可信来源)
notRunning,
/// 查不了(探针不存在 / 出错 / 路径算不出来)——不等于没在跑
undetermined,
/// 这平台不需要这层判断(锁已经够了)
notApplicable,
}
/// 进程执行器(可注入:测试不该真起进程)
typedef ProbeRunner = Future<ProcessResult> Function(
String executable, List<String> arguments);
/// 探针可执行文件的**绝对路径**。
///
/// 不用 `pgrep` 让系统找:沙箱应用的 `PATH` 可能被裁剪,靠 PATH 找会变成
/// "有时查得到有时查不到"的不确定性来源。
const String _pgrepPath = '/usr/bin/pgrep';
/// 用进程探测判断"常驻采集是否在跑"——**只对 macOS 有意义**。
///
/// ── 为什么锁不够(这是本函数存在的唯一理由)──────────────────────
/// 锁的路径是 `ConfigStore.dir/agent.lock`,而 `ConfigStore.dir` 来自
/// `homeFor(Platform.environment['HOME'])`。macOS 沙箱应用的 `HOME` 指向
/// 应用容器(`~/Library/Containers/com.inkwatch.eink/Data`),而 agent 由
/// launchd 拉起、`HOME` 是真实家目录:
///
/// GUI → 容器/agent.lock ← 自己锁自己,agent 从不碰
/// agent → 真实家目录/agent.lock
///
/// **两个进程锁的是两个不同文件,谁也不挡谁** → `isHeldByOther()` 在沙箱 GUI
/// 里恒为 false → 双写是必然,不是可能。故补一道"查进程":
/// 进程是系统级事实,不受沙箱路径分叉影响。
///
/// ── 另外两种形态不需要它 ────────────────────────────────────────
/// macOS 非沙箱、Windows、Linux 上 GUI 与 agent 的 `HOME` 相同 → 锁是同一文件,
/// 锁已经够用。**所以这里只对 macOS 生效**:`pgrep` 在 Windows 上根本不存在,
/// 把"一个平台的问题"推广成"三平台都改成探针"只会新增两处不必要的失败面。
///
/// ── [agentPathFromCli] 为什么必须来自 `inkwatch-install status --json` ────
/// 沙箱 GUI **算不出** agent 的真实路径:`installBinDirFor(macOS, homeDir)` 里
/// 的 `homeDir` 在沙箱里就是容器路径,会算出 `容器/.../bin/inkwatch-agent`(永远
/// 匹配不到)。而 CLI 是非沙箱进程,它报告的 `installed.agent_path` 正是 launchd
/// `ProgramArguments[0]` 那条 —— 与 installer 侧 `probeAgentProcess` 用的是**同一处
/// 真相源**。传空串表示"没有可信来源",此时如实返回 [AgentPresence.undetermined]。
///
/// ── 匹配口径为什么带 `--collector` ──────────────────────────────
/// 与 installer 的 `probeAgentProcess()` 保持同一套口径(那里也是
/// `'<agentPath> --collector'`):plist 的 `ProgramArguments` 就是这两项,
/// 只匹配路径会把"同目录下别的调用方式"也算成采集端。
Future<AgentPresence> probeInstalledAgent({
required InkwatchPlatform platform,
required String agentPathFromCli,
ProbeRunner? run,
}) async {
// 非 macOS:锁已经足够,**不要调用探针**(Windows 没有 pgrep)
if (platform != InkwatchPlatform.macOS) return AgentPresence.notApplicable;
final agentPath = agentPathFromCli.trim();
if (agentPath.isEmpty) {
// 没有可信来源算不出绝对路径 —— 不猜一个路径去 pgrep(那会返回"没在跑",
// 一个看起来像结论的错误答案)
return AgentPresence.undetermined;
}
// 路径里可能有 `.` `+` `(` 这类正则元字符(家目录尤其常见),
// 不转义就会匹配到别人家的进程 —— installer 侧已因这个踩过坑。
final pattern = RegExp.escape('$agentPath --collector');
final runner = run ?? _defaultProbeRunner;
try {
final r = await runner(_pgrepPath, ['-f', pattern]);
if (r.exitCode == 0) return AgentPresence.running;
// pgrep 的 1 = 没有匹配,这是**唯一**可信的"没在跑";
// 2/3 = 参数或系统错误,属于"查不了",不能当成"没有"。
if (r.exitCode == 1) return AgentPresence.notRunning;
return AgentPresence.undetermined;
} on ProcessException {
// 沙箱里 `/usr/bin/pgrep` 理论上可执行,但被策略拦下时会抛这个
return AgentPresence.undetermined;
}
}
Future<ProcessResult> _defaultProbeRunner(String exe, List<String> args) =>
Process.run(exe, args);
/// 把两层判据(锁 / 进程探测)合成一个结论:**要给用户看的那句话**。
///
/// 返回 `null` = 可以启动本界面的采集;返回非 null = 不要启动,并把这句话显示出来。
///
/// 分成纯函数是为了可测:`main.dart` 的 `_start` 里那段判断不该只能靠真机验证。
/// 判据的**优先级**:锁(同形态)优先于探针(跨形态)—— 两者的结论都是"有人
/// 在采",但锁能让"两个 GUI 实例"这种探针看不出来的情况也被挡住。
String? guiCollectorBlockReason({
required bool lockHeld,
required AgentPresence presence,
}) {
if (lockHeld) {
return '后台常驻采集已在运行,本界面仅查看(不会重复采集)';
}
switch (presence) {
case AgentPresence.running:
return '后台常驻采集已在运行(进程探测确认),本界面仅查看(不会重复采集)';
case AgentPresence.undetermined:
// 和落库/去重同一条规矩:不确定时**不放行**。
// 双写污染的是数据本身(两条 seq 流混进同一 device_id,服务端分不清,
// 历史曲线出现无法解释的毛刺且不报错);少采只是缺数据,用户能补救。
return '无法判定采集端是否已在运行,为避免与它重复采集同一台设备,'
'本界面没有启动采集。请稍后重试,或在终端用 `inkwatch-install status` 确认。';
case AgentPresence.notRunning:
case AgentPresence.notApplicable:
return null;
}
}
+91 -23
View File
@@ -15,6 +15,7 @@ import 'dart:io';
import 'package:path/path.dart' as p;
import 'app_paths.dart';
import 'config_migration.dart';
/// 编译期可注入的默认网关地址。
@@ -25,10 +26,10 @@ import 'config_migration.dart';
///
/// 用法:
/// flutter build apk --release --target-platform android-arm64 \
/// --dart-define=PMON_DEFAULT_SERVER=http://192.168.0.15:3000
/// --dart-define=INKWATCH_DEFAULT_SERVER=http://192.168.0.15:3000
/// 不传则回退 `http://127.0.0.1:3000`(配合 `adb reverse tcp:3000 tcp:3000` 使用)。
const String kDefaultServerUrl = String.fromEnvironment(
'PMON_DEFAULT_SERVER',
'INKWATCH_DEFAULT_SERVER',
defaultValue: 'http://127.0.0.1:3000',
);
@@ -73,6 +74,10 @@ class AppConfig {
this.autoStart = true,
this.samplingIntervalMs = 1000,
this.samplingIntervalManual = false,
this.einkMode = false,
this.einkRefreshSec = 10,
this.einkLandscape = true,
this.themeMode = 'light',
this.configVersion = kConfigVersion,
});
@@ -114,6 +119,42 @@ class AppConfig {
/// 采样间隔是否由用户手动指定(true 时服务端下发不覆盖,见 host/link.dart)
final bool samplingIntervalManual;
/// **水墨屏(E-Ink)看板模式**。
///
/// 开启后查看端只渲染一台设备的大字看板:无动画、无折线、状态用形状而非颜色,
/// 并把手动刷新之外的界面重绘节流到 [einkRefreshSec] 秒一次。
///
/// 为什么是"设备级设置"而不是"自动检测":Android **没有**可靠的
/// "这块屏是不是墨水屏"接口(iReader 是深度定制系统,也不暴露刷新模式)。
/// 硬猜会猜错;让用户开一次、永久记住,是唯一诚实的做法。
final bool einkMode;
/// 水墨屏看板的自动刷新间隔(秒)。0 表示**只手动刷新**。
///
/// 为什么默认 10 秒而不是 1 秒:墨水屏每一次刷新都要付出
/// "局部重绘 + 残影累积"的代价,而副屏看的是**趋势与异常**,
/// 不是逐帧数值。10 秒足够察觉"CPU 跑到 90% 了",又不会把屏刷花。
final int einkRefreshSec;
/// 水墨屏看板是否**默认横屏**(2026-09-16 产品负责人定的默认)。
///
/// 为什么默认横屏:Neo2 是 1072×1448 —— 横过来是 1448×1072,
/// **宽高比更接近 4:3**,6 个数值格能从 2 列铺成 3 列,
/// 每格更接近正方、字更好读;竖屏则高出一截,顶部/底部空白变多。
/// 用户可随时在看板里「转屏」切换,选择会被记住。
final bool einkLandscape;
/// 界面主题偏好,值域 `'light' | 'dark'`,默认 `'light'`。
///
/// 由应用内**显式切换**驱动([ThemeMode] 由它换算),**不跟随系统亮度** ——
/// 与 Web 侧「`html[data-iw-theme]` 显式控制、不写 prefers-color-scheme」一致。
///
/// 持久化差异:Web 用 `localStorage['inkwatch.theme']`,Flutter 用本 AppConfig
/// 的零依赖 JSON 配置,两者是不同端的独立持久化,不强行统一。
/// 默认 `'light'` 时 [toJson] **不预写 `theme_mode` 键**(与 Web 侧默认不 set
/// localStorage 同义),只有用户切到 dark 才落盘该键。
final String themeMode;
/// 写这份文件时所用的配置格式版本(见 [kConfigVersion])。
/// 磁盘上缺失该键时读作 0 → 触发一次迁移(见 `config_migration.dart`)。
final int configVersion;
@@ -130,6 +171,10 @@ class AppConfig {
bool? autoStart,
int? samplingIntervalMs,
bool? samplingIntervalManual,
bool? einkMode,
int? einkRefreshSec,
bool? einkLandscape,
String? themeMode,
int? configVersion,
}) {
return AppConfig(
@@ -145,6 +190,10 @@ class AppConfig {
samplingIntervalMs: samplingIntervalMs ?? this.samplingIntervalMs,
samplingIntervalManual:
samplingIntervalManual ?? this.samplingIntervalManual,
einkMode: einkMode ?? this.einkMode,
einkRefreshSec: einkRefreshSec ?? this.einkRefreshSec,
einkLandscape: einkLandscape ?? this.einkLandscape,
themeMode: themeMode ?? this.themeMode,
configVersion: configVersion ?? this.configVersion,
);
}
@@ -162,6 +211,11 @@ class AppConfig {
'auto_start': autoStart,
'sampling_interval_ms': samplingIntervalMs,
'sampling_interval_manual': samplingIntervalManual,
'eink_mode': einkMode,
'eink_refresh_sec': einkRefreshSec,
'eink_landscape': einkLandscape,
// 默认 light 不预写键(与 Web 侧 localStorage 默认不 set 同义)
if (themeMode != 'light') 'theme_mode': themeMode,
};
static AppConfig fromJson(Map<String, dynamic> j) => AppConfig(
@@ -176,6 +230,10 @@ class AppConfig {
autoStart: j['auto_start'] as bool? ?? true,
samplingIntervalMs: (j['sampling_interval_ms'] as num?)?.toInt() ?? 1000,
samplingIntervalManual: j['sampling_interval_manual'] as bool? ?? false,
einkMode: j['eink_mode'] as bool? ?? false,
einkRefreshSec: (j['eink_refresh_sec'] as num?)?.toInt() ?? 10,
einkLandscape: j['eink_landscape'] as bool? ?? true,
themeMode: j['theme_mode'] as String? ?? 'light',
// 缺失按 0:老版本写的文件没有这个键,正是要触发迁移的信号
configVersion: (j['config_version'] as num?)?.toInt() ?? 0,
);
@@ -183,14 +241,17 @@ class AppConfig {
/// 配置文件读写。
///
/// 路径按平台:
/// macOS `$HOME/Library/Application Support/pmon_client/`
/// (沙箱应用下 `$HOME` 已是应用容器,天然落在容器内、无需额外 entitlement)
/// Windows `%USERPROFILE%\AppData\Roaming\pmon_client\`
/// 其它 `$HOME/.pmon_client/`
/// **Android** 不用 `HOME`(该环境变量在 Android 上不可靠,读到空会退化成临时目录
/// 导致配置每次重启都丢),改为由平台层通过
/// [setPlatformDir] 注入 `getFilesDir()`(应用私有、持久)。
/// ⚠️ **路径口径不在这里**,在 `host/app_paths.dart` 的 [dataDirFor](唯一真相源)。
/// 本类只负责"读/写/迁移",平台分派一律委托出去 —— 之前这里自带一份
/// macOS/Windows/其它三分支,与 `install_paths.dart` 的硬编码互相矛盾。
///
/// 三种进程形态下的落点(都由 [dataDirFor] 决定,本类不再判断):
/// · GUI(macOS 沙箱)`HOME` 已是容器 `~/Library/Containers/<id>/Data`,
/// 故写 `$HOME/Library/Application Support/inkwatch_client/` 天然落在容器内
/// · 安装器 / 常驻采集端(非沙箱)落在真实家目录的同名子目录
/// · **Android** 不用 `HOME`(该变量在 Android 上不可靠,读到空会退化成
/// 临时目录导致配置每次重启都丢),由平台层经 [setPlatformDir] 注入
/// `getFilesDir()`(应用私有、持久)
class ConfigStore {
ConfigStore._();
@@ -202,21 +263,28 @@ class ConfigStore {
if (dir != null && dir.isNotEmpty) _platformDir = dir;
}
/// 数据目录 —— 口径**不在本文件**,在 `host/app_paths.dart`。
///
/// 合并前这里自带一份 macOS/Windows/其它三分支,与 `install_paths.dart`
/// 的硬编码 macOS 路径互相矛盾(Windows 上 GUI 写 AppData、安装器写
/// `~\\Library\\...`,装得进读不到)。现在只剩一句委托:平台分派、
/// 沙箱形态、Android 注入通道全部由 [dataDirFor] 决定。
///
/// 注入优先级最高(`InkwatchProc.injected`)—— 与 `setPlatformDir` 的既有语义一致,
/// 只是把"注入"收进 [InkwatchProc] 而不是留成与路径规则并列的第二套机制。
static String get _dir {
final injected = _platformDir;
if (injected != null) return injected;
final home =
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '';
if (home.isEmpty) return Directory.systemTemp.path; // 兜底:不崩,但不持久
if (Platform.isMacOS) {
return p.join(home, 'Library', 'Application Support', 'pmon_client');
}
if (Platform.isWindows) {
return p.join(home, 'AppData', 'Roaming', 'pmon_client');
}
return p.join(home, '.pmon_client');
final home = homeFor(Platform.environment);
final dir = dataDirFor(
currentPlatform,
home,
injectedDir: injected,
proc: injected != null ? InkwatchProc.injected : InkwatchProc.gui,
);
// 兜底语义与合并前一致:拿不到家目录(环境变量全缺)时退到临时目录 ——
// 不崩,但不持久。宁可临时目录也不要编一个假路径。
if (dir.isEmpty) return Directory.systemTemp.path;
return dir;
}
static String get path => p.join(_dir, 'config.json');
+266
View File
@@ -0,0 +1,266 @@
/// 安装/数据路径与产物文件名 —— **唯一真相源**
///
/// ── 这个文件为什么存在(第 6 次单一真相源复发的收口)──────────────
/// 合并前有四处互相矛盾的口径:
/// · `install_paths.dart` `installRoot` → **硬编码** macOS 路径,零平台分支
/// · `install_paths.dart` `installedAgentPath` → `inkwatch-agent`(**不带 `.exe`**)
/// · `app_config.dart` `ConfigStore._dir` → 反而有 macOS/Windows/其它三分支
/// · `core_lib.dart` / `bin/inkwatch_install.dart:88` / `:287` → 动态库文件名三份
/// 后果不是"不好看",是**装得进、读不到**:Windows 上 agent 被装到
/// `%USERPROFILE%\Library\Application Support\inkwatch_client\bin`,而配置写进
/// `%USERPROFILE%\AppData\Roaming\inkwatch_client` —— 同一个应用的两个部件
/// 住在两个不相干的地方,且没有任何一方会报错。
///
/// ── 两条正交维度(本文件的核心设计)──────────────────────────────
/// **维度一:平台** —— 做成可传入的参数 [InkwatchPlatform],不直接读 `Platform.isXxx`。
/// 否则单测里切不了平台,"非 macOS 上路径对不对"这类回归永远测不到
/// (而"非 macOS"恰恰就是上面那个装得进读不到的现场)。
/// 连**路径分隔符**也随参数走([pathContextFor]):Windows 口径在 macOS 上
/// 也能测出 `\`,否则测试测的是 posix 风格,等于没测。
///
/// **维度二:进程形态** —— [InkwatchProc],与平台**正交**。
/// 为什么必须有它:安装器是 CLI(**非沙箱**,`HOME` 是真实家目录),
/// GUI 是 macOS **沙箱应用**(`client/macos/Runner/Release.entitlements` 里
/// `com.apple.security.app-sandbox = true`,`HOME` 指向应用容器
/// `~/Library/Containers/<bundle-id>/Data`)。
/// 只把"平台分支"合并成一处、不管进程形态,等于把缺陷包装成"已修复":
/// GUI 读容器、安装器写真家目录,照样装得进读不到 —— 然后有人再补一个
/// `if (sandboxed)` 分支,真相第三次分裂。
///
/// ── 沙箱维度为什么不"嗅探" ──────────────────────────────────────
/// 判定"自己是否被沙箱"只有两种做法,都不好:
/// ① 嗅探 `HOME` 里有没有 `Library/Containers/` —— 那是**再写一份
/// "沙箱长什么样"的知识**,正是本文件要消灭的复制,且 Apple 改路径即失效;
/// ② 再开一条注入通道 —— 与 `ConfigStore.setPlatformDir` 并列成两套机制。
/// 故这里**不判定**,而是把它做成**显式参数** [InkwatchProc]:
/// · `gui` 传"该进程自己看到的家目录"(沙箱进程给的就是容器路径)
/// · `guiSandboxed` 安装器视角:传**真实家目录**,由本文件插入容器前缀
/// 两者必须给出同一个值 —— 这条等式有测试守着
/// (`test/app_paths_test.dart` 的「跨进程形态一致性」组),它就是
/// 「装进去的位置 == GUI 读的位置」的可执行形式。
library;
import 'dart:io';
import 'package:path/path.dart' as p;
/// 平台枚举 —— 刻意做成**参数**而不是直接读 `Platform.isXxx`(见文件头维度一)。
enum InkwatchPlatform { macOS, windows, linux, android }
/// 进程形态 —— 与平台**正交**的第二维度(见文件头维度二)。
enum InkwatchProc {
/// 安装器 CLI / 常驻采集端:**非沙箱**,`HOME` 是真实家目录
installer,
/// Flutter GUI:传"该进程自己看到的家目录"。macOS 沙箱下它已是容器路径。
gui,
/// 安装器视角看**被沙箱包裹的 GUI**:传真实家目录,由本文件插入容器前缀。
///
/// 非 macOS 上容器概念不存在,退化等同于 [gui]。
guiSandboxed,
/// 平台显式注入(Android 的 `getFilesDir()`),优先级最高。
injected,
}
/// macOS 应用 bundle id —— **真相源是 `macos/Runner/Configs/AppInfo.xcconfig`
/// 的 `PRODUCT_BUNDLE_IDENTIFIER`**。
///
/// 这里必须有一份常量(沙箱容器路径要拼它,而 Dart 侧读不到构建配置),
/// 所以配一条**自动比对**:`test/app_paths_test.dart` 会去读那个 xcconfig,
/// 逐字比对。改 bundle id 忘了改这里 → 测试变红,而不是运行期才发现
/// 容器路径拼错(那种错只表现为"GUI 读不到安装器写的配置",没有任何报错)。
const String kMacOsBundleId = 'com.inkwatch.eink';
/// 当前平台。判定收敛在这一个函数里,别处不要再写 `Platform.isXxx` 的平台分派。
InkwatchPlatform get currentPlatform => platformOf(
isMacOS: Platform.isMacOS,
isWindows: Platform.isWindows,
isAndroid: Platform.isAndroid,
);
/// [currentPlatform] 的纯函数版本 —— 让三平台口径在任何宿主上都能测。
InkwatchPlatform platformOf({
required bool isMacOS,
required bool isWindows,
required bool isAndroid,
}) {
if (isMacOS) return InkwatchPlatform.macOS;
if (isWindows) return InkwatchPlatform.windows;
if (isAndroid) return InkwatchPlatform.android;
return InkwatchPlatform.linux;
}
/// 该平台的路径语法(Windows 用反斜杠,其余用正斜杠)。
///
/// ⚠️ 必须随**参数**走而不是随宿主走:否则在 macOS 上测 Windows 口径时
/// 拼出来的是 `C:/Users/u/AppData/...`,看起来"也对",但真机上是 `\`,
/// 于是测试测不到真机形态(本仓库已因"测试与真机形态不一致"吃过亏)。
p.Context pathContextFor(InkwatchPlatform plat) =>
plat == InkwatchPlatform.windows ? p.windows : p.posix;
/* ────────────────────────── 家目录 ────────────────────────── */
/// 家目录 —— **唯一**的取环境入口。
///
/// ⚠️ Windows 上必须**优先 `USERPROFILE`**:装了 Git Bash / MSYS 时 `HOME` 会被
/// 设到 MSYS 的家目录(`C:\msys64\home\<name>`,不是 `C:\Users\<name>`),
/// 照 `HOME ?? USERPROFILE` 的顺序会把 Windows 版导向错误目录。
/// `APPDATA` 不用于推导 home —— 它本身就是 `...\AppData\Roaming`,
/// 拿它当 home 会拼出 `...\Roaming\AppData\Roaming\inkwatch_client`。
///
/// 空串是**合法返回值**(环境变量全缺),由调用方决定兜底策略
/// (`ConfigStore` 会退到 `Directory.systemTemp`)。本函数不自己兜底,
/// 那样会把"没有家目录"这件事藏起来。
String homeFor(Map<String, String> env, {InkwatchPlatform? platform}) {
final plat = platform ?? currentPlatform;
if (plat == InkwatchPlatform.windows) {
final up = env['USERPROFILE'];
if (up != null && up.isNotEmpty) return up;
}
return env['HOME'] ?? env['USERPROFILE'] ?? '';
}
/// 被 macOS 沙箱包裹的 GUI 实际看到的家目录(容器路径)。
///
/// 这是**安装器视角的推导**:安装器拿自己的真实家目录,算出 GUI 会落在哪个容器。
/// 只在 [InkwatchPlatform.macOS] 上有意义;其它平台原样返回 [realHome]。
String guiSandboxHomeFor(InkwatchPlatform plat, String realHome) {
if (plat != InkwatchPlatform.macOS) return realHome;
final ctx = pathContextFor(plat);
return ctx.join(realHome, 'Library', 'Containers', kMacOsBundleId, 'Data');
}
/* ────────────────────────── 数据目录 ────────────────────────── */
/// 数据目录(配置 + 启动日志 + 查看端缓存)。
///
/// [home] 的语义随 [proc] 走,这是本 API 唯一容易用错的地方,务必看清:
/// · [InkwatchProc.installer] / [InkwatchProc.gui] → 传**该进程自己看到的家目录**
/// · [InkwatchProc.guiSandboxed] → 传**真实家目录**(本函数插入容器前缀)
/// · [InkwatchProc.injected] → [home] 不用,[injectedDir] 为准
///
/// Android 恒走注入(`HOME` 在 Android 上不可靠,读到空会退化成临时目录,
/// 导致配置每次重启都丢);未注入时返回**空串**而不是编一个假路径 ——
/// 让"没有可用数据目录"显式暴露,而不是静默落到 `systemTemp`。
String dataDirFor(
InkwatchPlatform plat,
String home, {
InkwatchProc proc = InkwatchProc.installer,
String? injectedDir,
Map<String, String>? env,
}) {
if (proc == InkwatchProc.injected) {
final inj = injectedDir;
if (inj != null && inj.isNotEmpty) return inj;
// 未注入时不编假路径:Android 上 `HOME` 读到空会退化成临时目录,
// 配置每次重启都丢;静默给个 systemTemp 路径是把丢失包装成"能用"。
return '';
}
// 沙箱只是"家目录取值不同",不是另一套目录规则 —— 故这里先换算家目录,
// 后面全平台共用同一份分支。这是把沙箱做成参数而不是 if 分支的全部价值。
final effectiveHome =
proc == InkwatchProc.guiSandboxed ? guiSandboxHomeFor(plat, home) : home;
if (effectiveHome.isEmpty) return '';
final ctx = pathContextFor(plat);
return switch (plat) {
InkwatchPlatform.macOS =>
ctx.join(effectiveHome, 'Library', 'Application Support', 'inkwatch_client'),
InkwatchPlatform.windows =>
ctx.join(effectiveHome, 'AppData', 'Roaming', 'inkwatch_client'),
// Linux 数据目录保持存量口径(`app_config.dart` 一直就是 `~/.inkwatch_client`):
// 改它会让已装机器的配置"凭空消失",迁移成本不合理。
InkwatchPlatform.linux => ctx.join(effectiveHome, '.inkwatch_client'),
InkwatchPlatform.android => '',
};
}
/// 安装器视角:沙箱 GUI 会读到的数据目录。
///
/// 与 `dataDirFor(plat, guiSandboxHomeFor(plat, realHome), proc: InkwatchProc.gui)`
/// 恒等(测试守着)—— 也就是说安装器**能算出** GUI 的位置。
String guiDataDirForInstallerView(
InkwatchPlatform plat,
String realHome, {
Map<String, String>? env,
}) =>
dataDirFor(plat, realHome, proc: InkwatchProc.guiSandboxed, env: env);
/* ────────────────────────── 安装目录(仅桌面端)────────────────────────── */
/// 常驻进程安装根目录(可执行文件 + 核心库)。
///
/// macOS / Windows 与数据目录**同值**(应用目录一体,这也是"装进去=读得到"的
/// 前提);Linux 走 XDG(`$XDG_DATA_HOME/inkwatch_client`,缺省
/// `~/.local/share/inkwatch_client`),**与数据目录不同是有意的**:数据目录要兼容
/// 存量,安装目录是新建的,见 [dataDirFor] 的注释。
String installRootFor(
InkwatchPlatform plat,
String home, {
Map<String, String>? env,
}) {
if (home.isEmpty) return '';
final ctx = pathContextFor(plat);
switch (plat) {
case InkwatchPlatform.linux:
final e = env ?? Platform.environment;
final xdg = e['XDG_DATA_HOME'];
if (xdg != null && xdg.isNotEmpty) return ctx.join(xdg, 'inkwatch_client');
return ctx.join(home, '.local', 'share', 'inkwatch_client');
case InkwatchPlatform.macOS:
return ctx.join(home, 'Library', 'Application Support', 'inkwatch_client');
case InkwatchPlatform.windows:
return ctx.join(home, 'AppData', 'Roaming', 'inkwatch_client');
// Android 没有"安装目录"这个概念(产物随 APK/jniLibs 走)
case InkwatchPlatform.android:
return '';
}
}
/// 采集端可执行文件与核心库的落位目录
String installBinDirFor(InkwatchPlatform plat, String home, {Map<String, String>? env}) {
final root = installRootFor(plat, home, env: env);
if (root.isEmpty) return '';
return pathContextFor(plat).join(root, 'bin');
}
/// 可执行文件名 —— Windows 必须带 `.exe`(合并前 `install_paths.dart` 缺它,
/// 于是 Windows 上落位出来的文件根本不可执行)。
String agentExeNameFor(InkwatchPlatform plat) =>
plat == InkwatchPlatform.windows ? 'inkwatch-agent.exe' : 'inkwatch-agent';
/// 采集端可执行文件的完整路径
String installedAgentPathFor(
InkwatchPlatform plat,
String home, {
Map<String, String>? env,
}) {
final bin = installBinDirFor(plat, home, env: env);
if (bin.isEmpty) return '';
return pathContextFor(plat).join(bin, agentExeNameFor(plat));
}
/// 核心动态库文件名 —— **唯一真相源**。
///
/// 合并前这一份知识存在三处:`core_lib.dart:17-21`、`bin/inkwatch_install.dart:89`
/// (二分支,漏了 Windows 的 `.dll`)、`bin/inkwatch_install.dart:287`(三分支)。
/// 与 Cargo.toml 的 `crate-type` 对应。
String coreLibNameFor(InkwatchPlatform plat) => switch (plat) {
InkwatchPlatform.macOS => 'libinkwatch_core.dylib',
InkwatchPlatform.windows => 'inkwatch_core.dll',
_ => 'libinkwatch_core.so',
};
/// macOS LaunchAgent plist 落位路径(非 macOS 返回 null —— 没有 launchd 这回事)。
///
/// Rust 的 `AutoStartPlan.files[0].path` 声明的是同一处(`~/Library/LaunchAgents/<label>.plist`),
/// 故本函数只服务"还没向核心要计划就要看状态"的 `status` / `verify`;
/// `install` / `uninstall` 一律用核心返回的路径,不再自己拼。
/// 测试会读 `rust-core/src/keepalive.rs` 比对这段形状,防两边漂移。
String? launchAgentPlistPathFor(InkwatchPlatform plat, String home, String label) {
if (plat != InkwatchPlatform.macOS || home.isEmpty) return null;
return pathContextFor(plat).join(home, 'Library', 'LaunchAgents', '$label.plist');
}
+27
View File
@@ -0,0 +1,27 @@
/// 自启项的 **label** —— 安装器与 GUI 共用的唯一一份
///
/// ── 为什么单独一个文件(不是随手写个常量)──────────────────────────
/// 这个字符串有两处必须**完全一致**的消费者:
/// · `bin/inkwatch_install.dart`:注册/卸载/查询自启项时用作 label
/// (macOS 的 `~/Library/LaunchAgents/<label>.plist`、Linux 的
/// `~/.config/systemd/user/<label>.service` 都由它派生);
/// · `lib/host/resident_service.dart`:GUI 查状态时把它当参数传给 CLI
/// (`inkwatch-install status --json --label <label>`)。
///
/// 一旦两处漂移,症状非常隐蔽:**"明明装了,卡片却显示未注册"** ——
/// 因为 GUI 查的是 `A.plist`、安装器注册的是 `B.plist`,两边都"正常",
/// 只是问的不是同一个东西。这与本项目反复踩的"同一份清单写两处"
/// (指标字段、配对码契约、显示目录、安装路径)是同一族缺陷,
/// 故按同一套纪律处理:**只在一处定义,其余全部引用**。
///
/// 注意:本文件只放这个 label,不放路径 —— 路径的唯一真相源是
/// `host/app_paths.dart`(见其文件头)。两者刻意分开:label 是标识,
/// 路径是落位,改动原因完全不同(一个是"改名",一个是"换目录口径")。
library;
/// 自启项默认 label(macOS LaunchAgent / Linux systemd unit 用)
///
/// 改它等于改"自启项的标识":已装用户升级后旧 label 的产物会变成孤儿
/// (`inkwatch-install status` 会报未注册),需要配套写一个迁移/清理动作。
/// 所以**不要轻易改**,改了要在 install/uninstall 里处理旧 label。
const String kDefaultAutostartLabel = 'com.inkwatch.collector';
+128
View File
@@ -0,0 +1,128 @@
/// 自启命令执行器 —— 把「成败语义」与「真的去跑命令」分开,便于单测。
///
/// 为什么单独成模块:`bin/inkwatch_install.dart` 会被 `dart compile exe` 编成
/// 独立可执行文件,里面的私有函数无法被 `flutter test` 覆盖。而"安装器会不会
/// 撒谎"恰恰是最该被测试守住的那件事 —— 2026-10-25 的缺陷正是它把所有命令的
/// 非零返回都当预期、最后无条件宣布安装成功并 `return 0`。
///
/// 分工:
/// · 本模块 = 纯执行 + 判定,不打印(打印归 CLI,便于测试断言)
/// · `bin/inkwatch_install.dart` = 打印 + 决定进程退出码
library;
import 'dart:io';
/// 执行一条 shell 命令。默认走 `/bin/zsh -c`;测试注入假实现。
typedef ShellRunner = Future<ProcessResult> Function(String command);
/// 默认执行器。
///
/// 注意:跑 shell 意味着这里假设了 POSIX 环境。`/bin/zsh` 在多数 Linux 发行版
/// 上并不存在(默认是 bash),Windows 上更不存在 —— 这是多端支持的真实阻塞点,
/// 修法是让 Rust 产出结构化 argv 而不经过 shell,届时只需替换本函数。
Future<ProcessResult> runShell(String command) =>
Process.run('/bin/zsh', ['-c', command]);
/// 一条自启命令,连同它的成败语义。
///
/// 语义**只有一个合法产地**:Rust 的 `AutoStartPlan`(见
/// `rust-core/src/keepalive.rs` 的 `AutostartCommand`)。
/// 调用方不要按命令文本猜"这条失败算不算错" —— `contains('bootout')`
/// 之类的判断等于把同一份语义写在第二处,而"同一份清单写两处"在本项目
/// 已经翻车过五次(指标字段、配对码契约、显示目录、单位换算、安装路径)。
class AutostartCommandSpec {
const AutostartCommandSpec(this.text, {required this.allowFailure});
/// 要执行的命令行
final String text;
/// 返回非零时是否**不算**失败
final bool allowFailure;
/// 解析 Rust `AutoStartPlan` 里的条目,兼容两种载荷形态:
///
/// · 新:`{"cmd": "...", "allow_failure": bool}`
/// · 旧:裸字符串 —— 视为**必成**
///
/// 旧形态按必成处理是有意为之:宁可误报一次失败,也不能回到"命令全失败
/// 却返回 0"的老路。旧产物是真实存在的情形 —— `cargo test` 只编译测试
/// 二进制,**不更新 cdylib**,忘了跑 `cargo build --release` 就会拿到
/// 旧的 libinkwatch_core.dylib。
static AutostartCommandSpec parse(Object? item) {
if (item is String) {
return AutostartCommandSpec(item, allowFailure: false);
}
if (item is Map) {
final cmd = item['cmd'];
final allow = item['allow_failure'];
return AutostartCommandSpec(
cmd is String ? cmd : '',
allowFailure: allow is bool ? allow : false,
);
}
return const AutostartCommandSpec('', allowFailure: false);
}
}
/// 单条命令的执行结果
class AutostartCommandOutcome {
const AutostartCommandOutcome({
required this.spec,
required this.exitCode,
required this.stdoutText,
required this.stderrText,
});
final AutostartCommandSpec spec;
final int exitCode;
final String stdoutText;
final String stderrText;
bool get succeeded => exitCode == 0;
/// 是否应计入「安装失败」:非零 **且** 该命令不允许失败
bool get isHardFailure => !succeeded && !spec.allowFailure;
/// 是否属于"预期内的失败"(幂等前置动作 / 增强项)
bool get isToleratedFailure => !succeeded && spec.allowFailure;
}
/// 一组命令的整体执行结果
class AutostartRunOutcome {
const AutostartRunOutcome(this.commands);
final List<AutostartCommandOutcome> commands;
/// 必成命令的失败条数 —— **为 0 才代表"该做到的都做到了"**
///
/// 调用方必须据此决定进程退出码,不能因为"打印得挺好看"就返回 0。
int get hardFailures => commands.where((c) => c.isHardFailure).length;
bool get ok => hardFailures == 0;
List<AutostartCommandOutcome> get hardFailed =>
commands.where((c) => c.isHardFailure).toList();
}
/// 按计划逐条执行,返回结构化结果(**不做任何打印** —— 打印归 CLI)
Future<AutostartRunOutcome> runAutostartCommands(
List<dynamic> raw, {
ShellRunner runner = runShell,
}) async {
final results = <AutostartCommandOutcome>[];
for (final item in raw) {
final spec = AutostartCommandSpec.parse(item);
if (spec.text.isEmpty) continue;
final r = await runner(spec.text);
results.add(
AutostartCommandOutcome(
spec: spec,
exitCode: r.exitCode,
stdoutText: r.stdout.toString().trim(),
stderrText: r.stderr.toString().trim(),
),
);
}
return AutostartRunOutcome(results);
}
+5 -5
View File
@@ -1,7 +1,7 @@
/// 冷启动诊断报告 —— "覆盖安装残留"类故障的唯一取证口
///
/// ── 为什么要它(2026-09-15)────────────────────────────────────────
/// <user>定位到:**覆盖安装**(在原应用上直接更新、保留旧数据)后滑动卡顿,
/// 产品负责人定位到:**覆盖安装**(在原应用上直接更新、保留旧数据)后滑动卡顿,
/// **清除全部应用数据后卡顿消失**,全新机全新装也不卡。
///
/// 覆盖安装会保留的,正是应用私有目录(Android `getFilesDir()`)里的两个文件:
@@ -9,12 +9,12 @@
/// `host/viewer.dart` 的 `ViewerCache`)。也就是说**故障现场在设备上**。
///
/// 而 release 包 `android:debuggable=false` → `adb shell run-as` 直接拒绝
/// (实测:`run-as: package not debuggable: com.pmon.pmon_client`),
/// (实测:`run-as: package not debuggable: com.inkwatch.inkwatch_client`),
/// 读不到这两个文件。debug 版能 `run-as`,但为了读文件把设备换成 debug 包、
/// 还会改变运行时特征(JIT vs AOT)—— 恰恰会掩盖我们要查的性能问题。
///
/// 故让**应用自己**把非敏感字段与目录清单打到 logcat,用 `adb logcat` 取:
/// flutter build apk --release ... --dart-define=PMON_LOG_BOOT=true
/// flutter build apk --release ... --dart-define=INKWATCH_LOG_BOOT=true
/// adb logcat -s flutter | grep '\[boot\]'
/// 这样"覆盖安装 vs 清数据后"两次启动的日志可以直接 diff。
///
@@ -32,8 +32,8 @@ import 'package:path/path.dart' as p;
import 'app_config.dart';
/// 打开启动诊断(`--dart-define=PMON_LOG_BOOT=true`)
const bool kLogBoot = bool.fromEnvironment('PMON_LOG_BOOT');
/// 打开启动诊断(`--dart-define=INKWATCH_LOG_BOOT=true`)
const bool kLogBoot = bool.fromEnvironment('INKWATCH_LOG_BOOT');
/// 配置 → 诊断文本(**纯函数**,便于离线断言"凭据不泄漏")。
///
+79
View File
@@ -0,0 +1,79 @@
/// 时钟源 —— 单调时钟与墙钟的**唯一定义处**(纯 Dart,不 import flutter)
///
/// ── 为什么必须分开两种时钟(真实故障,别把两类用途混回去)──────────────
/// `DateTime.now()` 是**墙钟**:NTP 校时、用户手改时间、跨时区同步都会让它
/// **跳变,甚至可以回拨**。它适合回答"现在几点"(日志、上报、持久化),
/// 绝不能用来算"过了多久"。
///
/// 而连接状态机做的全是"过了多久"的判断:
/// · 退避到点没有(`transport.rs` 的 `now >= reconnect_at_ms`,**绝对值比较**)
/// · 心跳该不该发(`now - last_heartbeat_tx_ms >= interval`)
/// · 半开检测(`now - last_rx_ms >= suspect_after`)
/// 一旦喂进去的 now 被回拨(哪怕只回拨 1 分钟),`reconnect_at_ms` 就落在
/// "未来":条件长期不成立 → **重连挂死**,界面只显示灰灯,不报错、不留痕 ——
/// 属于最难自查的一类故障(用户只会说"连不上了,重开就好")。
///
/// 所以本项目的纪律是:
/// · **要给状态机/超时判断的 now** → `MonotonicClock`(本文件的 `processNowMs`)
/// · **要写进线格式或落盘的 time** → `wallMs()`
/// 两者混用一次,就会重新长出上面那个故障。
///
/// ── 为什么不用 `DateTime.now()` 包一层(那等于白做)──────────────────
/// 「把 `DateTime.now()` 改个名字叫单调时钟」是最常见的假修复:回拨问题
/// 原样保留。本文件用 `Stopwatch`(进程内单调计时,不受系统时钟调整影响)
/// 实现真单调;基准取**构造时的墙钟**,于是数值仍然贴近真实时间
/// (日志/排查时看得懂),而**增量**是纯单调的。
library;
/// 墙钟毫秒(Unix epoch)—— **只用于**线格式字段(信封 `ts`)、落盘时间戳、
/// 日志行前缀、以及需要"给人看的绝对时间"的地方。
///
/// 不要用它给状态机算超时:见文件头。
int wallMs() => DateTime.now().millisecondsSinceEpoch;
/// 单调毫秒时钟:同一进程内**严格不减**,且数值贴近真实时间。
///
/// 实现:构造时取一次墙钟作基准 `originMs`,之后 `originMs + stopwatch.elapsed`。
/// 这样:
/// · 系统时钟回拨/前跳**不影响**读数(增量来自 Stopwatch);
/// · 读数与墙钟同量级(排查时 `1680000000000` 能看懂,不会是"启动后 12 毫秒");
/// · 跨进程不可比(两个进程的 origin 不同)—— 所以它**不能**写进线格式,
/// 也不能落盘,只能用于"同一进程内比较先后/算间隔"。
///
/// 注意:`Stopwatch` 在部分平台上于系统深度休眠期间不前进(CLOCK_MONOTONIC
/// 不含 suspend)。这不会造成倒退,只会让"过了多久"偏小 —— 而休眠唤醒后
/// 宿主会主动调 `networkAvailable()` 立刻重连,不依赖退避计时器到点。
class MonotonicClock {
/// [originMs] 仅供测试(造一个基准可控的实例);生产不要传。
MonotonicClock({int? originMs})
: _originMs = originMs ?? DateTime.now().millisecondsSinceEpoch {
_sw.start();
}
final int _originMs;
final Stopwatch _sw = Stopwatch();
/// 基准墙钟(构造时刻的毫秒时间戳)—— 诊断用
int get originMs => _originMs;
/// 当前单调毫秒读数
int nowMs() => _originMs + _sw.elapsedMilliseconds;
}
/// 进程级默认单调时钟(首次访问时创建,基准即那一刻的墙钟)
final MonotonicClock processClock = MonotonicClock();
/// 测试专用覆盖:把"进程默认时钟"换成脚本时钟,用于验证各处默认值确实
/// 走的是**单调时钟**而不是 `DateTime.now()`。
///
/// 生产代码**不要**调用(传 null 恢复真实实现)。它存在的理由:默认值这条
/// 线没有别的可观测缝,而它正是"改回 `DateTime.now()`"最容易漏掉的地方。
int Function()? _debugSource;
/// 进程默认单调时钟读数(宿主各处的默认注入值)
int processNowMs() => (_debugSource ?? processClock.nowMs)();
/// 仅供测试:覆盖默认时钟源(null = 恢复)
void debugOverrideProcessClock(int Function()? source) {
_debugSource = source;
}
+30 -2
View File
@@ -1,6 +1,6 @@
/// 配置规范化 —— 从磁盘读进来的值**必须过这一道**
///
/// ── 为什么需要它(2026-09-15,<user>反馈"覆盖安装卡、清数据不卡")──────
/// ── 为什么需要它(2026-09-15,产品负责人反馈"覆盖安装卡、清数据不卡")──────
/// `config.json` 是**外部输入**:它是上一个版本、甚至几个版本前写下的。
/// 但加载路径此前对它的信任是**无条件**的 —— `AppConfig.fromJson` 只做类型转换,
/// 不做任何校验,值直接进业务:
@@ -42,13 +42,22 @@ import 'app_config.dart';
/// · URL / device_id / 令牌开始做形状校验
/// 采样间隔合法区间 —— **数值来自方案,不是拍脑袋**:
/// `docs/inkwatch-v4-architecture.md` 6.1「采集频率默认 1 秒,云端可动态下发 0.5–5 秒」。
/// 采集频率默认 1 秒,云端可动态下发 0.5–5 秒。
///
/// 下限尤其重要:它是"UI 线程被采样占满"的唯一闸门(见文件头实测表)。
const int kMinSamplingMs = 500;
const int kMaxSamplingMs = 5000;
const int kDefaultSamplingMs = 1000;
/// 水墨屏看板刷新间隔的合法区间(秒)。
///
/// 下限 0 = **只手动刷新**(合理诉求:一块静止的副屏不该自己刷);
/// 上限 300 秒(再长就不像"监控"了,用户不如直接看手动)。
/// 与采样间隔同理:磁盘上的旧值必须过这一道,否则任意值直达 `Timer.periodic`。
const int kMinEinkRefreshSec = 0;
const int kMaxEinkRefreshSec = 300;
const int kDefaultEinkRefreshSec = 10;
/// device_id 形状:`android-<后6位>` / 主机名 / 用户手填的短标识。
/// 上限放到 128:桌面端会用主机名(`devuser-MacBook-Pro.local` 已 32 位),
/// 收紧长度只会误伤合法值。
@@ -115,6 +124,23 @@ ConfigNormalization normalizeConfig(AppConfig raw, {int fromVersion = 0}) {
business = '';
}
// ── 水墨屏看板刷新间隔:同采样间隔,唯一闸门在加载处 ──
var einkSec = raw.einkRefreshSec;
if (einkSec < kMinEinkRefreshSec || einkSec > kMaxEinkRefreshSec) {
fixes.add('水墨屏刷新间隔 ${einkSec}s 超出 '
'$kMinEinkRefreshSec–$kMaxEinkRefreshSec s,已按 $kDefaultEinkRefreshSec s 刷新'
'(间隔过小会让墨水屏反复重绘、残影累积)');
einkSec = kDefaultEinkRefreshSec;
}
// ── 主题偏好:值域严格 'light' | 'dark',非法值回落浅色 ──
// 与 Web 侧 html[data-iw-theme] 的显式取值一致,不接受系统亮度或任意字符串。
var themeMode = raw.themeMode;
if (themeMode != 'light' && themeMode != 'dark') {
fixes.add('主题偏好「$themeMode」不是 light/dark,已回落浅色');
themeMode = 'light';
}
// ── 身份类字段(account_id / 令牌 / 邮箱):**一律原样保留** ──
// 它们的"坏"只会变成一次普通的鉴权失败(服务端会明确拒绝),
// 而误清有效凭据的代价是让用户重新登录 + 重新认领。
@@ -148,6 +174,8 @@ ConfigNormalization normalizeConfig(AppConfig raw, {int fromVersion = 0}) {
deviceId: deviceId,
lastViewedDeviceId: lastViewed,
samplingIntervalMs: interval,
einkRefreshSec: einkSec,
themeMode: themeMode,
configVersion: kConfigVersion,
),
fixes,
+14 -11
View File
@@ -1,7 +1,7 @@
/// 核心库(libpmon_core)定位与加载
/// 核心库(libinkwatch_core)定位与加载
///
/// 加载顺序(先精确后兜底,任一命中即返回):
/// 1. 环境变量 `PMON_CORE_LIB` —— 联调时指向 cargo 的构建产物最省事
/// 1. 环境变量 `INKWATCH_CORE_LIB` —— 联调时指向 cargo 的构建产物最省事
/// 2. 应用包内 Frameworks(macOS)/ jniLibs(Android)—— 正式安装形态
/// 3. 仓库内构建产物 —— 开发期直接 `flutter run` 也能跑
/// 全部未命中时抛带排查指引的异常,不做静默降级
@@ -13,12 +13,15 @@ import 'dart:io';
import 'package:path/path.dart' as p;
/// 各平台动态库文件名(与 Cargo.toml 的 crate-type 对应)
String get _libFileName {
if (Platform.isMacOS) return 'libpmon_core.dylib';
if (Platform.isWindows) return 'pmon_core.dll';
return 'libpmon_core.so';
}
import 'app_paths.dart';
/// 各平台动态库文件名 —— **不再自己判断平台**,直接取唯一真相源
/// (`host/app_paths.dart` 的 [coreLibNameFor])。
///
/// 合并前这里有一份 `Platform.isMacOS/isWindows` 三分支,而
/// `bin/inkwatch_install.dart` 里还有二分支和三分支各一份,共三处同源知识。
/// 任一处分支写错(例如 install 侧漏了 `.dll`)都不会报错,只会"装进去了但加载不到"。
String get _libFileName => coreLibNameFor(currentPlatform);
/// 候选路径列表(按优先级)
List<String> candidatePaths() {
@@ -26,7 +29,7 @@ List<String> candidatePaths() {
final out = <String>[];
// 1) 显式指定
final env = Platform.environment['PMON_CORE_LIB'];
final env = Platform.environment['INKWATCH_CORE_LIB'];
if (env != null && env.isNotEmpty) out.add(env);
// 2) 与可执行文件同目录 —— 安装形态(launchd / systemd / 计划任务启动时
@@ -56,7 +59,7 @@ List<String> candidatePaths() {
}
/// 定位并打开核心库
DynamicLibrary openPmonCore() {
DynamicLibrary openInkwatchCore() {
final tried = <String>[];
for (final path in candidatePaths()) {
tried.add(path);
@@ -80,6 +83,6 @@ DynamicLibrary openPmonCore() {
throw StateError(
'未找到 $_libFileName。已尝试以下路径:\n ${tried.join('\n ')}\n'
'请先在 rust-core 下执行 `cargo build --release`,'
'或设置环境变量 PMON_CORE_LIB 指向该文件。',
'或设置环境变量 INKWATCH_CORE_LIB 指向该文件。',
);
}
+95
View File
@@ -0,0 +1,95 @@
/// 埋点上报(M5)—— 5 个事件名的客户端出口。
///
/// ── 为什么这个文件必须存在(否则 M5 等于没做)─────────────────────
/// 服务端 `/api/events` 建好了、`events` 表建好了、端点测试全绿 —— 但**没有任何
/// 客户端上报**时,那张表是恒空的。而恒空的表不会让任何测试变红:所有断言都在验
/// "端点行为对不对",没有一条在验"表里有没有东西"。于是交付看上去完整,漏斗却
/// 没有分子分母。**"以空为通过"的形态里,这一种最难发现**,因为每一层单独看都对。
///
/// ── 三条纪律 ────────────────────────────────────────────────────
/// ① **不阻断业务**(AC-22 的客户端侧):`track` 只入队,`flush` 的失败一律吞掉。
/// 埋点失败绝不能让用户看到一个错。
/// ② **不重试**:失败就丢。埋点不是业务消息,重试只会把一次网络抖动放大成持续流量,
/// 而且补报的时序已经错了(漏斗是按时间算的)。
/// ③ **有界**:队列上限 20(与服务端单批上限一致)。满了就丢最旧的 —— 宁可少几条,
/// 不能让一个离线很久的进程攒出一个巨大队列,回头一次性打出去。
library;
import 'dart:async';
/// 单批上限 —— **与服务端 `shared/events.js` 的 `MAX_BATCH` 是同一个数**。
/// 客户端多传不报错(服务端会截断并计入 dropped),但那样每批都在浪费带宽和一条
/// `dropped` 计数,所以客户端自己先截。
const int kEventBatchMax = 20;
/// 上报出口:`(path, body) => Future`。注入进来是为了能在测试里断言形状而不发网络。
typedef EventPost = Future<void> Function(String path, Map<String, Object?> body);
/// 只允许这 5 个名字 —— 与 SPEC §6.1.1 逐字一致。
///
/// 客户端这份是**常量副本而不是真相源**:真正拦截在服务端(AC-20,未知即丢弃)。
/// 这里挡一手只是为了少发无用请求;它**不**承担"名单正确"的责任,改名字仍以 SPEC 为准。
const List<String> kEventNames = [
'app_open',
'first_frame_shown',
'quota_blocked',
'upgrade_click',
'upgrade_requested',
];
class EventReporter {
EventReporter({required this.post, this.maxQueue = kEventBatchMax});
final EventPost post;
final int maxQueue;
final List<Map<String, Object?>> _queue = [];
bool _flushing = false;
/// 只读用于测试断言
int get pendingCount => _queue.length;
/// 入队。**永不抛错、永不 await**:调用点都在 UI 路径上。
void track(String name, {Map<String, Object?>? props}) {
if (!kEventNames.contains(name)) return; // 与服务端同向的另一道闸(非真相源)
_queue.add({
'name': name,
if (props != null && props.isNotEmpty) 'props': props,
'ts': DateTime.now().toUtc().toIso8601String(),
});
// 超界丢最旧的:保留最近的事件更接近"用户刚才做了什么"
while (_queue.length > maxQueue) {
_queue.removeAt(0);
}
if (_queue.length >= maxQueue) {
// 队列满即冲刷;不 await —— 调用方是 UI
unawaitedFlush();
}
}
/// 立即冲刷(fire-and-forget 版)。UI 路径用这个。
///
/// `unawaited` 而不是裸调用:本仓库开了 `unawaited_futures`,裸丢 Future 会被
/// lint 抓住;而这里**确实**是故意不等的 —— 写 `await` 就等于把网络往返塞进 UI 帧。
void unawaitedFlush() {
unawaited(flush());
}
/// 冲刷。抽干队列后发一次;失败即丢(不重试、不退回队列)。
///
/// 先抽干再发:若发送期间又有 `track` 进来,那些新事件属于**下一批**,
/// 不该被这一批的失败连坐丢掉。
Future<void> flush() async {
if (_flushing || _queue.isEmpty) return;
_flushing = true;
final batch = List<Map<String, Object?>>.from(_queue);
_queue.clear();
try {
await post('/api/events', {'events': batch});
} catch (_) {
// ① 吞掉:埋点失败不阻断业务 ② 不重试:补报的时序已经错了
} finally {
_flushing = false;
}
}
}
+139 -139
View File
@@ -4,8 +4,8 @@
/// 否则运行时才炸(Dart 侧查不到符号会抛 StateError)。
///
/// 纪律(与 Rust 侧方案 9.5 / 12.3 对齐):
/// - 所有返回 `*mut c_char` 的函数,读完后**必须**调 `pmon_string_free`
/// - 所有 `*mut PmonXxx` 句柄必须成对 `_free`
/// - 所有返回 `*mut c_char` 的函数,读完后**必须**调 `inkwatch_string_free`
/// - 所有 `*mut InkwatchXxx` 句柄必须成对 `_free`
/// - Rust 侧已做空指针与 panic 兜底,但 Dart 侧仍不传 null 入参
///
/// ⚠️ 两个已踩过的坑(改动本文件前先看):
@@ -21,89 +21,89 @@ import 'dart:ffi';
import 'package:ffi/ffi.dart' show Utf8;
// ---------- 不透明句柄 ----------
final class PmonCollector extends Opaque {}
final class InkwatchCollector extends Opaque {}
final class PmonTransport extends Opaque {}
final class InkwatchTransport extends Opaque {}
final class PmonGuardian extends Opaque {}
final class InkwatchGuardian extends Opaque {}
// ---------- 返回码(与 ffi.rs 常量一致)----------
const int kPmonOk = 0;
const int kPmonErrNullPtr = -1;
const int kPmonErrBadUtf8 = -2;
const int kPmonErrPanic = -3;
const int kInkwatchOk = 0;
const int kInkwatchErrNullPtr = -1;
const int kInkwatchErrBadUtf8 = -2;
const int kInkwatchErrPanic = -3;
// ═══════════ 采集层 ═══════════
typedef CollectorNewC = Pointer<PmonCollector> Function(IntPtr);
typedef CollectorNewD = Pointer<PmonCollector> Function(int);
typedef CollectorNewC = Pointer<InkwatchCollector> Function(IntPtr);
typedef CollectorNewD = Pointer<InkwatchCollector> Function(int);
typedef CollectorFreeC = Void Function(Pointer<PmonCollector>);
typedef CollectorFreeD = void Function(Pointer<PmonCollector>);
typedef CollectorFreeC = Void Function(Pointer<InkwatchCollector>);
typedef CollectorFreeD = void Function(Pointer<InkwatchCollector>);
typedef CollectFrameC = Pointer<Utf8> Function(Pointer<PmonCollector>);
typedef CollectFrameD = Pointer<Utf8> Function(Pointer<PmonCollector>);
typedef CollectFrameC = Pointer<Utf8> Function(Pointer<InkwatchCollector>);
typedef CollectFrameD = Pointer<Utf8> Function(Pointer<InkwatchCollector>);
typedef DrainPendingC = Pointer<Utf8> Function(Pointer<PmonCollector>);
typedef DrainPendingD = Pointer<Utf8> Function(Pointer<PmonCollector>);
typedef DrainPendingC = Pointer<Utf8> Function(Pointer<InkwatchCollector>);
typedef DrainPendingD = Pointer<Utf8> Function(Pointer<InkwatchCollector>);
typedef ClearPendingC = Int32 Function(Pointer<PmonCollector>);
typedef ClearPendingD = int Function(Pointer<PmonCollector>);
typedef ClearPendingC = Int32 Function(Pointer<InkwatchCollector>);
typedef ClearPendingD = int Function(Pointer<InkwatchCollector>);
typedef PendingLenC = IntPtr Function(Pointer<PmonCollector>);
typedef PendingLenD = int Function(Pointer<PmonCollector>);
typedef PendingLenC = IntPtr Function(Pointer<InkwatchCollector>);
typedef PendingLenD = int Function(Pointer<InkwatchCollector>);
typedef NextSeqC = Uint64 Function(Pointer<PmonCollector>);
typedef NextSeqD = int Function(Pointer<PmonCollector>);
typedef NextSeqC = Uint64 Function(Pointer<InkwatchCollector>);
typedef NextSeqD = int Function(Pointer<InkwatchCollector>);
typedef StringFreeC = Void Function(Pointer<Utf8>);
typedef StringFreeD = void Function(Pointer<Utf8>);
// 推送网络计数器(Android TrafficStats 通道)
typedef SetNetCountersC = Int32 Function(Pointer<PmonCollector>, Uint64, Uint64);
typedef SetNetCountersD = int Function(Pointer<PmonCollector>, int, int);
typedef SetNetCountersC = Int32 Function(Pointer<InkwatchCollector>, Uint64, Uint64);
typedef SetNetCountersD = int Function(Pointer<InkwatchCollector>, int, int);
// ═══════════ 传输层 ═══════════
typedef TrNewC = Pointer<PmonTransport> Function(Pointer<Utf8>, IntPtr);
typedef TrNewD = Pointer<PmonTransport> Function(Pointer<Utf8>, int);
typedef TrNewC = Pointer<InkwatchTransport> Function(Pointer<Utf8>, IntPtr);
typedef TrNewD = Pointer<InkwatchTransport> Function(Pointer<Utf8>, int);
typedef TrFreeC = Void Function(Pointer<PmonTransport>);
typedef TrFreeD = void Function(Pointer<PmonTransport>);
typedef TrFreeC = Void Function(Pointer<InkwatchTransport>);
typedef TrFreeD = void Function(Pointer<InkwatchTransport>);
typedef TrOnConnectedC = Int32 Function(Pointer<PmonTransport>, Uint64);
typedef TrOnConnectedD = int Function(Pointer<PmonTransport>, int);
typedef TrOnConnectedC = Int32 Function(Pointer<InkwatchTransport>, Uint64);
typedef TrOnConnectedD = int Function(Pointer<InkwatchTransport>, int);
typedef TrOnDisconnectedC = Uint64 Function(Pointer<PmonTransport>, Uint64);
typedef TrOnDisconnectedD = int Function(Pointer<PmonTransport>, int);
typedef TrOnDisconnectedC = Uint64 Function(Pointer<InkwatchTransport>, Uint64);
typedef TrOnDisconnectedD = int Function(Pointer<InkwatchTransport>, int);
typedef TrOnRxC = Int32 Function(Pointer<PmonTransport>, Uint64);
typedef TrOnRxD = int Function(Pointer<PmonTransport>, int);
typedef TrOnRxC = Int32 Function(Pointer<InkwatchTransport>, Uint64);
typedef TrOnRxD = int Function(Pointer<InkwatchTransport>, int);
typedef TrOnAckC = Int32 Function(Pointer<PmonTransport>, Uint64);
typedef TrOnAckD = int Function(Pointer<PmonTransport>, int);
typedef TrOnAckC = Int32 Function(Pointer<InkwatchTransport>, Uint64);
typedef TrOnAckD = int Function(Pointer<InkwatchTransport>, int);
typedef TrNetAvailC = Pointer<Utf8> Function(Pointer<PmonTransport>, Uint64);
typedef TrNetAvailD = Pointer<Utf8> Function(Pointer<PmonTransport>, int);
typedef TrNetAvailC = Pointer<Utf8> Function(Pointer<InkwatchTransport>, Uint64);
typedef TrNetAvailD = Pointer<Utf8> Function(Pointer<InkwatchTransport>, int);
typedef TrNextActionC = Pointer<Utf8> Function(Pointer<PmonTransport>, Uint64);
typedef TrNextActionD = Pointer<Utf8> Function(Pointer<PmonTransport>, int);
typedef TrNextActionC = Pointer<Utf8> Function(Pointer<InkwatchTransport>, Uint64);
typedef TrNextActionD = Pointer<Utf8> Function(Pointer<InkwatchTransport>, int);
typedef TrStateC = Pointer<Utf8> Function(Pointer<PmonTransport>, Uint64);
typedef TrStateD = Pointer<Utf8> Function(Pointer<PmonTransport>, int);
typedef TrStateC = Pointer<Utf8> Function(Pointer<InkwatchTransport>, Uint64);
typedef TrStateD = Pointer<Utf8> Function(Pointer<InkwatchTransport>, int);
typedef TrNextMetricsC = Pointer<Utf8> Function(Pointer<PmonTransport>, Pointer<Utf8>);
typedef TrNextMetricsD = Pointer<Utf8> Function(Pointer<PmonTransport>, Pointer<Utf8>);
typedef TrNextMetricsC = Pointer<Utf8> Function(Pointer<InkwatchTransport>, Pointer<Utf8>);
typedef TrNextMetricsD = Pointer<Utf8> Function(Pointer<InkwatchTransport>, Pointer<Utf8>);
typedef TrPendingC = Pointer<Utf8> Function(Pointer<PmonTransport>);
typedef TrPendingD = Pointer<Utf8> Function(Pointer<PmonTransport>);
typedef TrPendingC = Pointer<Utf8> Function(Pointer<InkwatchTransport>);
typedef TrPendingD = Pointer<Utf8> Function(Pointer<InkwatchTransport>);
typedef TrPendingLenC = IntPtr Function(Pointer<PmonTransport>);
typedef TrPendingLenD = int Function(Pointer<PmonTransport>);
typedef TrPendingLenC = IntPtr Function(Pointer<InkwatchTransport>);
typedef TrPendingLenD = int Function(Pointer<InkwatchTransport>);
typedef TrDroppedC = Uint64 Function(Pointer<PmonTransport>);
typedef TrDroppedD = int Function(Pointer<PmonTransport>);
typedef TrDroppedC = Uint64 Function(Pointer<InkwatchTransport>);
typedef TrDroppedD = int Function(Pointer<InkwatchTransport>);
typedef TrClearC = Int32 Function(Pointer<PmonTransport>);
typedef TrClearD = int Function(Pointer<PmonTransport>);
typedef TrClearC = Int32 Function(Pointer<InkwatchTransport>);
typedef TrClearD = int Function(Pointer<InkwatchTransport>);
// ═══════════ 保活层 ═══════════
typedef NoArgStrC = Pointer<Utf8> Function();
@@ -118,27 +118,27 @@ typedef PlanAutostartD = Pointer<Utf8> Function(Pointer<Utf8>, Pointer<Utf8>);
typedef KaStateC = Pointer<Utf8> Function(Int32, Int32, Uint32, Int32);
typedef KaStateD = Pointer<Utf8> Function(int, int, int, int);
typedef GuardianNewC = Pointer<PmonGuardian> Function(Uint32);
typedef GuardianNewD = Pointer<PmonGuardian> Function(int);
typedef GuardianNewC = Pointer<InkwatchGuardian> Function(Uint32);
typedef GuardianNewD = Pointer<InkwatchGuardian> Function(int);
typedef GuardianFreeC = Void Function(Pointer<PmonGuardian>);
typedef GuardianFreeD = void Function(Pointer<PmonGuardian>);
typedef GuardianFreeC = Void Function(Pointer<InkwatchGuardian>);
typedef GuardianFreeD = void Function(Pointer<InkwatchGuardian>);
typedef GuardianI32C = Int32 Function(Pointer<PmonGuardian>);
typedef GuardianI32D = int Function(Pointer<PmonGuardian>);
typedef GuardianI32C = Int32 Function(Pointer<InkwatchGuardian>);
typedef GuardianI32D = int Function(Pointer<InkwatchGuardian>);
typedef GuardianHealthyC = Int32 Function(Pointer<PmonGuardian>, Uint64);
typedef GuardianHealthyD = int Function(Pointer<PmonGuardian>, int);
typedef GuardianHealthyC = Int32 Function(Pointer<InkwatchGuardian>, Uint64);
typedef GuardianHealthyD = int Function(Pointer<InkwatchGuardian>, int);
typedef GuardianOnExitC = Pointer<Utf8> Function(Pointer<PmonGuardian>, Int32, Uint64);
typedef GuardianOnExitD = Pointer<Utf8> Function(Pointer<PmonGuardian>, int, int);
typedef GuardianOnExitC = Pointer<Utf8> Function(Pointer<InkwatchGuardian>, Int32, Uint64);
typedef GuardianOnExitD = Pointer<Utf8> Function(Pointer<InkwatchGuardian>, int, int);
typedef NoArgU64C = Uint64 Function();
typedef NoArgU64D = int Function();
/// 核心库符号集合 —— 一次性查表,失败即抛(早失败好过运行时莫名其妙)
class PmonBindings {
PmonBindings(this.lib);
class InkwatchBindings {
InkwatchBindings(this.lib);
final DynamicLibrary lib;
@@ -148,98 +148,98 @@ class PmonBindings {
return resolve();
} on ArgumentError {
throw StateError(
'核心库缺少符号 $name —— 请确认 libpmon_core 与 ffi.rs 版本一致。'
'核心库缺少符号 $name —— 请确认 libinkwatch_core 与 ffi.rs 版本一致。'
'(改过 ffi.rs 后必须 `cargo build --release`;`cargo test` 不会刷新 cdylib)',
);
}
}
// ---------- 采集 ----------
late final CollectorNewD collectorNew = _sym('pmon_collector_new',
() => lib.lookupFunction<CollectorNewC, CollectorNewD>('pmon_collector_new'));
late final CollectorFreeD collectorFree = _sym('pmon_collector_free',
() => lib.lookupFunction<CollectorFreeC, CollectorFreeD>('pmon_collector_free'));
late final CollectFrameD collectFrame = _sym('pmon_collect_frame',
() => lib.lookupFunction<CollectFrameC, CollectFrameD>('pmon_collect_frame'));
late final DrainPendingD drainPending = _sym('pmon_drain_pending',
() => lib.lookupFunction<DrainPendingC, DrainPendingD>('pmon_drain_pending'));
late final ClearPendingD clearPending = _sym('pmon_clear_pending',
() => lib.lookupFunction<ClearPendingC, ClearPendingD>('pmon_clear_pending'));
late final PendingLenD pendingLen = _sym('pmon_pending_len',
() => lib.lookupFunction<PendingLenC, PendingLenD>('pmon_pending_len'));
late final NextSeqD nextSeq = _sym('pmon_next_seq',
() => lib.lookupFunction<NextSeqC, NextSeqD>('pmon_next_seq'));
late final StringFreeD stringFree = _sym('pmon_string_free',
() => lib.lookupFunction<StringFreeC, StringFreeD>('pmon_string_free'));
late final SetNetCountersD setNetCounters = _sym('pmon_collector_set_net_counters',
late final CollectorNewD collectorNew = _sym('inkwatch_collector_new',
() => lib.lookupFunction<CollectorNewC, CollectorNewD>('inkwatch_collector_new'));
late final CollectorFreeD collectorFree = _sym('inkwatch_collector_free',
() => lib.lookupFunction<CollectorFreeC, CollectorFreeD>('inkwatch_collector_free'));
late final CollectFrameD collectFrame = _sym('inkwatch_collect_frame',
() => lib.lookupFunction<CollectFrameC, CollectFrameD>('inkwatch_collect_frame'));
late final DrainPendingD drainPending = _sym('inkwatch_drain_pending',
() => lib.lookupFunction<DrainPendingC, DrainPendingD>('inkwatch_drain_pending'));
late final ClearPendingD clearPending = _sym('inkwatch_clear_pending',
() => lib.lookupFunction<ClearPendingC, ClearPendingD>('inkwatch_clear_pending'));
late final PendingLenD pendingLen = _sym('inkwatch_pending_len',
() => lib.lookupFunction<PendingLenC, PendingLenD>('inkwatch_pending_len'));
late final NextSeqD nextSeq = _sym('inkwatch_next_seq',
() => lib.lookupFunction<NextSeqC, NextSeqD>('inkwatch_next_seq'));
late final StringFreeD stringFree = _sym('inkwatch_string_free',
() => lib.lookupFunction<StringFreeC, StringFreeD>('inkwatch_string_free'));
late final SetNetCountersD setNetCounters = _sym('inkwatch_collector_set_net_counters',
() => lib.lookupFunction<SetNetCountersC, SetNetCountersD>(
'pmon_collector_set_net_counters'));
'inkwatch_collector_set_net_counters'));
// ---------- 传输 ----------
late final TrNewD transportNew = _sym('pmon_transport_new',
() => lib.lookupFunction<TrNewC, TrNewD>('pmon_transport_new'));
late final TrFreeD transportFree = _sym('pmon_transport_free',
() => lib.lookupFunction<TrFreeC, TrFreeD>('pmon_transport_free'));
late final TrOnConnectedD transportOnConnected = _sym('pmon_transport_on_connected',
() => lib.lookupFunction<TrOnConnectedC, TrOnConnectedD>('pmon_transport_on_connected'));
late final TrNewD transportNew = _sym('inkwatch_transport_new',
() => lib.lookupFunction<TrNewC, TrNewD>('inkwatch_transport_new'));
late final TrFreeD transportFree = _sym('inkwatch_transport_free',
() => lib.lookupFunction<TrFreeC, TrFreeD>('inkwatch_transport_free'));
late final TrOnConnectedD transportOnConnected = _sym('inkwatch_transport_on_connected',
() => lib.lookupFunction<TrOnConnectedC, TrOnConnectedD>('inkwatch_transport_on_connected'));
late final TrOnDisconnectedD transportOnDisconnected = _sym(
'pmon_transport_on_disconnected',
'inkwatch_transport_on_disconnected',
() => lib.lookupFunction<TrOnDisconnectedC, TrOnDisconnectedD>(
'pmon_transport_on_disconnected'));
late final TrOnRxD transportOnRx = _sym('pmon_transport_on_rx',
() => lib.lookupFunction<TrOnRxC, TrOnRxD>('pmon_transport_on_rx'));
late final TrOnAckD transportOnAck = _sym('pmon_transport_on_ack',
() => lib.lookupFunction<TrOnAckC, TrOnAckD>('pmon_transport_on_ack'));
'inkwatch_transport_on_disconnected'));
late final TrOnRxD transportOnRx = _sym('inkwatch_transport_on_rx',
() => lib.lookupFunction<TrOnRxC, TrOnRxD>('inkwatch_transport_on_rx'));
late final TrOnAckD transportOnAck = _sym('inkwatch_transport_on_ack',
() => lib.lookupFunction<TrOnAckC, TrOnAckD>('inkwatch_transport_on_ack'));
late final TrNetAvailD transportNetworkAvailable = _sym(
'pmon_transport_network_available',
'inkwatch_transport_network_available',
() => lib.lookupFunction<TrNetAvailC, TrNetAvailD>(
'pmon_transport_network_available'));
late final TrNextActionD transportNextAction = _sym('pmon_transport_next_action',
() => lib.lookupFunction<TrNextActionC, TrNextActionD>('pmon_transport_next_action'));
late final TrStateD transportState = _sym('pmon_transport_state',
() => lib.lookupFunction<TrStateC, TrStateD>('pmon_transport_state'));
late final TrNextMetricsD transportNextMetrics = _sym('pmon_transport_next_metrics',
() => lib.lookupFunction<TrNextMetricsC, TrNextMetricsD>('pmon_transport_next_metrics'));
late final TrPendingD transportPending = _sym('pmon_transport_pending',
() => lib.lookupFunction<TrPendingC, TrPendingD>('pmon_transport_pending'));
late final TrPendingLenD transportPendingLen = _sym('pmon_transport_pending_len',
() => lib.lookupFunction<TrPendingLenC, TrPendingLenD>('pmon_transport_pending_len'));
late final TrDroppedD transportDropped = _sym('pmon_transport_dropped',
() => lib.lookupFunction<TrDroppedC, TrDroppedD>('pmon_transport_dropped'));
late final TrClearD transportClearPending = _sym('pmon_transport_clear_pending',
() => lib.lookupFunction<TrClearC, TrClearD>('pmon_transport_clear_pending'));
'inkwatch_transport_network_available'));
late final TrNextActionD transportNextAction = _sym('inkwatch_transport_next_action',
() => lib.lookupFunction<TrNextActionC, TrNextActionD>('inkwatch_transport_next_action'));
late final TrStateD transportState = _sym('inkwatch_transport_state',
() => lib.lookupFunction<TrStateC, TrStateD>('inkwatch_transport_state'));
late final TrNextMetricsD transportNextMetrics = _sym('inkwatch_transport_next_metrics',
() => lib.lookupFunction<TrNextMetricsC, TrNextMetricsD>('inkwatch_transport_next_metrics'));
late final TrPendingD transportPending = _sym('inkwatch_transport_pending',
() => lib.lookupFunction<TrPendingC, TrPendingD>('inkwatch_transport_pending'));
late final TrPendingLenD transportPendingLen = _sym('inkwatch_transport_pending_len',
() => lib.lookupFunction<TrPendingLenC, TrPendingLenD>('inkwatch_transport_pending_len'));
late final TrDroppedD transportDropped = _sym('inkwatch_transport_dropped',
() => lib.lookupFunction<TrDroppedC, TrDroppedD>('inkwatch_transport_dropped'));
late final TrClearD transportClearPending = _sym('inkwatch_transport_clear_pending',
() => lib.lookupFunction<TrClearC, TrClearD>('inkwatch_transport_clear_pending'));
// ---------- 保活 ----------
late final NoArgStrD platform =
_sym('pmon_platform', () => lib.lookupFunction<NoArgStrC, NoArgStrD>('pmon_platform'));
late final PlatformBgD platformCanBackground = _sym('pmon_platform_can_background',
() => lib.lookupFunction<PlatformBgC, PlatformBgD>('pmon_platform_can_background'));
late final PlanAutostartD planAutostart = _sym('pmon_keepalive_plan_autostart',
_sym('inkwatch_platform', () => lib.lookupFunction<NoArgStrC, NoArgStrD>('inkwatch_platform'));
late final PlatformBgD platformCanBackground = _sym('inkwatch_platform_can_background',
() => lib.lookupFunction<PlatformBgC, PlatformBgD>('inkwatch_platform_can_background'));
late final PlanAutostartD planAutostart = _sym('inkwatch_keepalive_plan_autostart',
() => lib.lookupFunction<PlanAutostartC, PlanAutostartD>(
'pmon_keepalive_plan_autostart'));
late final KaStateD keepaliveStateJson = _sym('pmon_keepalive_state_json',
() => lib.lookupFunction<KaStateC, KaStateD>('pmon_keepalive_state_json'));
late final GuardianNewD guardianNew = _sym('pmon_guardian_new',
() => lib.lookupFunction<GuardianNewC, GuardianNewD>('pmon_guardian_new'));
late final GuardianFreeD guardianFree = _sym('pmon_guardian_free',
() => lib.lookupFunction<GuardianFreeC, GuardianFreeD>('pmon_guardian_free'));
late final GuardianI32D guardianMarkUserQuit = _sym('pmon_guardian_mark_user_quit',
() => lib.lookupFunction<GuardianI32C, GuardianI32D>('pmon_guardian_mark_user_quit'));
late final GuardianHealthyD guardianMarkHealthy = _sym('pmon_guardian_mark_healthy',
() => lib.lookupFunction<GuardianHealthyC, GuardianHealthyD>('pmon_guardian_mark_healthy'));
late final GuardianOnExitD guardianOnExit = _sym('pmon_guardian_on_exit',
() => lib.lookupFunction<GuardianOnExitC, GuardianOnExitD>('pmon_guardian_on_exit'));
late final NoArgU64D guardianCheckIntervalMs = _sym('pmon_guardian_check_interval_ms',
() => lib.lookupFunction<NoArgU64C, NoArgU64D>('pmon_guardian_check_interval_ms'));
'inkwatch_keepalive_plan_autostart'));
late final KaStateD keepaliveStateJson = _sym('inkwatch_keepalive_state_json',
() => lib.lookupFunction<KaStateC, KaStateD>('inkwatch_keepalive_state_json'));
late final GuardianNewD guardianNew = _sym('inkwatch_guardian_new',
() => lib.lookupFunction<GuardianNewC, GuardianNewD>('inkwatch_guardian_new'));
late final GuardianFreeD guardianFree = _sym('inkwatch_guardian_free',
() => lib.lookupFunction<GuardianFreeC, GuardianFreeD>('inkwatch_guardian_free'));
late final GuardianI32D guardianMarkUserQuit = _sym('inkwatch_guardian_mark_user_quit',
() => lib.lookupFunction<GuardianI32C, GuardianI32D>('inkwatch_guardian_mark_user_quit'));
late final GuardianHealthyD guardianMarkHealthy = _sym('inkwatch_guardian_mark_healthy',
() => lib.lookupFunction<GuardianHealthyC, GuardianHealthyD>('inkwatch_guardian_mark_healthy'));
late final GuardianOnExitD guardianOnExit = _sym('inkwatch_guardian_on_exit',
() => lib.lookupFunction<GuardianOnExitC, GuardianOnExitD>('inkwatch_guardian_on_exit'));
late final NoArgU64D guardianCheckIntervalMs = _sym('inkwatch_guardian_check_interval_ms',
() => lib.lookupFunction<NoArgU64C, NoArgU64D>('inkwatch_guardian_check_interval_ms'));
late final NoArgU64D guardianRestartDeadlineMs = _sym(
'pmon_guardian_restart_deadline_ms',
() => lib.lookupFunction<NoArgU64C, NoArgU64D>('pmon_guardian_restart_deadline_ms'));
'inkwatch_guardian_restart_deadline_ms',
() => lib.lookupFunction<NoArgU64C, NoArgU64D>('inkwatch_guardian_restart_deadline_ms'));
// ---------- 契约自省 ----------
late final NoArgStrD capabilitiesJson = _sym('pmon_capabilities_json',
() => lib.lookupFunction<NoArgStrC, NoArgStrD>('pmon_capabilities_json'));
late final NoArgStrD metricKeysJson = _sym('pmon_metric_keys_json',
() => lib.lookupFunction<NoArgStrC, NoArgStrD>('pmon_metric_keys_json'));
late final NoArgStrD protocolConstantsJson = _sym('pmon_protocol_constants_json',
() => lib.lookupFunction<NoArgStrC, NoArgStrD>('pmon_protocol_constants_json'));
late final NoArgStrD capabilitiesJson = _sym('inkwatch_capabilities_json',
() => lib.lookupFunction<NoArgStrC, NoArgStrD>('inkwatch_capabilities_json'));
late final NoArgStrD metricKeysJson = _sym('inkwatch_metric_keys_json',
() => lib.lookupFunction<NoArgStrC, NoArgStrD>('inkwatch_metric_keys_json'));
late final NoArgStrD protocolConstantsJson = _sym('inkwatch_protocol_constants_json',
() => lib.lookupFunction<NoArgStrC, NoArgStrD>('inkwatch_protocol_constants_json'));
}
@@ -101,14 +101,14 @@ class GuardianGiveUp extends GuardianDecision {
}
/// 核心库门面 —— 全应用单例
class PmonCore {
PmonCore._(this.bindings);
class InkwatchCore {
InkwatchCore._(this.bindings);
static PmonCore? _instance;
static InkwatchCore? _instance;
static PmonCore get instance => _instance ??= PmonCore._(PmonBindings(openPmonCore()));
static InkwatchCore get instance => _instance ??= InkwatchCore._(InkwatchBindings(openInkwatchCore()));
final PmonBindings bindings;
final InkwatchBindings bindings;
/// 读 Rust 分配的字符串并**立即释放** —— 成对纪律,漏了就是每次调用泄漏一次
String? _take(Pointer<Utf8> ptr) {
@@ -149,7 +149,7 @@ class PmonCore {
}
/// 生成自启注册计划(平台差异产物由 Rust 生成,宿主负责落盘与执行)
Map<String, dynamic> planAutostart({required String exePath, String label = 'com.pmon.collector'}) {
Map<String, dynamic> planAutostart({required String exePath, String label = 'com.inkwatch.collector'}) {
final exe = exePath.toNativeUtf8();
final lb = label.toNativeUtf8();
try {
@@ -186,7 +186,7 @@ class PmonCore {
CollectorHandle openCollector({int ringCapacity = 120}) {
final ptr = bindings.collectorNew(ringCapacity);
if (ptr == nullptr) {
throw StateError('pmon_collector_new 返回空指针(ringCapacity=$ringCapacity)');
throw StateError('inkwatch_collector_new 返回空指针(ringCapacity=$ringCapacity)');
}
return CollectorHandle._(this, ptr);
}
@@ -200,7 +200,7 @@ class PmonCore {
try {
final ptr = bindings.transportNew(idPtr, resendCapacity);
if (ptr == nullptr) {
throw StateError('pmon_transport_new 返回空指针(deviceId=$deviceId)');
throw StateError('inkwatch_transport_new 返回空指针(deviceId=$deviceId)');
}
return TransportHandle._(this, ptr);
} finally {
@@ -211,7 +211,7 @@ class PmonCore {
/// 打开一个守护调度器句柄。maxAttempts = 0 表示不放弃
GuardianHandle openGuardian({int maxAttempts = 0}) {
final ptr = bindings.guardianNew(maxAttempts);
if (ptr == nullptr) throw StateError('pmon_guardian_new 返回空指针');
if (ptr == nullptr) throw StateError('inkwatch_guardian_new 返回空指针');
return GuardianHandle._(this, ptr);
}
}
@@ -220,8 +220,8 @@ class PmonCore {
class CollectorHandle {
CollectorHandle._(this.core, this._ptr);
final PmonCore core;
final Pointer<PmonCollector> _ptr;
final InkwatchCore core;
final Pointer<InkwatchCollector> _ptr;
bool _disposed = false;
void _check() {
@@ -232,7 +232,7 @@ class CollectorHandle {
String sampleJson() {
_check();
final s = core._take(core.bindings.collectFrame(_ptr));
if (s == null) throw StateError('采集失败:pmon_collect_frame 返回空');
if (s == null) throw StateError('采集失败:inkwatch_collect_frame 返回空');
return s;
}
@@ -279,8 +279,8 @@ class CollectorHandle {
class TransportHandle {
TransportHandle._(this.core, this._ptr);
final PmonCore core;
final Pointer<PmonTransport> _ptr;
final InkwatchCore core;
final Pointer<InkwatchTransport> _ptr;
bool _disposed = false;
void _check() {
@@ -340,7 +340,7 @@ class TransportHandle {
final pj = jsonEncode(payload).toNativeUtf8();
try {
final raw = core._take(core.bindings.transportNextMetrics(_ptr, pj));
if (raw == null) throw StateError('pmon_transport_next_metrics 返回空');
if (raw == null) throw StateError('inkwatch_transport_next_metrics 返回空');
return jsonDecode(raw) as Map<String, dynamic>;
} finally {
malloc.free(pj);
@@ -381,8 +381,8 @@ class TransportHandle {
class GuardianHandle {
GuardianHandle._(this.core, this._ptr);
final PmonCore core;
final Pointer<PmonGuardian> _ptr;
final InkwatchCore core;
final Pointer<InkwatchGuardian> _ptr;
bool _disposed = false;
void _check() {
+47 -19
View File
@@ -1,36 +1,64 @@
/// 安装路径与 `~` 展开 —— 抽成独立模块以便单测
/// 安装路径与 `~` 展开 —— 安装器(`bin/inkwatch_install.dart`)的对外门面
///
/// ⚠️ 踩过的坑:`package:path` 的 `join()` 在遇到**绝对路径**参数时会丢弃前面已拼的部分
/// (与 Node/Python 的 join 语义一致)。所以
/// `p.join('/Users/me', '/Library/LaunchAgents/x.plist')` 得到的是
/// `/Library/LaunchAgents/x.plist` —— 而不是期望的 `/Users/me/Library/...`。
/// Rust 核心产出的自启计划里路径是 `~/Library/LaunchAgents/...`,
/// 经 `p.join(home, path.substring(1))` 展开就会丢掉家目录,最终写文件报 Permission denied。
/// 故这里必须先把开头的分隔符去掉再拼。
/// ⚠️ **本文件不许再出现任何路径字面量**。所有路径规则都在
/// `host/app_paths.dart`(唯一真相源);这里只保留**既有 getter 名**,
/// 免得 `bin/` 侧全部改调用点。测试里有一条护栏会扫本文件,
/// 一旦又冒出 `Library` / `AppData` / `.inkwatch_client` 之类的路径片段字面量就变红。
///
/// ── 合并前这里是什么样(别改回去)────────────────────────────────
/// `installRoot` 是**硬编码** macOS 路径、零平台分支,`installedAgentPath`
/// 不带 `.exe`。于是 Windows 上 agent 被装到 `%USERPROFILE%\Library\...`,
/// 而 `ConfigStore` 把配置写进 `%APPDATA%\inkwatch_client` —— **装得进、读不到**,
/// 且两边都不会报错。这是同一份路径口径的第 6 次分裂。
///
/// ── 关于 `~` 展开的坑(保留)──────────────────────────────────
/// `package:path` 的 `join()` 遇到**绝对路径**参数时会丢弃前面已拼的部分
/// (与 Node/Python 的 join 语义一致):`p.join('/Users/me', '/Library/x')`
/// 得到 `/Library/x`。Rust 产出的自启计划里路径是 `~/Library/LaunchAgents/...`,
/// 若直接 `p.join(home, path.substring(1))` 就会丢掉家目录,写文件报
/// Permission denied。故 [expandTilde] 必须先把开头的分隔符去掉再拼。
library;
import 'dart:io';
import 'package:path/path.dart' as p;
/// 用户家目录。
import 'app_paths.dart';
/// 用户家目录(当前进程视角)。
///
/// 注意:macOS **沙箱应用**里 `HOME` 指向应用容器
/// (`~/Library/Containers/<bundle-id>/Data`);非沙箱进程则是真实家目录。
/// 本模块服务于安装器(非沙箱),故拿到的是真实家目录。
String get homeDir =>
Platform.environment['HOME'] ??
Platform.environment['USERPROFILE'] ??
Directory.systemTemp.path;
/// 本 getter 服务安装器(**非沙箱** CLI),故拿到的是真实家目录。
/// 需要"GUI 会读哪里"时用 [InkwatchProc.guiSandboxed] 那条路径(见 [guiDataDir])。
String get homeDir => homeFor(Platform.environment);
/// 客户端安装根目录(非沙箱路径)
String get installRoot => p.join(homeDir, 'Library', 'Application Support', 'pmon_client');
/// 客户端安装根目录
String get installRoot => installRootFor(currentPlatform, homeDir);
/// 采集端可执行文件与核心库的落位目录
String get installBinDir => p.join(installRoot, 'bin');
String get installBinDir => installBinDirFor(currentPlatform, homeDir);
/// 采集端可执行文件路径
String get installedAgentPath => p.join(installBinDir, 'pmon-agent');
/// 采集端可执行文件路径(Windows 上自动带 `.exe`)
String get installedAgentPath => installedAgentPathFor(currentPlatform, homeDir);
/// 核心动态库文件名(与 `core_lib.dart` 同源,不要在别处再写一份)
String get coreLibName => coreLibNameFor(currentPlatform);
/// 配置/数据目录 —— 安装器写配置的落点(`ConfigStore` 用的就是这个口径)。
String get dataDir => dataDirFor(currentPlatform, homeDir);
/// **被沙箱包裹的 GUI** 会读到的数据目录(安装器视角,从真实家目录推导)。
///
/// 非 macOS 上等于 [dataDir]。macOS 上两者**不同**:GUI 在容器里,
/// 安装器在容器外 —— 这是操作系统决定的物理事实,路径层只能如实表达。
/// 「装进去的位置 == GUI 读的位置」这条等式要求安装器在写 GUI 要读的东西时
/// 用本 getter,而不是 [dataDir]。详见 `app_paths.dart` 文件头。
String get guiDataDir => guiDataDirForInstallerView(currentPlatform, homeDir);
/// macOS LaunchAgent plist 路径(非 macOS 为 null)
String? launchAgentPlistPath(String label) =>
launchAgentPlistPathFor(currentPlatform, homeDir, label);
/// 展开路径开头的 `~`(只支持 `~/...`,不支持 `~user/...`)
String expandTilde(String path) {
+483
View File
@@ -0,0 +1,483 @@
/// 局域网直连底座 —— 在本机内网地址上开一个**只对内网**的小服务
///
/// ── 为什么要它 ──────────────────────────────────────────────────────
/// 现有架构里所有流量都过云端 gateway。同一 Wi-Fi 下这是浪费:
/// 数据要先绕到腾讯云、再绕回来。局域网直连把它变成"点对点",
/// 延迟从几十 ms 降到几 ms,且**不消耗服务器流量包**
/// —— 轻量服务器按流量计费,这是真金白银。
///
/// 它同时是两件事的**共同底座**(所以先做它,不分成两遍):
/// · 指标走内网(LAN 优先)
/// · 大文件端到端传输(不经服务器 ⇒ 不受服务器磁盘与流量限制)
///
/// ── ⚠️ 安全要点(比功能本身重要)────────────────────────────────────
/// **绝不能 `HttpServer.bind(InternetAddress.anyIPv4)`** —— 那会同时绑到
/// 公网网卡上,等于在公网开了一个端口。本项目的教训是
/// **"只守入口而放行后续动作等于没守"**,故这里:
/// · 只绑 [pickLanAddress] 选中的**那一个**内网地址;
/// · 只暴露只读的 `/lan/ping`,不含任何设备数据;
/// · 将来接数据/文件时用**一次性令牌**鉴权 ——
/// **不拿"同网段"当授权**(那是隐蔽监控的形态,见方案 2.1 的合规红线)。
///
/// ── 地址怎么选 ──────────────────────────────────────────────────────
/// 一个进程常有多个网卡(虚拟网卡 / VPN / Docker / 回环)。选错的后果是
/// "看着开了、对面却连不上"。判定收敛在 [pickLanAddress] 一条纯函数里,
/// 便于单测穷举各种网卡组合。
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:path/path.dart' as p;
// 连接域底座:探测与 offer 编解码已下沉到 net/,此处转 exposed 供既有调用方
// (viewer.dart / transfer_card.dart / 测试)零改动继续引用。
// 只 export 不 import:本文件自身已不再直接使用这些符号(analyzer 验证)。
export '../net/offer.dart'
show
LanIncomingOffer,
LanTicket,
encodeLanTicket,
parseIncomingOffer,
parseLanTicket;
export '../net/probe.dart' show probeLanEndpoint, pickReachableLanEndpoint;
import '../net/relay_client.dart';
export '../net/relay_client.dart'
show
LanDownloadResult,
LanRelayChannel,
openLanRelay,
uploadViaRelay,
downloadViaRelay,
// 中继单次上限:界面要在"点发送之前"就明示它(transfer_card),
// 故随中继客户端一并转出,避免界面层再 import 一次 net/ 或手写常量。
kRelayMaxOnceBytes;
import '../link/relay_driver.dart';
/// 从网卡列表里挑出一个**适合对内提供服务**的内网地址。
///
/// 排除规则(每条都有具体场景,不是想当然):
/// · 回环 `127.` / `::1` —— 对面连不上;
/// · 链路本地 `169.254.` / `fe80::` —— 只在没有 DHCP 时自配,不可路由;
/// · IPv6 —— 本项目的目标设备(手机/墨水瓶)与家庭网多是 IPv4,
/// 先只走 v4,少一半的踩坑面;
/// · 虚拟网卡(utun / docker / br- / veth / vmnet / bridge / tap / tun)
/// —— VPN 与容器网段的地址不在同一 Wi-Fi 里,选了必然连不上。
///
/// 优先级:**私有网段优先**(192.168 / 10 / 172.16-31),
/// 其余可路由的 v4 次之。返回 null 表示"没有可用的内网地址"
/// —— 此时**不启动服务**,而不是退而求其次绑到公网。
/// 入参用 `(网卡名, 地址列表)` 这种简单形状,而不是直接吃 `NetworkInterface` ——
/// 因为 **`NetworkInterface` 没有公开构造函数**,吃了它这个纯函数就没法在测试里
/// 构造"VPN + Wi-Fi 同时存在"之类的场景,而**恰恰是这些场景最容易选错**。
/// 选错的表现是"服务看着开了、对面却连不上",很难在真机上定位。
InternetAddress? pickLanAddress(
List<({String name, List<InternetAddress> addrs})> ifaces,
) {
const virtualPrefixes = [
'utun',
'docker',
'br-',
'veth',
'vmnet',
'bridge',
'tap',
'tun',
'lo',
'llw',
'awdl',
'anpi',
'gif',
'stf',
'ap',
'p2p',
];
bool likelyVirtual(String name) {
final n = name.toLowerCase();
return virtualPrefixes.any(n.startsWith);
}
bool isPrivateV4(InternetAddress a) {
final b = a.rawAddress;
if (b.length != 4) return false;
final x = b[0], y = b[1];
if (x == 192 && y == 168) return true;
if (x == 10) return true;
if (x == 172 && y >= 16 && y <= 31) return true;
return false;
}
bool usable(InternetAddress a) {
if (a.type != InternetAddressType.IPv4) return false;
if (a.isLoopback) return false;
final b = a.rawAddress;
if (b.length != 4) return false;
// 169.254.0.0/16 链路本地
if (b[0] == 169 && b[1] == 254) return false;
// 0.0.0.0 / 广播
if (b[0] == 0 || b[0] == 255) return false;
return true;
}
final candidates = <InternetAddress>[];
for (final nic in ifaces) {
if (likelyVirtual(nic.name)) continue; // 虚拟网卡整张跳过
for (final a in nic.addrs) {
if (usable(a)) candidates.add(a);
}
}
if (candidates.isEmpty) return null;
// 私有网段优先:同 Wi-Fi 的设备基本都在私有网段
return candidates.firstWhere(isPrivateV4, orElse: () => candidates.first);
}
/// 进程级单例 —— 一个进程只开一个内网服务。
///
/// 做成单例而不是每页各开一个:端口是**进程级独占资源**,
/// 两处各起一个会撞端口;且 LAN 服务本来描述的就是"这台机器",
/// 不是"某个页面"。
class LanService {
LanService._();
static final LanService instance = LanService._();
HttpServer? _server;
InternetAddress? _addr;
int? _port;
Object? _lastError;
/// 待传出的文件登记:令牌 → 句柄。**有界**:过期即清扫(见 [_sweepOutgoing]),
/// 且没有登记就不会暴露任何东西 —— 服务默认不共享任何文件。
final Map<String, LanOutgoing> _outgoing = {};
/// 开中继所需的凭据(服务器地址 / 设备标识 / 设备令牌)。
///
/// 由**采集端链路**在启动时注入 —— 采集端与查看端同处一个 App
/// (主壳两个页签),**采集端总是在跑**,所以两端都取得到。
/// 这样就不必让查看端自己再持一份设备凭据(它本来也没有)。
({String serverUrl, String deviceId, String deviceToken})? relayCreds;
/// 供 `/metrics/latest` 取"最新一帧"的提供者。
///
/// 由采集端链路(`link.dart`)注入 —— 这样本模块**不必知道 link 的存在**,
/// 保持"只依赖 dart:io"的干净分层。
Map<String, dynamic>? Function()? frameProvider;
/// 当前登记数(测试与自检用)
int get outgoingCount => _outgoing.length;
/// 登记一个待传出的文件,返回带**一次性令牌**的句柄。
///
/// 令牌是"谁拿到谁才能取",由上层显式发放 ——
/// **不拿"同网段"当授权**(那是隐蔽监控的形态,见文件头合规红线)。
Future<LanOutgoing> offer(
String path, {
Duration ttl = const Duration(minutes: 5),
}) async {
await _sweepOutgoing();
final f = File(path);
if (!await f.exists()) throw ArgumentError('文件不存在:$path');
if (!await f.stat().then((s) => s.type == FileSystemEntityType.file)) {
throw ArgumentError('不是普通文件:$path');
}
final size = await f.length();
// 流式算哈希:大文件不能整个读进内存(这正是本模块要解决的问题本身)
final digest = await sha256.bind(f.openRead()).first;
final o = LanOutgoing(
token: _newToken(),
name: p.basename(path),
size: size,
sha256: digest.toString(),
file: f,
expiresAt: DateTime.now().add(ttl),
);
_outgoing[o.token] = o;
return o;
}
/// 撤销登记(传完就该撤:令牌的有效期不该比传输本身长)
void revoke(String token) => _outgoing.remove(token);
Future<void> _sweepOutgoing() async {
_outgoing.removeWhere((_, o) => o.expired);
}
/// 处理 `GET /file/<token>` —— **流式**返回,支持 `Range` 断点续传。
Future<void> _serveFile(HttpRequest req, String token) async {
await _sweepOutgoing();
final o = _outgoing[token];
// 令牌不存在 / 已过期 → **一律 404**。
// 刻意不用 403:403 会泄漏"这个令牌曾经存在",等于给爆破一个反馈信号。
if (o == null) {
req.response.statusCode = HttpStatus.notFound;
await req.response.close();
return;
}
final total = o.size;
var start = 0;
var end = total - 1;
var partial = false;
final range = req.headers.value(HttpHeaders.rangeHeader);
if (range != null) {
final m = RegExp(r'^bytes=(\d*)-(\d*)$').firstMatch(range.trim());
if (m != null && (m.group(1)!.isNotEmpty || m.group(2)!.isNotEmpty)) {
final a = m.group(1)!;
final b = m.group(2)!;
if (a.isEmpty) {
// `bytes=-N` = 最后 N 字节
final n = int.tryParse(b) ?? 0;
start = (total - n).clamp(0, total);
} else {
start = int.tryParse(a) ?? 0;
end = b.isEmpty ? total - 1 : (int.tryParse(b) ?? 0);
}
if (total == 0 || start >= total || end < start) {
req.response
..statusCode = HttpStatus.requestedRangeNotSatisfiable
..headers.set(HttpHeaders.contentRangeHeader, 'bytes */$total');
await req.response.close();
return;
}
end = end.clamp(start, total - 1);
partial = true;
}
}
final len = end - start + 1;
final res = req.response
..statusCode = partial ? HttpStatus.partialContent : HttpStatus.ok
..headers.contentType = ContentType.binary
..headers.set(HttpHeaders.acceptRangesHeader, 'bytes')
// 文件名可能含中文 → 用 URI 编码放在头里(HTTP 头不是 UTF-8 安全的)
..headers.set('x-file-name', Uri.encodeComponent(o.name))
..headers.set('x-file-sha256', o.sha256)
..contentLength = len;
if (partial) {
res.headers.set(
HttpHeaders.contentRangeHeader,
'bytes $start-$end/$total',
);
}
if (len > 0) {
await res.addStream(o.file.openRead(start, end + 1));
}
await res.close();
}
/// 形如 `192.168.1.5:54321`;未启动时为 null。
String? get endpoint {
final a = _addr;
final p = _port;
if (a == null || p == null) return null;
return '${a.address}:$p';
}
String? get address => _addr?.address;
int? get port => _port;
bool get running => _server != null;
/// 启动失败的原因(例如"没有可用的内网地址")。
/// 刻意暴露出来给自检页显示 —— **"没开"和"开了但对面连不上"必须能分辨**。
Object? get lastError => _lastError;
/// 幂等:已启动则直接返回。
Future<void> start() async {
if (_server != null) return;
_lastError = null;
try {
final nics = await NetworkInterface.list(
includeLoopback: false,
includeLinkLocal: false,
type: InternetAddressType.IPv4,
);
final addr = pickLanAddress([
for (final n in nics) (name: n.name, addrs: n.addresses),
]);
if (addr == null) {
_lastError = '没有可用的内网地址(未启动,且不会退而绑公网)';
return;
}
// ★ 只绑这一个地址、端口交给系统分配(避免与别的程序撞固定端口)
final server = await HttpServer.bind(addr, 0);
_server = server;
_addr = addr;
_port = server.port;
unawaited(_serve(server));
} catch (e) {
_lastError = e;
}
}
Future<void> stop() async {
final s = _server;
_server = null;
_addr = null;
_port = null;
// 停止服务 = **不再共享任何文件**。登记必须一并清掉:
// 留着的话,下次 start() 会带着一批上一轮的令牌复活 ——
// 令牌不该比它所属的那次服务活得更久。
_outgoing.clear();
try {
await s?.close(force: true);
} catch (_) {
// 关不掉不该让调用方失败(与其它"清理失败只记不抛"同一处理)
}
}
Future<void> _serve(HttpServer server) async {
await for (final req in server) {
try {
// 目前只暴露一个**只读、无数据**的探活端点。
// 真正的数据/文件通道接进来时,必须带一次性令牌 —— 见文件头。
final path = req.uri.path;
if (req.method == 'GET' && path == '/lan/ping') {
req.response
..statusCode = 200
..headers.contentType = ContentType.json
..write(jsonEncode({'ok': true, 'v': 1}));
} else if (req.method == 'GET' && path == '/metrics/latest') {
// 局域网直连取数:查看端改走内网后,靠它拿最新一帧。
//
// ⚠️ 返回的形状**刻意与云端 `server:metrics` 的 payload 一致**
// (device_id / ts / seq / metrics)—— 查看端两套数据源共用一套解析,
// 少一处"内网来的要特殊处理"的分支。
final f = frameProvider?.call();
if (f == null) {
req.response.statusCode = HttpStatus.noContent;
} else {
req.response
..statusCode = 200
..headers.contentType = ContentType.json
..write(jsonEncode(f));
}
} else if (req.method == 'GET' && path.startsWith('/file/')) {
await _serveFile(req, path.substring('/file/'.length));
return; // _serveFile 自己关响应
} else {
req.response.statusCode = HttpStatus.notFound;
}
await req.response.close();
} catch (_) {
// 单个请求出错不能让整个服务停摆
try {
req.response.statusCode = HttpStatus.internalServerError;
await req.response.close();
} catch (_) {}
}
}
}
}
// ══════════════════════════════════════════════════════════════════════
// 文件传输(A 方案:内网直连,**数据不经服务器**)
//
// 这一块存在的理由就是产品负责人的原始诉求:**文件不落服务端**,
// 因而**不受服务器磁盘与流量包限制**。所以:文件从发送端的磁盘
// 直接流到接收端的磁盘,服务端一分钟都不参与。
// ══════════════════════════════════════════════════════════════════════
/// 一次"待传出"的文件登记(= 接收端凭令牌可取的东西)。
class LanOutgoing {
LanOutgoing({
required this.token,
required this.name,
required this.size,
required this.sha256,
required this.file,
required this.expiresAt,
});
final String token;
final String name;
final int size;
final String sha256;
final File file;
final DateTime expiresAt;
bool get expired => DateTime.now().isAfter(expiresAt);
}
/// 生成一次性令牌:**16 字节**密码学随机 → base64url(去填充)。
///
/// 为什么是 16 而不是 32:领用它的场景是"同一局域网 + 5 分钟 TTL",
/// 128 位随机在这个窗口内爆破不现实;而令牌要**塞进领取码里发给对方**,
/// 每多 16 字节,码就长 22 个字符 —— 用户抄错、二维码变密的代价是真实的。
/// 32 位是给"长期有效凭据"用的强度,这里不是那个场景。
///
/// 为什么不用"同网段"当授权(**合规红线**):那等于把物理接近
/// 当成授权依据,正是隐蔽监控的形态。令牌由**上层显式发放**,
/// 谁拿到谁才能取 —— 与配对码是同一套思路。
String _newToken() {
final r = Random.secure();
final b = List<int>.generate(16, (_) => r.nextInt(256));
return base64Url.encode(b).replaceAll('=', '');
}
/// 从内网端点下载一个已登记的文件到本地,并做**端到端校验**。
///
/// ── 为什么校验必须做,而且必须作为返回值 ────────────────────────────
/// "写入了 N 字节"不等于"内容对":大文件传输最常出的问题是
/// 断点拼接错位、对端文件在中途被换过。所以收完比对 sha256,
/// 并把结果**放进返回值** —— 调用方据此决定算不算成功,
/// 而不是把它打成一行日志然后当没事发生。
Future<LanDownloadResult> downloadLanFile({
required String endpoint,
required String token,
required String savePath,
Duration timeout = const Duration(seconds: 30),
}) => receiveToFile(
url: Uri.parse('http://$endpoint/file/$token'),
savePath: savePath,
timeout: timeout,
);
/// 内网轮询拿到一帧时,**是否该接受**。
///
/// ── 为什么抽成纯函数 ────────────────────────────────────────────────
/// 这是"局域网直连不把同一帧画两遍"的**核心判据**。嵌在轮询回调里就
/// 只能靠真起 socket 才能验证,而这类"跨层状态不同步"恰恰是本项目
/// 最难复现的一类问题 —— 能单独测就必须单独测。
///
/// 轮询**必然**反复拿到同一帧(2 秒拉一次,而帧可能 10 秒才出一张),
/// 不去重就会把同一帧重复 append 进序列 —— 画面上是原地重复的点。
///
/// 返回 true = 接受(新帧);false = 丢弃(已经处理过)。
bool shouldAcceptLanFrame({required int? lastSeq, required int? seq}) {
// 对端没给 seq:没有依据可判,只能接受。
// 宁可多画一个点,也不要因为"判不了"而把数据全丢了。
if (seq == null) return true;
if (lastSeq == null) return true;
// 只认**严格递增**:等于(重复)与倒退(乱序/重启)都不接受。
return seq > lastSeq;
}
/// 开一条中继,返回**发给对端的那半**与**本地留着推文件的那半**。
///
/// 凭据取自 [LanService.relayCreds](由采集端注入)—— 调用方不必知道
/// 服务器地址与设备令牌,界面层因此干干净净。
///
/// 越界(超单次上限 / 并发 / 当日配额)时抛 StateError,
/// message 就是服务端给的中文原因 —— **界面要能原样告诉用户为什么传不了**。
Future<
({String id, String downloadToken, int bytesPerSec, LanRelayChannel upload})
>
openRelayForSend({required int size}) async {
final c = LanService.instance.relayCreds;
if (c == null) {
throw StateError('中继不可用:本机还没连上服务器');
}
// P1 收口:开中继的逻辑归连接域(RelayDriver),这里只做凭据注入。
final driver = RelayDriver(
serverBase: c.serverUrl,
deviceId: c.deviceId,
deviceToken: c.deviceToken,
);
return driver.openForOffer(size: size);
}
+149 -7
View File
@@ -36,8 +36,14 @@ import 'dart:math';
import 'package:socket_io_client/socket_io_client.dart' as io;
import 'pmon_core.dart';
import 'inkwatch_core.dart';
import 'clock.dart';
import 'config_migration.dart';
import 'lan_service.dart';
import 'pairing_code.dart';
import '../link/p2p_driver.dart';
import '../net/candidates.dart';
/// 上报的指标载荷(字段由 Rust 决定,此处不硬编码字段名)
typedef MetricsFrame = Map<String, dynamic>;
@@ -145,8 +151,10 @@ class ClientAgent {
this.netCountersProvider,
String? deviceToken,
this.scheduleIdle,
int Function()? nowMs,
}) : deviceName = deviceName ?? deviceId,
_deviceToken = deviceToken ?? '';
_deviceToken = deviceToken ?? '',
_now = nowMs ?? processNowMs;
/// 把"不急、但别挡路"的工作排到**空闲时**执行(由宿主注入)。
///
@@ -199,7 +207,54 @@ class ClientAgent {
/// 日志流
Stream<List<String>> get logStream => _logCtl.stream;
PmonCore get _core => PmonCore.instance;
// ── 设备间传文件的信令(见 lan_service.dart 的文件头)──────────────
// ⚠️ 变了只是"怎么把 去哪取+凭什么取 告诉对方":从人工发码变成走通道;
// **数据面完全不变**,文件仍是两端内网直连、不经服务器。
final StreamController<LanIncomingOffer> _offerCtl =
StreamController<LanIncomingOffer>.broadcast();
/// 来自对端的"待领文件"
Stream<LanIncomingOffer> get incomingOffers => _offerCtl.stream;
/// 最近一帧(带 seq),供局域网直连的查看端拉取。
Map<String, dynamic>? _latestForLan;
/// 发布"我这儿有个文件" → 广播给订阅本机的查看端。
///
/// 返回 false 表示**当前没有内网端点**(例如不在任何局域网里)——
/// 这时发布出去也没人取得着,不如不打扰对端。
bool publishOffer({
required String token,
required String name,
required int size,
({String id, String token, int bytesPerSec})? relay,
List<Candidate> candidates = const [],
}) {
final s = _socket;
if (s == null || !s.connected) return false;
final ep = LanService.instance.endpoint;
// 三条路至少有一条才发 —— 一条都没有就是把"传不了"包装成了"已通知"
if (ep == null && relay == null) return false;
s.emit('agent:offer', {
'v': 1,
'type': 'agent:offer',
'device_id': deviceId,
'payload': {
'name': name, 'size': size,
if (ep != null) 'lan_endpoints': [ep],
if (ep != null && token.isNotEmpty) 'token': token,
if (relay != null)
'relay': {'id': relay.id, 'token': relay.token,
'bytesPerSec': relay.bytesPerSec},
// P2 候选:打洞用(服务端校验后转发,脏项逐条丢弃)
if (candidates.isNotEmpty)
'candidates': [for (final c in candidates) c.toJson()],
},
});
return true;
}
InkwatchCore get _core => InkwatchCore.instance;
CollectorHandle? _collector;
TransportHandle? _transport;
@@ -219,7 +274,15 @@ class ClientAgent {
bool get running => _running;
static int _now() => DateTime.now().millisecondsSinceEpoch;
/// 状态机时钟(**单调**,见 `host/clock.dart` 的文件头)。
///
/// 这个值会被喂进 `transport.rs` 的 `on_connected/on_disconnected/on_rx/
/// next_action/state`,而那边的重连判断是 `now >= reconnect_at_ms` 这类
/// **绝对值比较**:喂墙钟时,NTP 一旦回拨,`reconnect_at_ms` 就落在"未来",
/// 条件长期不成立 → **重连挂死**(界面只有灰灯,不报错不留痕)。
///
/// 可注入(`ClientAgent(nowMs: ...)`),测试用脚本时钟验证行为。
final int Function() _now;
void _setStatus(AgentStatus s) {
_status = s;
@@ -227,6 +290,8 @@ class ClientAgent {
}
void _logLine(String s) {
// 日志前缀刻意用**墙钟**(人要看的是"几点几分发生的事");
// 状态机的时间判断另走 _now(单调),两者不可混用,见 host/clock.dart。
final t = DateTime.now().toIso8601String().substring(11, 19);
_log.insert(0, '[$t] $s');
if (_log.length > 300) _log.removeLast();
@@ -271,6 +336,39 @@ class ClientAgent {
_logLine('已停止');
await _statusCtl.close();
await _logCtl.close();
if (!_offerCtl.isClosed) await _offerCtl.close();
}
/// 收到"有人要给你一个文件" → 交给界面去确认(**界面才做"先确认再收"**)。
void _onOffer(dynamic data) {
final off = parseIncomingOffer(data);
if (off == null) return;
if (!_offerCtl.isClosed) _offerCtl.add(off);
// P2P 数据面:对端给了候选就打洞并**开始供帧**(查看端会来拉)
if (off.candidates.isNotEmpty && _p2pSession == null && !_p2pTried) {
_p2pTried = true;
unawaited(_openP2pServe(off.candidates));
}
}
P2pSession? _p2pSession;
bool _p2pTried = false;
Future<void> _openP2pServe(List<Candidate> cands) async {
_logLine('p2p: 尝试数据面供帧(${cands.length} 条候选)');
final s = await P2pSession.open(peerCandidates: cands, onLog: _logLine);
if (s == null) {
_logLine('p2p: 打洞未成功(保持原路)');
return;
}
if (!_running) {
await s.close();
return;
}
_p2pSession = s;
// 供帧:与局域网直连同一份数据(_latestForLan 与 /metrics/latest 同形)
s.startServing(() => _latestForLan);
_logLine('p2p: 数据面就绪,开始供帧 → ${s.peerEndpoint}');
}
/// 应用回到前台 / 网络可能恢复 —— 通知状态机主动重连,不等退避计时器
@@ -367,6 +465,7 @@ class ClientAgent {
s.onAny((event, data) {
_transport!.onRx(_now());
if (event == 'server:config') _applyServerConfig(data);
if (event == 'server:offer') _onOffer(data);
if (event == 'error') _logLine('服务端错误: ${jsonEncode(data)}');
});
@@ -436,6 +535,9 @@ class ClientAgent {
final String _sessionId = _genSessionId();
/// 生成会话标识:时间戳(可读、可排序)+ 随机段(防同毫秒重入)
///
/// 这里用**墙钟**是对的:它是个"给人看、能排序"的标识,不参与任何时间间隔
/// 计算。改成单调时钟会让 id 变成"启动后第几毫秒",跨进程不可读也不可比。
static String _genSessionId() {
final ts = DateTime.now().millisecondsSinceEpoch.toRadixString(36);
final rnd = Random().nextInt(0x7FFFFFFF).toRadixString(36);
@@ -451,7 +553,9 @@ class ClientAgent {
_socket!.emit('agent:hello', {
'v': 1,
'type': 'agent:hello',
'ts': _now(),
// 信封 ts 走**墙钟**:它是线格式字段(服务端拿它对时/落库/排障),
// 换成单调时钟会造出"1970 年之后第几毫秒"的假时间。状态机时钟是另一回事。
'ts': wallMs(),
'device_id': deviceId,
'payload': {
'device_name': deviceName,
@@ -465,12 +569,27 @@ class ClientAgent {
// 设备令牌(若已认领):服务端校验它绑定的 device_id 与本次声明一致,
// 不一致直接拒绝 —— 这是防止别人用你的 device_id 冒充上报的唯一手段
if (_deviceToken.isNotEmpty) 'device_token': _deviceToken,
// 本机配对码(M4″)。**服务端不再发码**,码只在这里产生、随这条连接上报,
// 网关收到即登记、断开即删。它与认领码是**同一个字符串**:用户只有一个
// 六格输入框的心智,两条链路各发一个码只会让人念错。
//
// 必须在 `_sendHello` 里取 `LocalPairingCode.current()` 而不是在卡片里取:
// 这里是"上报"的那一侧,先上报再显示,屏幕上的码才不会是一个服务端
// 从未登记过的值。见 host/pairing_code.dart 文件头那条硬约束。
'claim_code': LocalPairingCode.current(),
// 稳定硬件指纹(与 device_id 的派生规则**无关**):服务端据此在做认领时
// 认出"这还是同一台机",把历史标识的旧记录并进来。
// 见 `host/device_id.dart` 的文件头(device_id 曾掺机型名 → 换了 id 就变新设备)。
// ⚠️ 它**不是凭据**:可猜、不参与鉴权,只用于归并候选匹配。
if (hardwareId.isNotEmpty) 'hardware_id': hardwareId,
'capabilities': core.capabilities(),
// 内网端点(可选):查看端据此**先试内网、失败立刻回落云端**。
// 没有可用内网地址时**不带这个字段**(不是带空值)——
// "这台机没有内网地址"是合法状态,不该让查看端去猜空列表的含义。
// ⚠️ 服务端会再校验一遍(只收私有网段 IPv4):那是**信任边界**,
// 与这里"挑哪个地址上报"是两回事,别当成重复而删掉。
if (LanService.instance.endpoint != null)
'lan_endpoints': [LanService.instance.endpoint],
'keepalive': core.keepaliveState(),
'reconnect_policy': {
'heartbeat_interval_ms': constants['heartbeat_interval_ms'],
@@ -534,6 +653,15 @@ class ClientAgent {
_logLine('采样间隔 $intervalMs ms 超出方案允许的 '
'$kMinSamplingMs–$kMaxSamplingMs ms,已按 $ms ms 执行');
}
// 把"最新一帧"接给局域网服务 —— 查看端走内网时就是从这里取数。
// 注入而不是让它 import link,保持 host 层各模块互不依赖。
LanService.instance.frameProvider = () => _latestForLan;
// 中继凭据同理:**两端共用一个来源**。查看端没有设备令牌,
// 而采集端总有 —— 且采集端在主壳启动时就跑,查看端页要用时一定取得到。
LanService.instance.relayCreds =
(serverUrl: serverUrl, deviceId: deviceId, deviceToken: _deviceToken);
_sampler?.cancel();
_sampler = Timer.periodic(Duration(milliseconds: ms), (_) {
dispatchSample(
@@ -545,7 +673,7 @@ class ClientAgent {
Future<void> _sampleAndSend() async {
if (!_running || _collector == null || _transport == null) return;
// ── 性能留痕(2026-09-15 加,<user>反馈"滑动卡顿")──────────────
// ── 性能留痕(2026-09-15 加,产品负责人反馈"滑动卡顿")──────────────
// 采样整条链路都跑在 **Dart 主 isolate = Flutter UI 线程**上:
// 平台通道取网络计数(Kotlin 侧在 Android 主线程执行)
// → **同步 FFI** 进 Rust 采一帧(sysinfo 要读 /proc、statvfs、挂载表)
@@ -580,6 +708,18 @@ class ClientAgent {
// 2) Rust 打上 seq 并入待确认队列
final envelope = _transport!.nextMetrics(frame);
// 2b) 留一份给**局域网直连**的查看端拉取。
// ⚠️ **必须带 seq**:查看端的两套数据源(云端推送 / 内网拉取)要落在
// 同一个序号空间里,才能判连续、判重复。少了 seq 就会出现
// "切过去重复画一段"或"切过去少一段",而且两种都很难复现。
// 形状对齐云端 `server:metrics` 的 payload(device_id/ts/seq/metrics)。
_latestForLan = {
'device_id': deviceId,
'ts': envelope['ts'],
'seq': envelope['seq'],
'metrics': frame,
};
// 3) 发出;连接存活则视为送达并出队(见文件头 on_ack 策略说明)
if (_socket?.connected == true) {
_socket!.emit('agent:metrics', envelope);
@@ -615,7 +755,9 @@ class ClientAgent {
}
_logLine('补传 ${pending.length} 帧(断线期间缓存)');
if (t.droppedFrames > 0) {
_logLine('⚠️ 超窗丢失 ${t.droppedFrames} 帧(超出 120 帧窗口,图表将显示断点)');
// 日志前缀用纯 ASCII:可 grep、跨终端不乱码,且不与 UI 的图标方案混在一起
// (UI 侧的状态符号一律走 Material Icons,见 lib/ui/ 下的护栏测试)
_logLine('[WARN] 超窗丢失 ${t.droppedFrames} 帧(超出 120 帧窗口,图表将显示断点)');
}
}
}
+2 -2
View File
@@ -13,7 +13,7 @@
/// 真实帧被静默丢弃而测试全绿)。故此处立三条硬规矩:
///
/// 1. **字段名的权威清单仍只有一处**:`shared/metrics-schema.js`
/// (其下游是 Rust 的 `pmon_metric_keys_json()`)。本文件不定义"有哪些指标",
/// (其下游是 Rust 的 `inkwatch_metric_keys_json()`)。本文件不定义"有哪些指标",
/// 只定义"这些指标怎么显示"。
/// 2. 一致性由 `tests/schema-consistency-test.js` 第 11 段**跨语言**强制校验:
/// 本目录的键集合、单位、分组必须与 `METRICS_SCHEMA` 逐项相等(双向)。
@@ -23,7 +23,7 @@
/// 绝不会"静默不显示" —— 与 5.4「客户端对未知字段一律忽略」相比,
/// 这里选择了更安全的"未知字段照样呈现"。
///
/// 运行校验:cd pmon/tests && node schema-consistency-test.js
/// 运行校验:cd inkwatch/tests && node schema-consistency-test.js
library;
// 时序常量在纯 Dart 层定义(断点阈值等),此处转出便于宿主/界面/CLI 共用。
+20 -24
View File
@@ -14,6 +14,8 @@ library;
import 'dart:convert';
import 'dart:io';
import 'pairing_code.dart';
/// HTTP 响应(只留状态码与原文,避免引入第三方 http 包)
class HttpReply {
const HttpReply(this.status, this.body);
@@ -145,31 +147,25 @@ class PairingApi {
final RestGet _restGet;
final RestPost _restPost;
/// 采集端:生成并展示本机配对码(方案 6.3「被查看设备生成并展示配对码」)
/// 采集端:展示本机配对码(M4″ 之后**不再向服务端要码**)
///
/// 服务端发码的端点已下线(`POST /api/pairing/code` → 410):它零鉴权,知道
/// `device_id` 的任何人无需账号即可拿到该设备的码,再换到查看授权。码现在由
/// 设备本地生成、随 `agent:hello` 上报、网关登记、断连即删(见
/// `host/pairing_code.dart`)。
///
/// 因此这个方法**幂等**:它只返回当前码,不生成新码、不发网络。
/// 别把它改成"每次调用都换一个码" —— 换了却不同时重发 hello,屏幕就会显示一个
/// 服务端从未登记过的值,用户念它必然失败,而且看起来像服务端坏了。
Future<PairingOutcome> issueCode(String deviceId) async {
try {
final res = await _restPost(Uri.parse('$businessUrl/api/pairing/code'),
{'device_id': deviceId});
final j = res.json;
if (res.status == 200 && j != null && j['code'] is String) {
return PairingOutcome(
ok: true,
message: '配对码已生成,请在查看端输入',
code: j['code'] as String,
expiresAtMs: (j['expires_at'] as num?)?.toInt(),
deviceId: deviceId,
);
}
final ec = j?['error'] as String?;
return PairingOutcome(
ok: false,
message: pairingErrorText(ec, j?['message'] as String?),
errorCode: ec,
);
} catch (e) {
return PairingOutcome(
ok: false, message: '无法连接业务服务($businessUrl):$e');
}
final code = LocalPairingCode.current();
return PairingOutcome(
ok: true,
message: '配对码已就绪,请在采集端设备屏幕上核对',
code: code,
expiresAtMs: DateTime.now().millisecondsSinceEpoch + 300 * 1000,
deviceId: deviceId,
);
}
/// 查看端:提交配对码,换取该设备的查看授权
+65
View File
@@ -0,0 +1,65 @@
/// 本机配对码 —— **唯一的生成处**(M4″)。
///
/// ── 为什么客户端要自己生成(而不是问服务端要)──────────────────────
/// 原实现是采集端调 `POST /api/pairing/code` 让服务端生成并把码返回。那个端点
/// 零鉴权:知道 `device_id` 的任何人无需账号即可拿到该设备的码,再去查看端输入,
/// 换到该设备的查看授权。根因不是"端点忘了鉴权",是**码的来源错了** ——
/// 服务端没有任何办法证明"请求方就是那台设备"(`device_id` 是可猜的公开标识,
/// `device_token` 只发给已归属设备,新设备会成环)。
///
/// 唯一不可伪造的是**那条已建立的连接本身**。所以码在设备本地生成,随
/// `agent:hello` 上报,服务端只做登记(`gateway._rememberClaimCode` →
/// `PairingStore.registerDeviceCode`),连接断开即删。
///
/// ── 由此得到的一条硬约束(改代码前先读)──────────────────────────
/// **屏幕上的码必须与上报给服务端的码是同一个字符串。** 所以生成只能有一处、
/// 只能有一个实例:`Link` 发 hello 时用它,配对卡显示也用它。若哪天有人在这里
/// 加"重新生成"却没有同时触发重发 hello,屏幕会显示一个服务端从未登记过的码 ——
/// 用户念它是必然失败,而且**看起来像服务端坏了**。
///
/// 换码的正确做法只有一个:让连接重建(重连/重启应用),新码随新 hello 上报。
library;
import 'dart:math';
/// 位数 —— 与服务端 `shared/pairing.js` 的 `PAIRING_CODE_LENGTH` 同一个数。
///
/// 客户端无法 import 服务端常量,所以这里是**副本**。它不承担"名单正确"的
/// 责任:服务端会用 `PAIRING_CODE_RE` 再校验一次,格式不符就拒登记(并在
/// 网关日志里留一行)。这比让客户端"猜对"更可靠。
const int kLocalPairingCodeLength = 6;
final Random _rng = Random.secure();
/// 生成一个 6 位数字码(含前导零,故必须按字符串处理)。
String generatePairingCode() {
final buf = StringBuffer();
for (var i = 0; i < kLocalPairingCodeLength; i++) {
buf.write(_rng.nextInt(10));
}
return buf.toString();
}
/// 进程级唯一实例。
///
/// 为什么是**静态**而不是注入:`Link`(发 hello)与 `PairingCard`(显示)在
/// 两条互不相识的路径上,注入要穿过一整条 widget 树才连得起来;而这个值在
/// 进程内本来就只有一份语义("本机当前的配对码")。用静态单例把"只有一份"
/// 变成编译器就能保证的事,比靠人记得传参可靠。
class LocalPairingCode {
LocalPairingCode._();
static String? _value;
/// 当前码。首次调用时生成 —— **首次调用点必须是 Link 发 hello 之前**,
/// 否则卡片可能先显示出一个还没上报过的码(见文件头那条硬约束)。
static String current() => _value ??= generatePairingCode();
/// 是否已经生成过(诊断/测试用,不参与业务判断)
static bool get hasValue => _value != null;
/// 仅供测试:把单例重置到已知状态(避免测试之间互相污染)
static void debugReset([String? value]) {
_value = value;
}
}
+447
View File
@@ -0,0 +1,447 @@
/// 常驻采集的**状态与动作** —— GUI 与 `inkwatch-install` CLI 之间的唯一接缝
///
/// ── 为什么 GUI 不自己做 launchctl / plist 那一套 ──────────────────────
/// 1. **物理限制**:macOS 沙箱 GUI 写不了 `~/Library/LaunchAgents/`,自启注册
/// 必须由非沙箱进程完成 —— 那个进程就是 `inkwatch-install`(见其文件头)。
/// 2. **单一真相源**:自启产物内容由 Rust 核心生成、落盘与启停由 CLI 执行。
/// GUI 若自己 stat 文件、自己 pgrep 进程,迟早与 CLI 说的不一致,
/// 用户会看到"卡片说在跑、CLI 说没起"这种两张嘴。
/// 故本文件只做三件事:**找到 CLI、跑它、把 `status --json` 解析成类型**。
/// 判据(四类:自启注册态 / 进程 / 配置 / 日志)只有一处,在
/// `bin/inkwatch_install.dart` 的 `statusSnapshot()` 里。
///
/// ── 为什么状态要解析成枚举而不是直接显示 JSON ────────────────────────
/// 界面需要"一句话 + 一个颜色 + 一个该做的动作"。把 JSON 直接铺到界面上,
/// 等于让 UI 层自己解释字段 —— 那就是第二份判据。这里做**唯一一次**解释,
/// 且做成纯函数([ResidentStatus.fromJson]),便于单测三态。
library;
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'autostart_label.dart';
import 'install_paths.dart';
/// CLI 执行器(可注入:测试不该真的去跑进程)
typedef ProcessRunner = Future<ProcessResult> Function(
String executable,
List<String> arguments, {
String? workingDirectory,
});
/// 常驻采集的三种可显示状态(+ 一种"问不出来")
enum ResidentState {
/// 采集进程在跑(自启已注册或手动起的都算)
running,
/// 装了但没起来:落位文件/自启项在,进程不在
installedNotRunning,
/// 没装:既没有自启项,也没落位文件
notInstalled,
/// 问不到状态(CLI 缺失、输出不是 JSON、执行失败)—— 界面必须如实说"不知道"
unknown,
}
/// `inkwatch-install status --json` 的类型化视图
class ResidentStatus {
const ResidentStatus({
required this.state,
this.pids = const [],
this.autostartRegistered = false,
this.autostartLoaded = false,
this.unitPath,
this.configExists = false,
this.configPath = '',
this.logPath = '',
this.logExists = false,
this.agentPath = '',
this.agentExists = false,
this.probeAvailable,
this.probeError = '',
this.serverUrl = '',
this.deviceId = '',
this.detail,
});
final ResidentState state;
final List<int> pids;
/// 自启项(plist/systemd unit/注册表项)是否已注册
final bool autostartRegistered;
/// 自启项是否已**加载**(launchd 已接管 / systemd 已 enable)
final bool autostartLoaded;
/// 自启产物路径(Windows 走注册表时为 null)
final String? unitPath;
final bool configExists;
final String configPath;
/// 采集端错误日志路径(launchd 的 `StandardErrorPath`)。
///
/// ⚠️ **这条事实目前在 Rust 与 Dart 各写了一份**:`rust-core/src/keepalive.rs`
/// 的 `macos_launchagent_plist` 把 `StandardErrorPath` 写成 `/tmp/<label>.err.log`,
/// `bin/inkwatch_install.dart` 的 `statusSnapshot()` 又自己拼一遍。今天两边**逐字
/// 相同**,但"两边碰巧一样"正是漂移的前兆 —— 改一处,status 就指向一个不存在
/// 的日志文件,而两边都不会报错。
///
/// 正解(未做,要动 Rust 结构 + FFI 出口):让 `AutoStartPlan` 多返回
/// `stderr_log_path`,Dart 侧**只读不算** —— 即改为读
/// `AutoStartPlan.stderr_log_path`,**勿再在本地拼 `/tmp/<label>.err.log`**。
final String logPath;
final bool logExists;
/// 落位的采集端可执行文件
final String agentPath;
final bool agentExists;
/// 采集端进程探针**是否可用**(`process.probe_available`)。
///
/// 三态而非布尔:`null` 表示这个 CLI 版本压根没报这个字段(老二进制/老快照)。
/// 只有明确 `false` 才允许把结论降级为 [ResidentState.unknown] ——
/// 老 CLI 没这个字段时若按"不可用"处理,会把所有老机器的正常状态打成"未知",
/// 那就是用另一种方式骗人。
final bool? probeAvailable;
/// 探针不可用的原因(`process.probe_error`),只在 [probeAvailable] 为 false 时有意义
final String probeError;
/// 采集端**实际读到的**网关地址(`config.server_url`,即安装器那份配置)
///
/// 为什么不让界面拿自己那份配置显示:macOS GUI 是**真沙箱**,它读写的
/// `ConfigStore` 在应用容器里;而常驻采集端由 launchd 拉起、读真实家目录。
/// 两份都活着、各说各话 —— 显示"采集端实际读到什么"才是真的。
final String serverUrl;
/// 采集端**实际读到的**设备标识(`config.device_id`)
final String deviceId;
/// 诊断说明(未知态时说明为什么问不出来)
final String? detail;
static const unknown = ResidentStatus(
state: ResidentState.unknown,
detail: '拿不到常驻采集状态',
);
/// 解析 `status --json` 的输出。
///
/// 容错原则:**字段缺了不能崩,也不能假装知道**。缺关键字段时退化为
/// [ResidentState.unknown] 并把原因写进 detail —— 界面宁可说"看不清",
/// 也不要猜一个"在跑/没跑"骗人。
static ResidentStatus fromJson(Map<String, dynamic> j) {
final process = _map(j['process']);
final autostart = _map(j['autostart']);
final config = _map(j['config']);
final log = _map(j['log']);
final installed = _map(j['installed']);
final rawPids = (process['pids'] as List?) ?? const [];
final pids = rawPids
.map((e) => e is num ? e.toInt() : int.tryParse('$e'))
.whereType<int>()
.toList();
// `probe_available` 是三态:缺字段(老 CLI)→ null,不看这个判据。
final rawProbe = process['probe_available'];
final probeAvailable = rawProbe is bool ? rawProbe : null;
final probeError = (process['probe_error'] as String?) ?? '';
final running = process['running'] == true || pids.isNotEmpty;
final registered = autostart['registered'] == true;
final loaded = autostart['loaded'] == true;
final agentExists = installed['agent_exists'] == true;
final ResidentState state;
// 探针不可用 ≠ 没在跑
String? resolutionDetail;
if (running) {
// 正面观察到的"在跑"永远优先:探针能给出 pid,就说明它至少是能用的
state = ResidentState.running;
} else if (probeAvailable == false) {
// ★「查不了」不是「没在跑」:本平台没有探针(Windows 暂无)时,
// `running` 只能是 false —— 若照旧往下判,会得出"已安装但没起来"这个
// **看起来很确定、实际没查过**的结论,卡片还会染成告警色。
// 本项目的规矩是"不知道"优于"错的答案",故降级为 unknown(中性灰)。
state = ResidentState.unknown;
resolutionDetail = '本平台无法判定采集端进程'
'(${probeError.isEmpty ? '缺少可用的进程探针' : probeError}),'
'这不等于"没在运行"';
} else if (registered || agentExists) {
state = ResidentState.installedNotRunning;
} else {
state = ResidentState.notInstalled;
}
return ResidentStatus(
state: state,
pids: pids,
autostartRegistered: registered,
autostartLoaded: loaded,
unitPath: autostart['unit_path'] as String?,
configExists: config['exists'] == true,
configPath: (config['path'] as String?) ?? '',
logPath: (log['path'] as String?) ?? '',
logExists: log['exists'] == true,
agentPath: (installed['agent_path'] as String?) ?? '',
agentExists: agentExists,
probeAvailable: probeAvailable,
probeError: probeError,
serverUrl: (config['server_url'] as String?) ?? '',
deviceId: (config['device_id'] as String?) ?? '',
detail: resolutionDetail,
);
}
static Map<String, dynamic> _map(Object? v) =>
v is Map ? Map<String, dynamic>.from(v) : const <String, dynamic>{};
}
/// 一次动作(安装/卸载)的结果。
///
/// 为什么带 `message` 而不是只给 bool:P0-6 已让 installer 返回非零并打印
/// **哪条命令失败**,失败时把那段原文回显给用户,比"安装失败"有用得多。
class ResidentActionResult {
const ResidentActionResult({
required this.ok,
required this.message,
this.exitCode = 0,
});
final bool ok;
final String message;
final int exitCode;
static const okNoOutput = ResidentActionResult(ok: true, message: '');
}
/// 卡片依赖的最小接口(测试用假实现替换,不必真跑进程)
abstract class ResidentOps {
Future<ResidentStatus> status();
Future<ResidentActionResult> install({
required String serverUrl,
required String deviceId,
});
Future<ResidentActionResult> uninstall();
}
/// 真实现:`Process.run` 调 `inkwatch-install` CLI
class ResidentService implements ResidentOps {
ResidentService({
ProcessRunner? runner,
String? cliPath,
String? cliSource,
String? projectDir,
// label 与安装器共用同一份(见 host/autostart_label.dart):漂移的后果是
// "明明装了、卡片却说未注册" —— 两边都正常,只是问的不是同一个自启项。
this.label = kDefaultAutostartLabel,
}) : _run = runner ?? _defaultRunner,
_cliPathOverride = cliPath,
_cliSourceOverride = cliSource,
_projectDirOverride = projectDir;
final ProcessRunner _run;
final String? _cliPathOverride;
final String? _cliSourceOverride;
final String? _projectDirOverride;
final String label;
static Future<ProcessResult> _defaultRunner(
String executable,
List<String> arguments, {
String? workingDirectory,
}) =>
Process.run(executable, arguments, workingDirectory: workingDirectory);
/// CLI 的落位(**从安装路径派生,不新写一份**)。
///
/// 安装器把可执行文件统一落在 [installBinDir](见 app_paths/install_paths),
/// 所以装好的 CLI 就在它旁边。
String get installedCliPath => p.join(installBinDir, _cliFileName);
String get _cliFileName => Platform.isWindows ? 'inkwatch-install.exe' : 'inkwatch-install';
/// 解析出"怎么调这个 CLI":`[exe, ...前置参数]`(**按优先级**返回全部候选)。
///
/// 三级回落(每一级都写清为什么存在,避免后人凭直觉删):
/// 1. 装好的 `inkwatch-install` —— 终端用户的实际形态;
/// 2. 开发树 `build/inkwatch-install` —— 开发机按 README 编译后的形态;
/// 3. `dart run bin/inkwatch_install.dart` —— 没编译也能用(开发/排障兜底),
/// 代价是慢(每次要 JIT 编译一遍)。
///
/// ⚠️ 为什么是"候选列表"而不是"第一个存在的就用"(真机实测驱动):
/// 开发机上 `build/inkwatch-install` 往往是**几天前编译的旧二进制**。实测它
/// 不支持 `status --json`,旧实现会打印一行**配置 JSON**:
/// `{"server_url":...,"device_id":...}`
/// 于是"取最后一个能解析的 JSON"会抓到那一行,解析成"未安装、路径全空" ——
/// 卡片**静默显示错误状态**,而不是"这个 CLI 太旧"。这正是"形状对、内容错"。
/// 故 [status] 会按候选顺序逐个试,**只接受形状正确的快照**(见 [looksLikeSnapshot])。
List<List<String>> resolveCliCandidates() {
if (_cliPathOverride != null) {
return [
[_cliPathOverride]
];
}
final proj = _projectDirOverride ?? Directory.current.path;
final out = <List<String>>[];
for (final c in [installedCliPath, p.join(proj, 'build', _cliFileName)]) {
if (File(c).existsSync()) out.add([c]);
}
final entry = p.join(proj, 'bin', 'inkwatch_install.dart');
if (File(entry).existsSync()) {
out.add([_cliSourceOverride ?? 'dart', 'run', entry]);
}
return out;
}
/// 第一个候选(安装/卸载用:那两个动作没有"形状不合规"这回事,
/// 失败原因原样回显就够;状态查询才需要逐个试)
List<String>? resolveCli() {
final all = resolveCliCandidates();
return all.isEmpty ? null : all.first;
}
@override
Future<ResidentStatus> status() async {
final candidates = resolveCliCandidates();
if (candidates.isEmpty) {
return const ResidentStatus(
state: ResidentState.unknown,
detail: '未找到 inkwatch-install(先执行 client/scripts/build_cli.sh,'
'产物是 build/inkwatch-install;或直接使用已安装的版本)',
);
}
// 逐个候选试:**只接受形状正确的快照**。第一个不合规的(很可能是几天前
// 编译的旧二进制)不能直接把结果当真 —— 那会静默显示错误状态(见
// resolveCliCandidates 的说明)。全都不合规时,把最后一个的原因报出去。
ResidentStatus? rejected;
for (final cli in candidates) {
final st = await _statusOnce(cli);
if (st.state != ResidentState.unknown) return st;
rejected = st;
}
return rejected ??
const ResidentStatus(
state: ResidentState.unknown, detail: '拿不到常驻采集状态');
}
Future<ResidentStatus> _statusOnce(List<String> cli) async {
final r = await _run(cli.first, [...cli.skip(1), 'status', '--json', '--label', label]);
if (r.exitCode != 0) {
return ResidentStatus(
state: ResidentState.unknown,
detail: 'inkwatch-install status 失败(exit ${r.exitCode},${cli.first}):'
'${_tail(r.stderr)}',
);
}
final raw = (r.stdout as String).trim();
final decoded = _extractJson(raw);
if (decoded == null) {
return ResidentStatus(
state: ResidentState.unknown,
detail: '这个 inkwatch-install 不支持 `status --json`(${cli.first});'
'请重新编译:client/scripts/build_cli.sh(产物 build/inkwatch-install)。'
'输出前 120 字:${raw.length > 120 ? raw.substring(0, 120) : raw}',
);
}
return ResidentStatus.fromJson(decoded);
}
/// 一份输出"像不像 status 快照"—— 形状校验,不是有 `{` 就算。
///
/// 真机血案:旧版 CLI 的 `status` 会打印一行**配置 JSON**
/// (`{"server_url":...,"device_id":...}`)。若只判"能解析成 Map",
/// 就会把配置当成状态快照 → 三个路径全空、state 落到"未安装",
/// 而卡片会一本正经地显示"尚未安装"(用户明明装过)。
/// 判据:必须同时有 `autostart` 与 `process` 两段(它们只出现在状态快照里)。
static bool looksLikeSnapshot(Map<String, dynamic> m) =>
m['autostart'] is Map && m['process'] is Map;
/// 从 CLI 的 stdout 里抠出**状态快照** JSON。
///
/// 两条真机教训(都实测过,别简化回去):
/// 1. 不能假设"最后一行以 `{` 开头":`dart run` 会把 `Running build hooks...`
/// **写在同一行**(`Running build hooks...{"label":"com.inkwatch..."}`);
/// 2. 不能"能解析成 Map 就收":旧版 CLI 的 `status` 会打印一行**配置 JSON**,
/// 那也会解析成功,但内容是错的(三个路径全空、state 落到"未安装")。
/// 故只接受 [looksLikeSnapshot] 认可的(含 `autostart` + `process` 两段)。
static Map<String, dynamic>? _extractJson(String raw) {
final lines = raw.split('\n');
for (var i = lines.length - 1; i >= 0; i--) {
final line = lines[i];
final start = line.indexOf('{');
if (start < 0) continue;
try {
final v = jsonDecode(line.substring(start));
if (v is Map<String, dynamic> && looksLikeSnapshot(v)) return v;
} catch (_) {
// 这一行不是完整 JSON,继续往前找
}
}
return null;
}
@override
Future<ResidentActionResult> install({
required String serverUrl,
required String deviceId,
}) =>
_action(['install'], extraArgs: [
if (serverUrl.isNotEmpty) ...['--server', serverUrl],
if (deviceId.isNotEmpty) ...['--device-id', deviceId],
]);
@override
Future<ResidentActionResult> uninstall() => _action(['uninstall']);
Future<ResidentActionResult> _action(
List<String> subcommand, {
List<String> extraArgs = const [],
}) async {
final cli = resolveCli();
if (cli == null) {
return const ResidentActionResult(
ok: false,
message: '未找到 inkwatch-install:先执行 client/scripts/build_cli.sh'
'(产物 build/inkwatch-install),或把产物放到安装目录的 bin/ 下。',
);
}
final args = [...cli.skip(1), ...subcommand, '--label', label, ...extraArgs];
try {
final r = await _run(cli.first, args);
if (r.exitCode == 0) {
return ResidentActionResult(
ok: true,
message: _tail(r.stdout),
);
}
// 失败必须带**具体原因**:installer 会把"哪条命令失败"打到 stderr,
// 只回一句"安装失败"等于把最有用的一行丢掉。
final err = _tail(r.stderr);
final out = _tail(r.stdout);
return ResidentActionResult(
ok: false,
exitCode: r.exitCode,
message: err.isNotEmpty ? err : (out.isNotEmpty ? out : '命令失败,但没有输出'),
);
} catch (e) {
return ResidentActionResult(ok: false, message: '无法执行 $cli:$e');
}
}
/// 取输出的**最后几行**(安装器的失败原因通常在末尾,前面的都是过程日志)
static String _tail(Object? raw, {int lines = 4}) {
final s = (raw as String?)?.trim() ?? '';
if (s.isEmpty) return '';
final all = s.split('\n').map((l) => l.trimRight()).where((l) => l.isNotEmpty).toList();
return (all.length <= lines ? all : all.sublist(all.length - lines)).join('\n');
}
}
+57
View File
@@ -0,0 +1,57 @@
/// 角色与能力目录(Dart 侧)—— 与 `shared/permissions.js` **必须双向相等**
///
/// ── 为什么客户端也要有一份 ────────────────────────────────────────
/// 界面要决定"这个按钮该不该显示"(viewer 不该看到"邀请成员")。
/// 若客户端自己写一套判断,就会出现"按钮点得到、服务端 403"的最差体验;
/// 而若完全不判断,viewer 会看到一个永远失败的按钮。
///
/// 真相源仍然是服务端那份:这里只镜像 **能力名 → 最低角色**,
/// 由 `tests/schema-consistency-test.js` 第 13 段逐键比对(多一个少一个都红)。
/// 这与指标显示目录(`metric_display.dart`)是同一套做法。
///
/// ⚠️ 改这里必须同时改 `shared/permissions.js`,否则守卫会红。
library;
/// 角色强弱序(与 JS 的 RANK 数值一致,便于跨语言比对)
const Map<String, int> roleRank = {'viewer': 1, 'manager': 2, 'owner': 3};
/// 角色显示名。**只影响文案**,不参与判定。
const Map<String, String> roleLabels = {
'viewer': '仅查看',
'manager': '可分享',
'owner': '可管理',
};
/// 能力 → 所需最低角色(镜像 `CAP` 的 `min`)
const Map<String, String> capabilityMinRole = {
'VIEW_SNAPSHOT': 'viewer',
'VIEW_LIVE': 'viewer',
'VIEW_HISTORY': 'viewer',
'EXPORT_DATA': 'manager',
'INVITE_MEMBER': 'manager',
'VIEW_MEMBERS': 'manager',
'REVOKE_OWN_GRANT': 'manager',
'RENAME_DEVICE': 'manager',
'MANAGE_MEMBERS': 'owner',
'ROTATE_TOKEN': 'owner',
'VIEW_AUDIT': 'owner',
'TRANSFER_OWNER': 'owner',
'DELETE_DEVICE': 'owner',
};
/// 只认归属本人的能力(镜像 `CAP` 的 `ownerOnly`)
const Set<String> ownerOnlyCapabilities = {'TRANSFER_OWNER', 'DELETE_DEVICE'};
/// 某角色能否做某能力(纯函数,与 JS 的 `can()` 同语义)。
///
/// `via` 区分"归属人"与"被授予 owner 的成员" —— 后者不能做 ownerOnly 的事。
bool canByRole(String? role, String capability, {bool isOwner = false}) {
final need = capabilityMinRole[capability];
if (need == null) return false; // 打错能力名 → 不放行
final have = roleRank[role];
if (have == null) return false;
if (ownerOnlyCapabilities.contains(capability) && !isOwner) return false;
return have >= (roleRank[need] ?? 99);
}
String roleLabel(String? role) => roleLabels[role] ?? '未知';
+169
View File
@@ -0,0 +1,169 @@
/// 启动事件日志 —— 落盘、有界、默认开。
///
/// ── 为什么需要它(2026-09-17,macOS 实机测试)─────────────────────
/// 实测到一次**"起而不采"**:应用进程活着、窗口正常、配置里 `auto_start=true`,
/// 却停在配置面板没有开始采集(服务端 0 帧)。当时**无法取证**:
///
/// | 取证手段 | 为什么不够 |
/// |---|---|
/// | GUI `.app` 的 stdout | 沙箱应用必须经 LaunchServices(`open`)启动,stdout 不接终端;本机 `open --stdout` 也不产文件 |
/// | `log show --predicate 'process == "inkwatch_client"'` | Dart 的 `print` 不走 os_log,查到的是空 |
/// | `host/boot_report.dart` | 它是**快照**且默认关(要重新出包带 `INKWATCH_LOG_BOOT=true`);故障现场往往不允许重装 |
/// | SnackBar 提示 | 只活 6 秒,人一走开就没了,且不落盘 |
///
/// 结论:`print` 是**开发期**手段,`boot_report` 是**计划内复现**手段,
/// 而真机上的偶发故障需要的是**默认开、能事后翻**的第三种手段 —— 就是本文件。
///
/// ── 与 boot_report 的分工(别把两者合并)─────────────────────────
/// | | `boot_report.dart` | 本文件 |
/// |---|---|---|
/// | 形态 | 一次性快照(配置全字段 + 目录清单) | 追加式事件流 |
/// | 输出 | stdout / logcat | **落盘** `startup_log.jsonl` |
/// | 默认 | 关(要出包时带 define) | **开** |
/// | 回答的问题 | "这台机器上的配置长什么样" | "这一次启动究竟走到哪一步、哪一步错了" |
///
/// ── 纪律 ────────────────────────────────────────────────────────
/// ① **凭据一律不落盘**:键名含 token / secret / password / email 的字段,
/// 值一律替换成 `(非空, len=N)`。日志文件躺在应用私有目录里,
/// 但"写进磁盘"与"只在内存里"是两个风险面,不该因为目录私有就放松。
/// ② **有界**:单文件超上限即截断保留尾部 —— 日志把磁盘写满比没有日志更糟。
/// ③ **写失败必须静默**:日志是诊断手段,绝不能反过来让启动失败。
/// ④ 纯 Dart(放 `host/`,不 import Flutter):CLI 采集端也要能用同一套。
library;
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
/// 关闭启动事件日志:`--dart-define=INKWATCH_NO_STARTUP_LOG=true`
///
/// 默认**开**是本文件的全部意义(见文件头)。发布包若确要关,
/// 请先想清楚"下次偶发故障拿什么取证"。
const bool kStartupLogDisabled = bool.fromEnvironment('INKWATCH_NO_STARTUP_LOG');
/// 单文件上限(截断保留尾部)。64KB ≈ 数百条事件,够翻若干次启动。
const int kStartupLogMaxBytes = 64 * 1024;
/// 落盘文件名(与 `config.json` 同目录 = 应用私有目录)
const String kStartupLogFile = 'startup_log.jsonl';
/// 键名命中即视为凭据 —— 值不进日志,只记"非空 + 长度"。
/// 用小写子串匹配(`account_token` / `device_token` / `pm_sess_`… 一并覆盖)。
const List<String> kSecretKeyParts = ['token', 'secret', 'password', 'passwd', 'email'];
/// 启动事件日志。
///
/// 用法:`main()` 里注入目录(`StartupLog.dir = ConfigStore.dir`),
/// 之后在关键路径上 `StartupLog.event('started', {...})`。
class StartupLog {
StartupLog._();
/// 落盘目录 —— **必须由宿主注入**(`ConfigStore.dir`)。
///
/// 不自己算一遍目录:那份"数据目录在哪"的真相源已经在 `ConfigStore` 里,
/// 两处各算一次就会踩本仓库反复踩的"同一份清单写两处"(配置在容器内、
/// 日志落到 `systemTemp` 之类的错位,且只在真机上暴露)。
static String? dir;
/// 文件名可覆盖(测试用;生产不要动)
static String fileName = kStartupLogFile;
/// 进程内是否已写过事件 —— 供测试与自检判断"日志口是否真的接上了"
static bool get hasWritten => _written;
static bool _written = false;
static String? get _path => dir == null ? null : p.join(dir!, fileName);
/// 记一条事件。**永不抛**(日志失败不能影响启动)。
///
/// [fields] 的值支持 String / num / bool / null;凭据键名会被替换成占位符。
static void event(String name, [Map<String, Object?> fields = const {}]) {
if (kStartupLogDisabled) return;
final path = _path;
if (path == null) return;
try {
final line = jsonEncode({
't': DateTime.now().toIso8601String(),
'e': name,
'f': redactFields(fields),
});
final f = File(path);
f.writeAsStringSync('$line\n', mode: FileMode.append, flush: true);
_written = true;
_enforceBound(f);
} catch (_) {
// 静默:磁盘满 / 权限 / 沙箱拒写都不该影响应用本身
}
}
/// 凭据脱敏(**纯函数**,便于离线断言)。
///
/// 命中 [kSecretKeyParts] 的键 → `(非空, len=N)`;
/// 其余值截断到 200 字符(防某条超长值把日志撑爆)。
static Map<String, Object?> redactFields(Map<String, Object?> fields) {
final out = <String, Object?>{};
fields.forEach((k, v) {
final lower = k.toLowerCase();
if (kSecretKeyParts.any(lower.contains)) {
final n = v == null ? 0 : v.toString().length;
out[k] = n == 0 ? '(空)' : '(非空, len=$n)';
return;
}
if (v is String && v.length > 200) {
out[k] = '${v.substring(0, 200)}…(截断, 原长 ${v.length})';
return;
}
out[k] = v;
});
return out;
}
/// 有界:超过上限就砍掉前半段(保留尾部 = 最近的启动)。
///
/// 用"读全文再写回"而不是滚动多个文件:单文件、可读性优先,
/// 且触发条件是 64KB 一次,不是每写一条都扫全文。
static void _enforceBound(File f) {
try {
final len = f.lengthSync();
if (len <= kStartupLogMaxBytes) return;
final raw = f.readAsStringSync();
// 从中间往后找一个换行,保证不把某条事件切成半行(半行 JSON 读不出来)
final keepFrom = raw.length ~/ 2;
final nl = raw.indexOf('\n', keepFrom);
final kept = nl < 0 ? '' : raw.substring(nl + 1);
f.writeAsStringSync(kept, mode: FileMode.write, flush: true);
} catch (_) {
// 同上:静默
}
}
/// 读最近 [max] 条事件(诊断用:`inkwatch-install status` / 支持人员取现场)。
static Future<List<String>> tail({int max = 30}) async {
final path = _path;
if (path == null) return const [];
try {
final f = File(path);
if (!await f.exists()) return const [];
final lines = (await f.readAsString())
.split('\n')
.where((l) => l.trim().isNotEmpty)
.toList();
return lines.length <= max ? lines : lines.sublist(lines.length - max);
} catch (e) {
return ['(读取启动日志失败: $e)'];
}
}
/// 清空(测试 / 用户主动清理用)
static Future<void> clear() async {
final path = _path;
if (path == null) return;
try {
final f = File(path);
if (await f.exists()) await f.delete();
} catch (_) {
// 静默
}
}
}
File diff suppressed because it is too large. Load diff
+48
View File
@@ -0,0 +1,48 @@
/// 传输驱动的契约 —— 四种模式对"外面"的同一种说法
///
/// ── 为什么 P1 才立这个文件 ──────────────────────────────────────────
/// P0 没有任何真驱动(数据面未迁),先立接口是投机抽象。P1 的 RelayDriver
/// 是第一个实现者,契约按**真实需要**定,只收三个问题的答案:
/// · 你是什么模式?([mode])
/// · 这条路现在通不通、多快?([probe],**不烧配额**)
/// · 不用了怎么收?([close])
///
/// 数据传输的接口(send/stream)**刻意不在这里**:四种模式的传输形态
/// 差异至今很大(云端是 socket 事件、内网是 HTTP 拉取、中继是 HTTP 直管、
/// P2P 未实现),此时统一成"发一帧"只会造出四个空壳适配器。
/// 等 P3 数据面真的统一时再加,届时按四个真实实现反推出共同形状。
library;
import 'link_mode.dart';
/// 一次探测的结果。不可变,可空字段**不猜**(没测就是 null)。
class DriverProbe {
const DriverProbe({
required this.mode,
required this.reachable,
this.rttMs,
this.reason = '',
});
final LinkMode mode;
final bool reachable;
/// 探测往返毫秒;null = 没量到(不要填 0 冒充"极快")。
final int? rttMs;
/// 人话原因(给诊断日志)。
final String reason;
}
/// 传输驱动:一条数据路的可插拔实现。
abstract class TransportDriver {
/// 这条路的模式(词汇见 [LinkMode])。
LinkMode get mode;
/// 探活。**禁止为了探活而消耗配额/建立持久连接**——
/// 选路会周期性探测,探活成本必须接近零。
Future<DriverProbe> probe();
/// 释放资源(幂等:重复调用不报错)。
Future<void> close();
}
+125
View File
@@ -0,0 +1,125 @@
/// 切换状态机 —— 把"从一条路换到另一条路"的时序显式化
///
/// ── 为什么需要它 ────────────────────────────────────────────────────
/// 切换最容易出事的不是"选哪条",而是**中途**:新路还没证通就把旧路
/// 退了(空洞)、旧路退早了还有在途帧(漏帧)、两边都开着又没人收尾
/// (双倍流量)。这些在散落的 if/else 里既说不清也测不了。
///
/// 相位(唯一合法路径,倒序/跳跃一律忽略):
/// idle → decided → proving → overlapping → retiring → done
/// ↘ aborted(任一步失败:旧路不动,等下次)
///
/// - **decided**:决定要切(还没动任何数据面)
/// - **proving**:新路证通(探测/握手/收到第一帧)
/// - **overlapping**:双路并存——旧路**仍在工作**(这期间同帧会来两次,
/// 由 seq 去重吃掉;这是"不丢帧"的代价,也是唯一可靠的做法)
/// - **retiring**:新路已确认收到数据,旧路开始退役(如云端退订等回执)
/// - **done**:旧路已退,切完
/// - **aborted**:任一步失败 → 旧路保持,`reason` 说明为什么
///
/// 本类**不含定时器**:超时由调用方(ViewerSession/LinkHub)用
/// `isStalled(now)` 判定,避免状态机自带时钟导致测试要等真时间。
library;
import 'link_mode.dart';
enum HandoverPhase { idle, decided, proving, overlapping, retiring, done, aborted }
class Handover {
Handover({
required this.from,
required this.to,
this.onLog,
this.stallMs = 4000,
});
/// 从哪条路切走(`null` = 从"无"开始,例如首次选路)。
final LinkMode? from;
final LinkMode to;
final void Function(String msg)? onLog;
/// 任一相位允许停留的时长;超过即视为卡住(调用方据此中止)。
final int stallMs;
HandoverPhase _phase = HandoverPhase.idle;
int _phaseAtMs = 0;
String _reason = '';
String? _abortReason;
HandoverPhase get phase => _phase;
bool get active =>
_phase != HandoverPhase.idle &&
_phase != HandoverPhase.done &&
_phase != HandoverPhase.aborted;
/// 中止原因(仅 aborted 时有值)。
String? get abortReason => _abortReason;
/// 开始切换。[nowMs] 为单调时钟毫秒。
void start(int nowMs) {
if (_phase != HandoverPhase.idle) return;
_enter(HandoverPhase.decided, nowMs, '决定切换到 ${to.label}');
}
/// 新路已证通(探测成功 / 收到第一帧 / 握手完成)。
void markProven(int nowMs) {
if (_phase != HandoverPhase.decided) return;
_enter(HandoverPhase.proving, nowMs, '新路已证通');
// 证通即刻进入重叠期:旧路**先不退**,等新路真收到数据
_enter(HandoverPhase.overlapping, nowMs, '双路重叠:旧路继续');
}
/// 新路已确认承载数据(可以退旧路了)。
void markCarrying(int nowMs) {
if (_phase != HandoverPhase.overlapping) return;
_enter(HandoverPhase.retiring, nowMs, '新路已承载数据,旧路退役');
}
/// 旧路退役完成。
void markRetired(int nowMs) {
if (_phase != HandoverPhase.retiring) return;
_enter(HandoverPhase.done, nowMs, '切换完成:${to.label}');
}
/// 中止(旧路保持,不丢帧)。
void abort(int nowMs, String why) {
if (_phase == HandoverPhase.done || _phase == HandoverPhase.aborted) return;
_abortReason = why;
_enter(HandoverPhase.aborted, nowMs, '切换中止:$why(旧路保持)');
}
/// 当前相位停留过久(调用方应 abort)。
///
/// 只对"需要外部推进"的相位判卡:proving / overlapping / retiring。
bool isStalled(int nowMs) {
final watched = _phase == HandoverPhase.proving ||
_phase == HandoverPhase.overlapping ||
_phase == HandoverPhase.retiring;
return watched && nowMs - _phaseAtMs > stallMs;
}
/// 一行诊断文本(日志/UI 直接用)。
String describe() {
final fromL = from?.label ?? '无';
final toL = to.label;
return switch (_phase) {
HandoverPhase.idle => '切换[$fromL→$toL] 未开始',
HandoverPhase.decided => '切换[$fromL→$toL] 已决定',
HandoverPhase.proving => '切换[$fromL→$toL] 证通中',
HandoverPhase.overlapping => '切换[$fromL→$toL] 双路重叠',
HandoverPhase.retiring => '切换[$fromL→$toL] 旧路退役中',
HandoverPhase.done => '切换[$fromL→$toL] 完成',
HandoverPhase.aborted => '切换[$fromL→$toL] 中止($_abortReason)',
};
}
void _enter(HandoverPhase next, int nowMs, String msg) {
_phase = next;
_phaseAtMs = nowMs;
_reason = msg;
onLog?.call('handover: $msg');
}
/// 最近一次相位说明(测试与诊断用)。
String get lastEvent => _reason;
}
+253
View File
@@ -0,0 +1,253 @@
/// 连接枢纽(LinkHub)—— 选路/切换/度量/可见的观测与决策件
///
/// ── P0 阶段的边界(重要,别越界)────────────────────────────────────
/// 本阶段 LinkHub **只观测、只建议、不控制**:ViewerSession 把既有的
/// 连接事件喂进来,LinkHub 算出每条路的分数与推荐,写进诊断日志。
/// 数据面仍走 ViewerSession 的老逻辑(行为零变化,全量回归证明)。
/// P3 才把"切换"的权交给它——先让"走哪条路"变成可见、可验证的事实,
/// 再去动数据面;否则改完也说不清到底有没有生效(本项目吃过
/// "静默失败"的亏:指标静默漂移三个月没被发现)。
///
/// ── 为什么观测 API 是"事件"而不是"轮询"────────────────────────────
/// 调用方(ViewerSession)在状态变更的**原地点**喂事件,LinkHub 不
/// 起定时器、不碰 socket。代价是调用点要记得喂;收益是度量与真实
/// 状态永远同源,不会有" hub 以为连着、其实早断了"的第二套真相。
library;
import 'dart:async';
import '../host/clock.dart';
import 'link_mode.dart';
class LinkHub {
LinkHub({required this.peerDeviceId, this.onLog, int Function()? nowMs})
: _now = nowMs ?? _defaultNowMs;
/// 对端设备 id(一个 ViewerNode × 一台设备一个 hub)。
final String peerDeviceId;
/// 诊断出口(可空;空则完全安静,CLI/测试不受扰)。
final void Function(String msg)? onLog;
/// 观测时刻的时钟(**单调**)。只用于"哪次观测更新"这类同进程比较;
/// 不写线格式、不落盘(跨进程不可比,见 `host/clock.dart`)。
final int Function() _now;
// ── 内部状态:每种模式只留最新一次度量(有界,不攒历史)──────────
final Map<LinkMode, ModeScore> _scores = {};
LinkMode? _best;
LinkMode? _active;
/// 用户手动指定的模式(null = 自动)。指定后**跳过评分与迟滞**——
/// 人的判断优先于算法;但指定的路不可达时回落自动并在日志里说明
/// (不能因为用户选了一条不通的路就装死)。
LinkMode? _preferred;
final _changes = StreamController<LinkMode>.broadcast();
/// 推荐路变化时发出(P3 的切换器消费;P0 仅诊断日志)。
Stream<LinkMode> get changes => _changes.stream;
LinkMode? get bestMode => _best;
LinkMode? get activeMode => _active;
LinkMode? get preferredMode => _preferred;
/// 手动覆盖(null = 回到自动)。立即重算并广播。
void setPreferredMode(LinkMode? m) {
// 收敛到"界面能表达的档位":p2p / relay 已下线(见 link_mode.dart 的
// isModeSelectable),旧偏好或代码里硬塞的这两个值一律当**自动**处理。
final target = normalizePreferredMode(m);
if (_preferred == target) return;
_preferred = target;
_log('link: 手动模式 = ${target?.label ?? '自动'}');
recompute();
}
ModeScore? scoreOfMode(LinkMode m) => _scores[m];
// ── 观测入口(调用方在状态变更原点调用)─────────────────────────
/// 云端 socket 连上。
void observeCloudConnected() {
_put(
ModeScore(
mode: LinkMode.cloud,
reachable: true,
observedAtMs: _now(),
reason: 'socket 已连接',
),
);
}
/// 云端 socket 断开(reason 给人话:被踢/超时/主动退订)。
void observeCloudDisconnected(String reason) {
_put(
ModeScore(
mode: LinkMode.cloud,
reachable: false,
observedAtMs: _now(),
reason: reason,
),
);
}
/// 内网探测结果(`pickReachableLanEndpoint` 的归来)。
void observeLanProbe({
required bool reachable,
String? endpoint,
int? rttMs,
}) {
_put(
ModeScore(
mode: LinkMode.lan,
reachable: reachable,
rttMs: reachable ? rttMs : null,
observedAtMs: _now(),
reason: reachable ? '探测可达 $endpoint' : '探测不可达',
),
);
}
/// 给**已有度量**的路补一个 RTT 观测(心跳往返/帧间隔均可作来源)。
///
/// 只更新、不创建:没有度量说明还没观测过连通性,凭空一个 RTT
/// 不能证明"通"(本项目纪律:度量必须来自事实,不猜)。
/// 没有该模式的度量时静默忽略。
void observeRtt(LinkMode mode, int rttMs) {
final prev = _scores[mode];
if (prev == null) return;
_put(
ModeScore(
mode: mode,
reachable: prev.reachable,
rttMs: rttMs,
lossPct: prev.lossPct,
observedAtMs: _now(),
reason: prev.reason,
),
);
}
/// P2P 打洞结果(数据面会话开/关时各喂一次)。
void observeP2pProbe({required bool reachable, int? rttMs, String reason = ''}) {
_put(ModeScore(
mode: LinkMode.p2p,
reachable: reachable,
rttMs: reachable ? rttMs : null,
observedAtMs: _now(),
reason: reason.isEmpty ? (reachable ? '打洞成功' : '打洞失败') : reason,
));
}
/// 内网数据面开启(`_lanMode = true` 的那个点)。
void observeLanDataOn() {
_active = LinkMode.lan;
_log('link: 数据面切到内网');
}
/// 内网数据面关闭并回落云端(`_lanMode = false` 的那个点)。
void observeLanDataOff(String reason) {
if (_active == LinkMode.lan) {
_active = LinkMode.cloud;
_log('link: 数据面回落云端($reason)');
}
}
/// 收到一帧,顺带说明它从哪条路来(推荐活跃路的唯一依据)。
void observeFrame(LinkMode via) {
_active = via;
}
// ── 决策 ────────────────────────────────────────────────────────
/// 重新计算推荐路并返回是否发生变化。
///
/// 规则(可单测,见 test/link_hub_test.dart):
/// ① 只在**可达**的路里选;
/// ② 按原始分排序,最高者为候选;
/// ③ 换推荐必须超出「现任分数 + [kStickinessBonus]」——这是**唯一**的
/// 防抖机制(曾另写"最小领先 3 分",被本机制吞掉成死代码,已删);
/// ④ 一条都不通 → 推荐 null(界面说"全网不通",别装还有路)。
LinkMode? recompute() {
// 手动覆盖:该路有可达度量就直接用它;不可达 → 回落自动并说明
final pref = _preferred;
if (pref != null) {
final ps = _scores[pref];
// isModeSelectable 是防御性复检:正常路径已由 setPreferredMode 收敛,
// 但 `_preferred` 若被别处直接改写,不能让它把已下线的档位变成推荐。
if (ps != null && ps.reachable && isModeSelectable(pref)) {
return _setBest(pref, null);
}
_log('link: 手动指定的 ${pref.label} 当前不可达,回落自动选路');
}
final candidates = _scores.values
// 推荐**不得产出已下线的档位**:p2p 的候选通道尚未打通
// (2026-10-25 决策,见 link_mode.dart),推荐它等于把用户
// 领到一条走不通的路上。这里只排除"产出",**不改评分权重** ——
// 权重是成本语义的一部分,将来接线后要原样生效。
.where((s) => s.reachable && isModeSelectable(s.mode))
.toList();
if (candidates.isEmpty) {
return _setBest(null, '没有可达的路');
}
candidates.sort((a, b) => scoreOf(b) - scoreOf(a));
final top = candidates.first;
if (_best == null || top.mode == _best) return _setBest(top.mode, null);
final current = _scores[_best];
if (current != null && current.reachable) {
if (scoreOf(top) <= scoreOf(current) + kStickinessBonus) {
return _best; // 迟滞:领先不超过黏性加成,维持现任
}
}
return _setBest(top.mode, null);
}
LinkMode? _setBest(LinkMode? next, String? why) {
if (next == _best) return _best;
final from = _best?.label ?? '无';
final to = next?.label ?? '无';
_best = next;
_log('link: 推荐路 $from → $to${why == null ? '' : '($why)'}');
if (next != null) _changes.add(next);
return _best;
}
// ── 度量出口 ────────────────────────────────────────────────────
/// 一行诊断文本(界面/日志直接显示)。
///
/// 形如:`link[dev-1] 活跃=内网 推荐=内网 | 云端 62ms/72 内网 8ms/88`
String summary() {
final parts = _scores.values
.map((s) {
final rtt = s.rttMs == null ? '—' : '${s.rttMs}ms';
return '${s.mode.label} $rtt/${s.reachable ? scoreOf(s) : '不通'}';
})
.join(' ');
final manual = _preferred == null ? '' : ' 手动=${_preferred!.label}';
return 'link[$peerDeviceId] 活跃=${_active?.label ?? '无'} '
'推荐=${_best?.label ?? '无'}$manual | $parts';
}
void dispose() {
_changes.close();
}
void _put(ModeScore s) {
_scores[s.mode] = s;
recompute();
}
void _log(String msg) => onLog?.call(msg);
}
/// 默认时钟:**进程级单调时钟**(见 `host/clock.dart`)。
///
/// 为什么不能用 `DateTime.now()`:改系统时间(NTP 校时/用户手改)会让
/// `observedAtMs` 跳变 —— 回拨时"最近活跃"会变负、前跳时会瞬间显示过期。
/// 这些字段只做**同一进程内**的先后比较,本就该用单调时钟。
///
/// ⚠️ 历史教训:这里曾经注释写着"刻意不用 DateTime.now()",实现却是
/// `DateTime.now().millisecondsSinceEpoch`,函数名还叫 `_monotonicishMs`
/// (假单调)—— 注释把正确意图写对了,代码没有。现已改为真实现
/// (`Stopwatch` 增量),并由 `test/clock_test.dart` 锁定。
int _defaultNowMs() => processNowMs();
+126
View File
@@ -0,0 +1,126 @@
/// 连接模式词汇与评分 —— 连接域的唯一字典
///
/// 四种模式(云端 / 内网 / P2P / 中继)的命名、度量与排序规则只许有
/// 这一份。界面显示、诊断日志、将来的选路决策都用这里的词,
/// 不允许各处各写一套(本项目为"同一模式两个名字"吃过亏)。
library;
/// 一条数据路的模式。
///
/// 顺序即成本从低到高(内网免费 → P2P 廉价 → 云端走服务器流量包 →
/// 中继烧双倍流量),评分与界面排序都引用这个次序。
enum LinkMode {
/// 云端订阅:查看端 socket.io 到 gateway,默认路
cloud,
/// 内网直连:同一 Wi-Fi 下点对点,指标面已实现(LAN 优先)
lan,
/// P2P 打洞:跨网经 STUN 候选直连(P2 阶段实现)
///
/// 待接线:候选通道尚未打通(2026-10-25 决策:暂不上线),UI 已隐藏该档位。
/// 枚举值**保留**:协议词汇、评分表与既有测试都引用它;
/// 隐藏的是一个界面上"选了也不生效"的选项,不是这个词汇本身。
p2p,
/// 服务器中转:relay open/PUT/GET,烧服务器流量,最后兜底
///
/// 待接线:中继目前只服务**文件面**(跨网兜底传文件),指标面没有走它
/// (2026-10-25 决策:指标路菜单里不再给它位置 —— 那同样是"界面在说谎")。
relay;
String get label => switch (this) {
cloud => '云端',
lan => '内网',
p2p => 'P2P',
relay => '中继',
};
}
/// 这条路的档位在**界面上是否可被用户选择/生效**。
///
/// 2026-10-25 决策:P2P(候选通道未打通)与中继(只服务文件面)从指标路菜单下线。
/// 判断只有一个来源(就是这里),避免"菜单藏了、别处还在用"的半下线状态。
bool isModeSelectable(LinkMode mode) => switch (mode) {
LinkMode.p2p || LinkMode.relay => false,
LinkMode.cloud || LinkMode.lan => true,
};
/// 把**任何来源**的偏好收敛成"界面能表达的那个值"。
///
/// 收敛点只有一个:存量的 `p2p`/`relay` 偏好(旧版本写下的、或代码里硬塞的)
/// 一律按 **null = 自动** 处理。
/// 为什么不是"保留但忽略":那样界面上会出现一个**选不回来、也不生效**的档位,
/// 用户点开菜单看到的勾选和他实际走的路径不一致 —— 这就是"界面在说谎"。
LinkMode? normalizePreferredMode(LinkMode? mode) =>
mode == null || isModeSelectable(mode) ? mode : null;
/// 一条路的度量快照。**不可变**——每次观测产生一个新对象,
/// 避免"半个旧值半个新值"的中间态(跨层状态不同步是本项目老大难)。
class ModeScore {
const ModeScore({
required this.mode,
required this.reachable,
this.rttMs,
this.lossPct = 0,
this.observedAtMs = 0,
this.reason = '',
});
final LinkMode mode;
/// 通不通。**评分的第一道门槛**:不通的路分数再高也不参与排序。
final bool reachable;
/// 最近一次往返毫秒数;没有测量则为 null(不猜 0,见项目"诚实边界"纪律)。
final int? rttMs;
/// 丢包率 0–100(整数百分比)。
final int lossPct;
/// 最近一次成功观测的单调时钟(毫秒);0 = 从未观测过。
final int observedAtMs;
/// 人话说明当前为什么是这个状态(给诊断日志用)。
final String reason;
bool get hasRtt => rttMs != null;
}
/// 模式的成本权重(分)。轻量服务器按流量计费,中继烧双倍,
/// 所以同样连通质量下内网必须赢过中继——不把成本写进分数,
/// "自动选路"就会在半夜把流量包烧光。
int costWeight(LinkMode mode) => switch (mode) {
LinkMode.lan => 0,
LinkMode.p2p => 5,
LinkMode.cloud => 15,
LinkMode.relay => 30,
};
/// 给一条**可达**的路打分(0–100,越高越优)。
///
/// 公式(刻意简单,三个可解释的部件):
/// 基础 100
/// − RTT 惩罚 :每 30ms 扣 1 分,封顶 300ms(再高就是不通,别细抠)
/// − 丢包惩罚 :每 1% 扣 2 分
/// − 成本权重 :见 [costWeight]
/// 没有 RTT 测量时按 150ms 中性值计(探测成功但没量过),不奖不罚。
int scoreOf(ModeScore s) {
if (!s.reachable) return -1;
final rtt = s.rttMs ?? 150;
final rttPenalty = (rtt.clamp(0, 300) / 30).round();
final lossPenalty = s.lossPct.clamp(0, 100) * 2;
final raw = 100 - rttPenalty - lossPenalty - costWeight(s.mode);
return raw.clamp(0, 100);
}
/// 黏性加成(分):换推荐时,新路必须超出「现任分数 + 本加成」才生效。
///
/// 为什么必须有:两条路分数接近时,没有迟滞就会**来回抖**
/// (云端 78 / 内网 80 → 切内网 → 内网 79 / 云端 78 → 切回……),
/// 每次切换都是双路重叠与退订握手。8 分约等于"领先半个 RTT 档",
/// 足以压住测量噪声,又不至于让一条明显更差的路赖着不走。
///
/// 这是**唯一**的防抖机制(曾同时写过"最小领先 3 分"的检查,被黏性
/// 排序吞掉成为死代码——两个重叠机制必删一个,删的是检查、留的是这个)。
const int kStickinessBonus = 8;
+461
View File
@@ -0,0 +1,461 @@
/// P2P 驱动 —— UDP 打洞(同时发包,先通先赢)
///
/// ── 待接线(2026-10-25 决策:暂不上线,UI 已隐藏 p2p 档位)──────────
/// **生产路径未调用**:`P2PDriver` 只在测试(`p2p_driver_test.dart` /
/// `p2p_session_test.dart`)里被驱动;`stun.dart` 的 `stunDiscover()` 唯一
/// 调用点同样是测试,且 `publishOffer` 的两处注入点都没传 `candidates`。
/// 也就是说:**代码是完整的,通道是断的** —— 缺的是"上报候选 + 真机跨网验证"
/// 这两步,不是本文件的逻辑。
/// 决策理由(2.6.2):P2P 只在"两台都在 NAT 后且同账号"这一窄场景生效,
/// 且验证只能跨网真机做、CI 守不住(典型"接了也没人知道它还活着");同样的
/// 3–5 人日投到 Linux/Windows 扩面是同构扩展、CI 可守、覆盖设备数直接翻倍。
/// 接线时按顺序补:STUN 配置 → 候选上报(host/srflx)→ publishOffer 带 candidates
/// → 真机跨网验证。在那之前**不要**把这个驱动接进任何生产路径。
///
/// ── 打洞在做什么(一句话)─────────────────────────────────────────
/// 双方各从自己的 NAT 后方**同时**向对方的候选地址发包。第一包会在
/// 各自的 NAT 上凿出一个映射;对方的包随后从那个孔进来 —— 两边就通了。
/// 关键在"同时":只一方发包,包会被 NAT 丢掉(没有对应映射)。
///
/// ── 协议(刻意极简)────────────────────────────────────────────────
/// `PUNCH:<nonce>` → 对端收到后回 `PONG:<nonce>`
/// `PONG:<nonce>` → 收到即成功(双向都已打通)
/// 为什么不上加密/签名:**这一层只做"孔通不通"的判定**,不传数据;
/// 真正的数据面若走 P2P,鉴权在领取令牌/设备令牌那一层(与 relay 同源)。
/// 收到任何非 PUNCH/PONG 的包一律忽略——否则一个随机 UDP 扫描就能
/// 让"打洞成功"变成谎言。
///
/// ── 诚实边界 ────────────────────────────────────────────────────────
/// 对称 NAT 下打洞大概率失败(每个目标一个映射,孔对不上)。本驱动
/// 如实返回失败,由上层(LinkHub/P3)落中继 —— 不假装成功。
/// 打洞窗口默认 3s:跨网 RTT 几十毫秒,150ms 一包足够 200 轮。
library;
import 'dart:async';
import 'dart:io';
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import '../net/candidates.dart';
import 'driver.dart';
import 'link_mode.dart';
/// 打洞包前缀(文件级:驱动与会话共用同一套协议)。
const String _punchPrefix = 'INKWATCH-PUNCH:';
const String _pongPrefix = 'INKWATCH-PONG:';
/// 一次打洞的结果。
class PunchResult {
const PunchResult({
required this.ok,
this.peerEndpoint,
this.rttMs,
this.reason = '',
this.tried = const [],
});
final bool ok;
/// 打通的对端地址(`ip:port`);失败为 null。
final String? peerEndpoint;
/// 首次收到回包的大致往返(毫秒);语义上是"打到通用了多久"。
final int? rttMs;
final String reason;
/// 试过的候选 endpoint(诊断用,界面上能说"试过哪几条")。
final List<String> tried;
}
class P2pDriver implements TransportDriver {
P2pDriver({
required List<Candidate> peerCandidates,
this.onLog,
this.localPort = 0,
}) : _candidates = List.unmodifiable(peerCandidates);
final List<Candidate> _candidates;
final void Function(String msg)? onLog;
/// 本地绑定端口;0 = 临时端口(生产用),固定端口仅测试用。
final int localPort;
@override
LinkMode get mode => LinkMode.p2p;
List<Candidate> get peerCandidates => _candidates;
@override
Future<DriverProbe> probe() async {
if (_candidates.isEmpty) {
return const DriverProbe(
mode: LinkMode.p2p,
reachable: false,
reason: '没有对端候选',
);
}
// 探活就是一次短窗口打洞:UDP 不烧服务器配额,代价可接受。
final r = await punch(window: const Duration(milliseconds: 500));
return DriverProbe(
mode: LinkMode.p2p,
reachable: r.ok,
rttMs: r.rttMs,
reason: r.ok ? '打洞成功 ${r.peerEndpoint}' : r.reason,
);
}
/// 同时向所有候选发包,窗口内收到对端任何 PUNCH/PONG 即成功。
Future<PunchResult> punch({
Duration window = const Duration(seconds: 3),
Duration interval = const Duration(milliseconds: 150),
}) async {
if (_candidates.isEmpty) {
return const PunchResult(ok: false, reason: '没有对端候选');
}
final tried = _candidates.map((c) => c.endpoint).toList();
final nonce = _newNonce();
final punchPacket = _encode('$_punchPrefix$nonce');
final pongPacket = _encode('$_pongPrefix$nonce');
RawDatagramSocket? socket;
StreamSubscription<RawSocketEvent>? sub;
Timer? ticker;
Timer? deadline;
try {
socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, localPort);
final completer = Completer<PunchResult>();
final started = DateTime.now();
final dests = <({InternetAddress addr, int port})>[];
for (final c in _candidates) {
final a = InternetAddress.tryParse(c.ip);
if (a != null) dests.add((addr: a, port: c.port));
}
if (dests.isEmpty) {
return PunchResult(ok: false, reason: '候选地址全都解析不了', tried: tried);
}
void fire() {
if (completer.isCompleted) return;
for (final d in dests) {
try {
socket?.send(punchPacket, d.addr, d.port);
} catch (_) {}
}
}
sub = socket.listen((e) {
if (e != RawSocketEvent.read) return;
final dg = socket?.receive();
if (dg == null) return;
final text = String.fromCharCodes(dg.data);
if (text.startsWith(_pongPrefix) || text.startsWith(_punchPrefix)) {
// 收到对端的包 ⇒ 这个方向上 NAT 的孔已经通了。
// 若是对方先发的 PUNCH,回一包 PONG 让对方也确认。
if (text.startsWith(_punchPrefix)) {
try {
socket?.send(pongPacket, dg.address, dg.port);
} catch (_) {}
}
if (!completer.isCompleted) {
final ms = DateTime.now().difference(started).inMilliseconds;
_log('p2p: 打通 ${dg.address.address}:${dg.port}(${ms}ms)');
completer.complete(PunchResult(
ok: true,
peerEndpoint: '${dg.address.address}:${dg.port}',
rttMs: ms,
reason: '打通',
tried: tried,
));
}
}
// 其它任何包:忽略(防随机 UDP 扫描冒充打通)
});
fire();
ticker = Timer.periodic(interval, (_) => fire());
deadline = Timer(window, () {
if (!completer.isCompleted) {
_log('p2p: ${window.inMilliseconds}ms 内未打通(候选 ${tried.length} 条)');
completer.complete(PunchResult(
ok: false,
reason: '${window.inMilliseconds}ms 内未打通',
tried: tried,
));
}
});
return await completer.future;
} catch (e) {
return PunchResult(ok: false, reason: '打洞异常: $e', tried: tried);
} finally {
ticker?.cancel();
deadline?.cancel();
await sub?.cancel();
socket?.close();
}
}
@override
Future<void> close() async {
// 每次 punch 自建自收 socket,没有常驻资源。幂等。
}
static Uint8List _encode(String s) =>
Uint8List.fromList(s.codeUnits);
static String _newNonce() {
final r = Random.secure();
return List<int>.generate(8, (_) => r.nextInt(256))
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
}
void _log(String msg) => onLog?.call(msg);
}
// ══════════════════════════════════════════════════════════════════════
// P2pSession —— 打通之后**保留 socket** 的数据面
//
// 为什么必须保留:NAT 的映射绑在**本地端口**上。打洞用的 socket 一关,
// 端口释放、映射消失,再开一个新端口等于重新打一次洞。所以:
// · 采集端:常驻一个 socket(P2pServer),把它的端口当候选上报;
// · 查看端:打洞成功后不关 socket,直接在上面拉帧。
//
// 帧协议(单包,UDP):
// `INKWATCH-FRAME-REQ:<nonce>` → 查看端拉最新一帧
// `INKWATCH-FRAME:<nonce>:<json>` → 采集端回帧(payload 与云端
// `server:metrics` 同形,查看端
// 两套数据源共用一套解析)
// nonce 必须原样回显:迟到的/串包的响应一律丢弃(同 STUN 事务号的理由)。
// 单包上限 [kMaxFramePacket]:超过就当没收(UDP 拍大包会分片,丢一片就废)。
// ══════════════════════════════════════════════════════════════════════
/// 单包上限(UDP 安全 MTU 以内)。指标帧约几百字节,够用绰绰有余。
const int kMaxFramePacket = 8000;
/// 打通后持有 socket 的 P2P 会话。
class P2pSession {
P2pSession._(this._socket, this.peerEndpoint, this.rttMs);
final RawDatagramSocket _socket;
final String peerEndpoint;
final int? rttMs;
InternetAddress? _peerAddr;
int _peerPort = 0;
StreamSubscription<RawSocketEvent>? _sub;
/// 服务端模式(采集端):收到 REQ 就调它取最新帧。
Map<String, dynamic>? Function()? _frameProvider;
final _waiters = <String, Completer<Map<String, dynamic>?>>{};
static const String _reqPrefix = 'INKWATCH-FRAME-REQ:';
static const String _framePrefix = 'INKWATCH-FRAME:';
/// 本机绑定端口(上报给对端的候选端口)。
int get localPort => _socket.port;
/// 打开会话:先打洞,成功后**不关 socket**。
///
/// 失败返回 null(对称 NAT 等),调用方保持原路。
static Future<P2pSession?> open({
required List<Candidate> peerCandidates,
int localPort = 0,
Duration window = const Duration(seconds: 3),
Duration interval = const Duration(milliseconds: 150),
void Function(String msg)? onLog,
}) async {
if (peerCandidates.isEmpty) return null;
RawDatagramSocket? socket;
StreamSubscription<RawSocketEvent>? sub;
Timer? ticker;
Timer? deadline;
try {
socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, localPort);
final dests = <({InternetAddress addr, int port})>[];
for (final c in peerCandidates) {
final a = InternetAddress.tryParse(c.ip);
if (a != null) dests.add((addr: a, port: c.port));
}
if (dests.isEmpty) {
socket.close();
return null;
}
final nonce = _newNonce();
final punch = _encode('$_punchPrefix$nonce');
final pong = _encode('$_pongPrefix$nonce');
final completer = Completer<P2pSession?>();
final started = DateTime.now();
InternetAddress? peerAddr;
int peerPort = 0;
// ★ 只有一个监听器,从头活到尾。
// RawDatagramSocket 是**单订阅流**:cancel 之后不能再 listen
// (会抛 "Stream has already been listened to"),所以不能
// "打洞一个监听、数据面换一个监听"——只能在同一处按前缀分派。
late final P2pSession session;
sub = socket.listen((e) {
if (e != RawSocketEvent.read) return;
final dg = socket?.receive();
if (dg == null) return;
final text = String.fromCharCodes(dg.data);
if (text.startsWith(_punchPrefix) || text.startsWith(_pongPrefix)) {
if (text.startsWith(_punchPrefix)) {
try {
socket?.send(pong, dg.address, dg.port);
} catch (_) {}
}
peerAddr = dg.address;
peerPort = dg.port;
if (!completer.isCompleted) {
session = P2pSession._(
socket!,
'${dg.address.address}:${dg.port}',
DateTime.now().difference(started).inMilliseconds,
)
.._peerAddr = peerAddr
.._peerPort = peerPort
.._sub = sub;
completer.complete(session);
}
return;
}
// 数据面包:会话已建立就交给分派器;未建立则忽略
if (completer.isCompleted) session._handleDatagram(dg);
});
void fire() {
if (completer.isCompleted) return;
for (final d in dests) {
try {
socket?.send(punch, d.addr, d.port);
} catch (_) {}
}
}
fire();
ticker = Timer.periodic(interval, (_) => fire());
deadline = Timer(window, () {
if (!completer.isCompleted) {
onLog?.call('p2p: 数据面打洞失败(窗口 ${window.inMilliseconds}ms)');
completer.complete(null);
}
});
final opened = await completer.future;
ticker.cancel();
deadline.cancel();
if (opened == null) {
await sub.cancel();
socket.close();
return null;
}
onLog?.call('p2p: 数据面会话就绪 → ${opened.peerEndpoint}');
return opened;
} catch (e) {
ticker?.cancel();
deadline?.cancel();
await sub?.cancel();
socket?.close();
onLog?.call('p2p: 数据面异常 $e');
return null;
}
}
/// 采集端:开始应答拉帧请求(阻塞式,直到 [close])。
///
/// 同一个 socket 继续收包——**打洞的映射靠它维持**,不能换。
void startServing(Map<String, dynamic>? Function() provider) {
_frameProvider = provider;
}
/// 查看端:按需拉一帧。超时/没数据返回 null(调用方保持原路)。
Future<Map<String, dynamic>?> requestFrame({
Duration timeout = const Duration(seconds: 2),
}) async {
final addr = _peerAddr;
if (addr == null) return null;
final nonce = _newNonce();
final c = Completer<Map<String, dynamic>?>();
_waiters[nonce] = c;
try {
_socket.send(_encode('$_reqPrefix$nonce'), addr, _peerPort);
} catch (_) {
_waiters.remove(nonce);
return null;
}
final t = Timer(timeout, () {
if (!c.isCompleted) c.complete(null);
});
try {
return await c.future;
} finally {
t.cancel();
_waiters.remove(nonce);
}
}
void _handleDatagram(Datagram dg) {
if (dg.data.length > kMaxFramePacket) return;
final text = String.fromCharCodes(dg.data);
if (text.startsWith(_reqPrefix)) {
final provider = _frameProvider;
if (provider == null) return;
final frame = provider();
if (frame == null) return; // 还没采到帧:不回(调用方按超时处理)
final nonce = text.substring(_reqPrefix.length);
try {
final json = jsonEncode(frame);
if (json.length + _framePrefix.length + nonce.length > kMaxFramePacket) {
return; // 超包:宁可不回,也不发分片包
}
_socket.send(_encode('$_framePrefix$nonce:$json'), dg.address, dg.port);
} catch (_) {}
return;
}
if (text.startsWith(_framePrefix)) {
final rest = text.substring(_framePrefix.length);
final sep = rest.indexOf(':');
if (sep <= 0) return;
final nonce = rest.substring(0, sep);
final waiter = _waiters[nonce];
if (waiter == null || waiter.isCompleted) return; // 迟到/串包:丢
try {
final j = jsonDecode(rest.substring(sep + 1));
waiter.complete(j is Map<String, dynamic> ? j : null);
} catch (_) {
waiter.complete(null);
}
return;
}
// 打洞包在会话期可能还有迟到重传:回一个 PONG 维持映射,不触发事件
if (text.startsWith(_punchPrefix)) {
final nonce = text.substring(_punchPrefix.length);
try {
_socket.send(_encode('$_pongPrefix$nonce'), dg.address, dg.port);
} catch (_) {}
}
}
/// 释放(幂等)。关闭后 NAT 映射随之失效,需要重新打洞。
Future<void> close() async {
await _sub?.cancel();
_sub = null;
for (final w in _waiters.values) {
if (!w.isCompleted) w.complete(null);
}
_waiters.clear();
_socket.close();
}
static Uint8List _encode(String s) => Uint8List.fromList(s.codeUnits);
static String _newNonce() =>
List<int>.generate(8, (_) => Random.secure().nextInt(256))
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
}
+127
View File
@@ -0,0 +1,127 @@
/// 中继驱动 —— 四种模式里**唯一有真实服务端**的兜底路
///
/// ── 它到底收口了什么 ────────────────────────────────────────────────
/// 中继的客户端实现在 P1 之前是三块散着的:`openRelayForSend`(host/
/// lan_service.dart,凭据取自单例)、`uploadViaRelay`/`downloadViaRelay`
/// (同上)、以及 transfer_card 里的编排。本驱动把前两块收进连接域,
/// **不重写任何传输逻辑**——委托既有函数,行为零变化;新增的只有:
/// · `probe()`:GET /health 探活,**不烧配额**(open 会占并发闸,
/// 探活若走 open,选路一频繁用户就永远"请等前一条结束");
/// · 一处集中的失败翻译(四道闸的中文原因原样上行)。
library;
import 'dart:convert';
import 'dart:io';
import '../net/relay_client.dart';
import 'driver.dart';
import 'link_mode.dart';
class RelayDriver implements TransportDriver {
RelayDriver({
required this.serverBase,
this.deviceId,
this.deviceToken,
this.onLog,
});
/// 网关基址(例:`http://host:3000`)。
final String serverBase;
final String? deviceId;
final String? deviceToken;
final void Function(String msg)? onLog;
@override
LinkMode get mode => LinkMode.relay;
/// 探活:GET /health。**不碰 /relay/open**(那会占并发闸与配额)。
///
/// 只认 `ok: true` 且 http 200——网关在 Redis 掉线时 health 仍可 200
/// (degraded),那种状态开不出中继,按"不可达"算更诚实。
@override
Future<DriverProbe> probe({
Duration timeout = const Duration(seconds: 3),
}) async {
final sw = Stopwatch()..start();
final client = HttpClient()..connectionTimeout = timeout;
try {
final req = await client
.getUrl(Uri.parse('${_trimBase(serverBase)}/health'))
.timeout(timeout);
final resp = await req.close().timeout(timeout);
final body = await resp
.transform(const Utf8Decoder(allowMalformed: true))
.join()
.timeout(timeout);
sw.stop();
final ok = resp.statusCode == 200 && body.contains('"ok":true');
return DriverProbe(
mode: LinkMode.relay,
reachable: ok,
rttMs: ok ? sw.elapsedMilliseconds : null,
reason: ok ? 'health ok' : 'health ${resp.statusCode}',
);
} catch (e) {
return DriverProbe(
mode: LinkMode.relay,
reachable: false,
reason: 'health 探测失败: $e',
);
} finally {
client.close(force: true);
}
}
/// 为一次发送开中继,返回**塞进 offer 的那半**与**本地推文件的那半**。
///
/// 凭据缺一就抛 StateError(人话)——界面要能原样告诉用户为什么传不了。
/// 服务端的四道闸越界时,openLanRelay 已把中文原因抛出,这里不吞。
Future<({String id, String downloadToken, int bytesPerSec, LanRelayChannel upload})>
openForOffer({required int size}) async {
final id = deviceId;
final tok = deviceToken;
if (id == null || id.isEmpty || tok == null || tok.isEmpty) {
throw StateError('中继不可用:本机还没连上服务器');
}
final ch = await openLanRelay(
serverBase: serverBase,
deviceId: id,
deviceToken: tok,
size: size,
);
_log('中继已开 id=${ch.upload.id} bps=${ch.upload.bytesPerSec}');
return (
id: ch.download.id,
downloadToken: ch.download.token,
bytesPerSec: ch.download.bytesPerSec,
upload: ch.upload,
);
}
/// 经中继上传(委托既有实现,含"等接收端先到"的轮询)。
Future<void> upload({
required LanRelayChannel channel,
required String path,
void Function(int sent, int total)? onProgress,
}) =>
uploadViaRelay(channel: channel, path: path, onProgress: onProgress);
/// 经中继下载(委托既有实现)。
Future<LanDownloadResult> download({
required LanRelayChannel channel,
required String savePath,
}) =>
downloadViaRelay(channel: channel, savePath: savePath);
@override
Future<void> close() async {
// 中继通道是"用完即弃"的服务端会话(对端断开/超时即回收),
// 客户端没有需要主动关闭的持久资源;幂等返回即可。
_log('中继驱动关闭');
}
String _trimBase(String s) =>
s.endsWith('/') ? s.substring(0, s.length - 1) : s;
void _log(String msg) => onLog?.call(msg);
}
+462 -187
View File
File diff suppressed because it is too large. Load diff
+115
View File
@@ -0,0 +1,115 @@
/// 连接候选(ICE 式候选的**最小子集**)—— host / srflx 两类
///
/// ── 为什么只要两类 ──────────────────────────────────────────────────
/// 完整的 ICE 有 host / srflx / prflx / relay 四类候选,还带优先级、
/// foundation、component。本项目的场景是「两台设备之间传文件/看指标」,
/// 不是浏览器媒体协商:
/// · host :本机内网地址(同网段直连用,不依赖 STUN)
/// · srflx :经 STUN 反射出的**公网映射地址**(跨网打洞用)
/// · relay :本项目已有独立的中继通道(服务器 relay),**不需要 ICE
/// relay 候选** —— 那会再造一套等价的机制。
/// · prflx :打洞过程中对端学习到的地址,运行期内部使用,不进 offer。
///
/// ── 形状纪律 ────────────────────────────────────────────────────────
/// 候选会被**服务端校验后转发给对方**,对端据此发包。所以:
/// · host 候选只收私有网段(与 lan_endpoints 同一条禁令);
/// · srflx 候选只收公网(与 wan_endpoints 同一条禁令:拒回环/元数据);
/// · 数量上限 8、端口 1–65535、IP 只收 IPv4(与项目其余部分一致)。
/// 服务端的同一份规则在 `server/shared/lan-endpoint.js` 的
/// `normalizeCandidates`——**跨语言两份实现必须同步**,由
/// schema-consistency 的跨语言段守着(见 P2.3 的测试)。
library;
/// 候选类型。序列化用短字符串(`host` / `srflx`),与 ICE 词汇一致。
enum CandidateType {
host,
srflx;
static CandidateType? parse(Object? v) => switch (v) {
'host' => CandidateType.host,
'srflx' => CandidateType.srflx,
_ => null,
};
String get wire => name;
}
/// 一条连接候选。不可变。
class Candidate {
const Candidate({
required this.type,
required this.ip,
required this.port,
});
final CandidateType type;
final String ip;
final int port;
/// `ip:port` 形式(探测与展示都用它)。
String get endpoint => '$ip:$port';
/// 同一地址的两种类型只留 srflx(公网映射优先尝试——host 在跨网时不可达)。
/// 用于去重:同 endpoint 的 host 与 srflx 不重复探测。
bool get isPublic => type == CandidateType.srflx;
Map<String, Object?> toJson() => {
'type': type.wire,
'ip': ip,
'port': port,
};
@override
bool operator ==(Object other) =>
other is Candidate && other.type == type && other.ip == ip && other.port == port;
@override
int get hashCode => Object.hash(type, ip, port);
@override
String toString() => '${type.wire} $ip:$port';
/// 解析一条候选;形状不对返回 null(**宁可当作没收到**,不把半个对象
/// 交给打洞器——打洞器会朝它发包,脏地址等于替攻击者打探测)。
static Candidate? parse(Object? v) {
if (v is! Map) return null;
final t = CandidateType.parse(v['type']);
final ip = v['ip'];
final port = v['port'];
if (t == null || ip is! String || port is! int) return null;
if (!_isIpv4(ip)) return null;
if (port < 1 || port > 65535) return null;
return Candidate(type: t, ip: ip, port: port);
}
/// 解析候选列表:上限 [kMaxCandidates],同 endpoint 去重(srflx 优先)。
static List<Candidate> parseList(Object? raw) {
if (raw is! List) return const [];
final out = <Candidate>[];
for (final v in raw) {
if (out.length >= kMaxCandidates) break;
final c = parse(v);
if (c == null) continue;
final dup = out.indexWhere((e) => e.endpoint == c.endpoint);
if (dup >= 0) {
if (c.isPublic && !out[dup].isPublic) out[dup] = c;
continue;
}
out.add(c);
}
return out;
}
}
/// 候选数量上限(与服务端 MAX_CANDIDATES 一致)。
const int kMaxCandidates = 8;
bool _isIpv4(String s) {
final m = RegExp(r'^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$').firstMatch(s);
if (m == null) return false;
for (var i = 1; i <= 4; i++) {
final n = int.tryParse(m.group(i)!);
if (n == null || n > 255) return false;
}
return true;
}
+263
View File
@@ -0,0 +1,263 @@
/// 连接邀约(offer)与领取码 —— 两端共用的同一份编解码
///
/// 「去哪取、凭什么取」的编码/解析,以及服务端 `server:offer` 信封的解析。
/// 采集端与查看端收到的是同一形状,各写一份迟早漂移(本项目为此吃过
/// 多次亏),故收敛在此。
/// 2026-10-17 从 `host/lan_service.dart` 原样抽出(纯搬迁,行为不变),
/// 原文件以 export 继续对外暴露,既有调用方零改动。
library;
import 'dart:convert';
import 'candidates.dart';
// ══════════════════════════════════════════════════════════════════════
// 领取码 —— 把"去哪取、凭什么取"编码成一段可以转给对方的字符串
//
// ── 为什么用码,而不是走服务端信令 ──────────────────────────────────
// 走服务端最顺(对端直接弹出来),但需要给采集端加寻址 + 改下游事件白名单 + 部署。
// 先用"码"把**可用闭环**做出来:发送方出示、接收方粘贴,
// 数据仍然是**两端直连、不经服务器**。体验优化留给下一步。
//
// 码里放什么:端点、令牌、**文件名与大小**(接收方在下载前就能看到"要收什么",
// 这是"先确认再收"的前提)。**不放哈希** —— 完整哈希从响应头拿,
// 放进码里只会让它变长而不增加保证。
// ══════════════════════════════════════════════════════════════════════
const String _kTicketPrefix = 'INKW-FT1.';
/// 把"去哪取、凭什么取"编成领取码。
String encodeLanTicket({
required String endpoint,
required String token,
required String name,
required int size,
}) {
final payload = jsonEncode({'e': endpoint, 't': token, 'n': name, 's': size});
return _kTicketPrefix +
base64Url.encode(utf8.encode(payload)).replaceAll('=', '');
}
/// 解析领取码。
///
/// **对垃圾输入必须给明确结果而不是抛得莫名其妙** —— 用户是从聊天窗口里
/// 复制粘贴过来的,多一个空格、少一个字符都很常见。
/// 返回 null 表示"这不是一个有效的领取码",调用方据此给出人话提示。
LanTicket? parseLanTicket(String raw) {
final s = raw.trim();
if (!s.startsWith(_kTicketPrefix)) return null;
final body = s.substring(_kTicketPrefix.length);
if (body.isEmpty) return null;
try {
// base64Url.decode 要求长度合规;补回填充再解
final padded = body + '=' * ((4 - body.length % 4) % 4);
final decoded = utf8.decode(base64Url.decode(padded));
final j = jsonDecode(decoded);
if (j is! Map) return null;
final e = j['e'];
final t = j['t'];
final n = j['n'];
final sz = j['s'];
if (e is! String || t is! String || n is! String || sz is! int) return null;
if (e.isEmpty || t.isEmpty || sz < 0) return null;
// ★ 端点必须**只允许私有网段 IPv4**。
//
// 这一条是安全底线,不是"格式检查":码是**从外部粘贴进来**的,
// 里面的端点会被拿去做 HTTP 请求 —— 若放行任意主机名/公网地址,
// 粘贴一条码就等于让应用去连任意地址(SSRF 面)。
// 而且本功能的前提就是"同一局域网",公网地址本来也不该出现在这里。
final parsed = _parsePrivateV4Endpoint(e);
if (parsed == null) return null;
// 令牌字符集收一道:它会被拼进 URL 路径,不能让奇怪东西混进去
if (!RegExp(r'^[A-Za-z0-9_\-]{8,64}$').hasMatch(t)) return null;
return LanTicket(endpoint: parsed, token: t, name: n, size: sz);
} catch (_) {
return null; // 不是 base64、不是 JSON、字段类型不对 —— 统统算"无效码"
}
}
/// 校验并规范化 `ip:port`,**只接受私有网段 IPv4**。
///
/// ⚠️ 服务端 `server/shared/lan-endpoint.js` 有一份语义相同的 JS 实现 ——
/// 那不是重复,而是**纵深防御**:两端处在不同进程、不同信任位置,
/// 客户端这道是"别去连不该连的地方",服务端那道是"别把不该发的发出去"。
/// 跨语言无法共享代码,故两处都要有,且**判据必须一致**。
String? _parsePrivateV4Endpoint(String s) {
final m = RegExp(r'^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d{1,5})$')
.firstMatch(s.trim());
if (m == null) return null;
final parts = m.group(1)!.split('.');
final n = <int>[];
for (final x in parts) {
final v = int.tryParse(x);
if (v == null || v > 255) return null;
n.add(v);
}
final isPrivate =
n[0] == 10 ||
(n[0] == 192 && n[1] == 168) ||
(n[0] == 172 && n[1] >= 16 && n[1] <= 31);
if (!isPrivate) return null;
final port = int.tryParse(m.group(2)!);
if (port == null || port < 1 || port > 65535) return null;
return '${m.group(1)}:$port';
}
/// 解析出来的领取信息。
class LanTicket {
const LanTicket({
required this.endpoint,
required this.token,
required this.name,
required this.size,
});
final String endpoint;
final String token;
final String name;
final int size;
}
// ══════════════════════════════════════════════════════════════════════
// 信令:对端主动告知"我这儿有个文件"(免去手工粘贴领取码)
//
// 数据面**完全不变** —— 仍然是两端内网直连、不经服务器。
// 变的只是"怎么把 去哪取+凭什么取 告诉对方":
// 从"人工把码发过去"变成"走已有的订阅/转发通道送过去"。
// ══════════════════════════════════════════════════════════════════════
/// 一条来自对端的"待领文件"。
///
/// **三条路各自独立、可同时存在**:内网直连最快、公网直连次之、中继兜底。
/// 接收端按这个顺序试 —— 那是"优先选快的"的落地。
class LanIncomingOffer {
const LanIncomingOffer({
required this.deviceId,
required this.from,
required this.endpoints,
required this.token,
required this.name,
required this.size,
this.wanEndpoints = const [],
this.candidates = const [],
this.relayId,
this.relayToken,
this.relayBytesPerSec,
});
/// 与哪台设备相关
final String deviceId;
/// 谁发来的:`agent`(采集端)/ `viewer`(查看端)。
/// 界面据此说"来自哪一侧",而不是笼统一句"有人发来文件"。
final String from;
/// 对端声明的内网端点(服务端已校验只收私有网段)
final List<String> endpoints;
/// 对端声明的**公网**端点(服务端已校验只收公网地址,且拒云元数据/回环)
final List<String> wanEndpoints;
/// 对端声明的连接候选(host/srflx,P2 阶段引入)。
///
/// 与 lan/wan 是**增量**关系、不是替代:端点列表说"从哪能到我这",
/// 候选说"打洞时可以朝这几条发"——后者更细(含 STUN 反射地址)。
/// 服务端校验后转发(host 只收私网、srflx 只收公网、上限 8)。
final List<Candidate> candidates;
/// 直连用的领取令牌(走内网/公网时必需;只有中继时为空)
final String token;
final String name;
final int size;
/// 中继兜底:服务端开好的通道(不落盘,但会烧流量包)
final String? relayId;
final String? relayToken;
final int? relayBytesPerSec;
/// 有没有中继可走
bool get hasRelay => relayId != null && relayToken != null;
/// 有没有直连可走(内网或公网)
bool get hasDirect => endpoints.isNotEmpty || wanEndpoints.isNotEmpty;
/// 转成领取信息。
///
/// 取第一个端点作默认 —— 多个端点的场景由调用方先用
/// [pickReachableLanEndpoint] 挑出可达的那个再调本方法。
LanTicket toTicket([String? endpoint]) => LanTicket(
endpoint: endpoint ?? (endpoints.isEmpty ? '' : endpoints.first),
token: token,
name: name,
size: size,
);
}
/// 解析服务端下发的 `server:offer` 信封。
///
/// **两端共用同一份** —— 采集端与查看端收到的是同一个事件形状,
/// 各写一份解析迟早会漂移(本项目为此吃过多次亏)。
/// 形状不对一律返回 null:宁可当作"没收到",也不要把半个对象交给界面。
LanIncomingOffer? parseIncomingOffer(dynamic envelope) {
if (envelope is! Map) return null;
final p = envelope['payload'];
if (p is! Map) return null;
final deviceId = p['device_id'];
final from = p['from'];
final token = p['token'];
final name = p['name'];
final size = p['size'];
final eps = p['lan_endpoints'];
if (deviceId is! String || from is! String) return null;
if (name is! String || size is! int) return null;
final endpoints = eps is List
? eps.whereType<String>().toList(growable: false)
: const <String>[];
final wes = p['wan_endpoints'];
final wanEndpoints = wes is List
? wes.whereType<String>().toList(growable: false)
: const <String>[];
// 候选:脏形状逐条丢弃(解析器自带上限与去重,见 candidates.dart)
final candidates = Candidate.parseList(p['candidates']);
final tok = token is String ? token : '';
// 中继:id/token 必须成对出现,缺一视为没有
String? rid;
String? rtok;
int? bps;
final rel = p['relay'];
if (rel is Map) {
final i = rel['id'];
final tk = rel['token'];
if (i is String && i.isNotEmpty && tk is String && tk.isNotEmpty) {
rid = i;
rtok = tk;
final b = rel['bytesPerSec'];
bps = b is num && b > 0 ? b.toInt() : null;
}
}
// 三条路至少要有一条,否则这条 offer 没有意义(服务端也会先拦一道)
if (endpoints.isEmpty && wanEndpoints.isEmpty && rid == null) return null;
// 走直连就必须有领取令牌
if ((endpoints.isNotEmpty || wanEndpoints.isNotEmpty) && tok.isEmpty) {
return null;
}
return LanIncomingOffer(
deviceId: deviceId,
from: from,
endpoints: endpoints,
wanEndpoints: wanEndpoints,
candidates: candidates,
token: tok,
name: name,
size: size,
relayId: rid,
relayToken: rtok,
relayBytesPerSec: bps,
);
}
+59
View File
@@ -0,0 +1,59 @@
/// 网络探测原语 —— 「这条路通不通」的唯一事实来源
///
/// 连接域(`link/`)的底座:四种传输驱动(云端/内网/P2P/中继)都要先
/// 回答"通不通、哪个通",判定逻辑只许有这一份。
/// 2026-10-17 从 `host/lan_service.dart` 原样抽出(纯搬迁,行为不变),
/// 原文件以 export 继续对外暴露,既有调用方零改动。
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
/// 探测**一个**内网端点是否真的可达(查看端用)。
///
/// ── 超时为什么刻意短(800ms)──────────────────────────────────────
/// 探测只决定"走哪条路",而**云端通道本来就是通的**。
/// 若为了等内网而把超时放到好几秒,就等于把"局域网更快"这个优化
/// 变成了"每次连接都先卡几秒" —— 优化的代价转嫁给了体验。
/// 探不通就立刻走云端,用户感知不到这一步的存在。
///
/// 只认 `/lan/ping` 且**要求 body 里 `ok:true`** ——
/// 光看状态码不够:同一网段里别的服务也可能返回 200。
Future<bool> probeLanEndpoint(
String endpoint, {
Duration timeout = const Duration(milliseconds: 800),
}) async {
final client = HttpClient()..connectionTimeout = timeout;
try {
final req = await client
.getUrl(Uri.parse('http://$endpoint/lan/ping'))
.timeout(timeout);
final resp = await req.close().timeout(timeout);
if (resp.statusCode != 200) return false;
final body = await resp
.transform(const Utf8Decoder(allowMalformed: true))
.join()
.timeout(timeout);
return body.contains('"ok":true');
} catch (_) {
// 连不上、超时、DNS 失败…… 一律当作"不可达"。
// 探测失败不是错误,只是"这条更快但非必需的路不可用"。
return false;
} finally {
client.close(force: true);
}
}
/// 依次探测,返回**第一个可达**的端点;都不可达返回 null。
///
/// 顺序即优先级:服务端下发的顺序就是采集端上报的顺序。
Future<String?> pickReachableLanEndpoint(
List<String> endpoints, {
Duration timeout = const Duration(milliseconds: 800),
}) async {
for (final e in endpoints) {
if (await probeLanEndpoint(e, timeout: timeout)) return e;
}
return null;
}
+259
View File
@@ -0,0 +1,259 @@
/// 中继客户端 —— 服务端流式转发(不落盘),三层里最后一层的客户端实现
///
/// 数据面:open 拿双令牌 → 上传端 PUT、下载端 GET,两条连接在服务端
/// 对接后直接 pipe。**服务端不落盘**,但会烧流量包(成本已进选路评分)。
/// 2026-10-17 从 `host/lan_service.dart` 原样抽出(纯搬迁,行为不变),
/// 原文件以 export 继续对外暴露,既有调用方零改动。
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
/// 中继**单次传输上限**(字节)。默认 1 MiB。
///
/// ⚠️ 这是**唯一**的客户端来源,不要再在界面里手写 `1048576`。
/// 它必须与服务端 `server/gateway/relay.js:44-47` 的 `MAX_ONCE_BYTES` 一致
/// (那边默认 1024*1024,可用环境变量 `RELAY_MAX_BYTES` 覆盖)。
///
/// 为什么服务端收到 1 MiB:>1MB 时中继的流控(手写背压的 pause/resume 竞态)
/// 会让传输卡住,修好之前**宁可明确拒绝也不让用户传一半卡死**。
/// 局域网直连不走中继,**不受此限**。
///
/// 客户端提前知道它的用途只有一个:**在点发送之前就说清上限**,
/// 而不是等服务端 409 或让进度条停住(界面不许骗人,也不要让人干等着猜)。
/// 服务端若把上限调大/调小,这里要同步 —— 这是目前唯一的知识点重复,
/// 将来让网关在 `/health` 或 `/relay/open` 里回上限,就能彻底消除。
const int kRelayMaxOnceBytes = 1024 * 1024;
/// 下载结果 —— **把"传完了"和"内容对"分开说**。
///
/// 只报"写入了 N 字节"是不够的:大文件最容易出的问题恰恰是
/// "字节数对得上、内容不对"(断点拼接错位、对端文件被换过)。
class LanDownloadResult {
const LanDownloadResult({
required this.path,
required this.bytes,
required this.sha256,
required this.expectedSha256,
required this.verified,
});
final String path;
final int bytes;
final String? sha256;
final String? expectedSha256;
/// 端到端校验是否通过。**为 false 时调用方必须当作失败**,不可只用字节数。
final bool verified;
}
/// **所有下载路径共用的实现**(局域网直连 / 中继)。
///
/// 两条路各写一份"收完算哈希比对"迟早会漂移,而这个判据是
/// "文件到底对不对"的唯一依据 —— 只能有一处。
Future<LanDownloadResult> receiveToFile({
required Uri url,
required String savePath,
required Duration timeout,
}) async {
final client = HttpClient()..connectionTimeout = timeout;
try {
final req = await client.getUrl(url).timeout(timeout);
final resp = await req.close().timeout(timeout);
if (resp.statusCode != 200) {
// 把**响应体**带进错误信息:4xx 可能来自网关(带 JSON 原因),
// 也可能来自接入层(HTML 页)。只说"404"等于什么都没说,
// 排查时还得再复现一次。
final body = await resp
.transform(const Utf8Decoder(allowMalformed: true))
.join()
.timeout(const Duration(seconds: 5))
.catchError((_) => '');
final brief = body.length > 200 ? '${body.substring(0, 200)}…' : body;
throw HttpException(
'下载失败:HTTP ${resp.statusCode}${brief.isEmpty ? '' : ' — $brief'}',
uri: url,
);
}
final expected = resp.headers.value('x-file-sha256');
final out = File(savePath);
await out.parent.create(recursive: true);
final sink = out.openWrite();
var bytes = 0;
await for (final chunk in resp) {
sink.add(chunk);
bytes += chunk.length;
}
await sink.flush();
await sink.close();
final actual = (await sha256.bind(out.openRead()).first).toString();
return LanDownloadResult(
path: savePath,
bytes: bytes,
sha256: actual,
expectedSha256: expected,
// 对端没给哈希时不能算"验过" —— 那只是"没报错"。
// **"没坏"与"验过了"是两件事**,不能拿后者冒充前者。
// (中继这条路上哈希由发送端在响应头带来,见 uploadViaRelay。)
verified: expected != null && expected == actual,
);
} finally {
client.close(force: true);
}
}
// ══════════════════════════════════════════════════════════════════════
// 中继通道(服务端流式转发,**不落盘**)
//
// 它是三层里的最后一层:局域网不通、公网也不通时才用。
// ⚠️ 会消耗服务器的流量包 —— 所以界面上必须**明示**,不能默默走它。
// ══════════════════════════════════════════════════════════════════════
/// 一条已开好的中继通道。
class LanRelayChannel {
const LanRelayChannel({
required this.id,
required this.token,
required this.serverBase,
required this.bytesPerSec,
});
final String id;
/// 接收端用它的下载令牌;发送端拿的是另一条(见 openLanRelay 的返回值)。
final String token;
/// 服务器基地址,如 `http://203.0.113.7:3000`(示例用文档段地址,勿写真值)
final String serverBase;
/// 服务端声明的限速(字节/秒)—— 界面据此估算耗时,别让用户以为卡住了
final int bytesPerSec;
}
/// 在服务端开一条中继。返回 (上传通道, 下载通道)。
///
/// 服务端会在此刻就检查四道限制(单次大小 / 并发 / 每日配额),
/// **越界时把原因原样返回** —— 界面要能告诉用户"为什么传不了"。
/// 失败时抛 StateError,message 就是服务端给的中文原因。
Future<({LanRelayChannel upload, LanRelayChannel download})> openLanRelay({
required String serverBase,
required String deviceId,
required String deviceToken,
required int size,
Duration timeout = const Duration(seconds: 15),
}) async {
final client = HttpClient()..connectionTimeout = timeout;
try {
final base = serverBase.endsWith('/')
? serverBase.substring(0, serverBase.length - 1)
: serverBase;
final req = await client
.postUrl(Uri.parse('$base/relay/open'))
.timeout(timeout);
req.headers.contentType = ContentType.json;
req.headers.set('x-device-id', deviceId);
req.headers.set('x-device-token', deviceToken);
req.write(jsonEncode({'size': size}));
final resp = await req.close().timeout(timeout);
final body = await resp
.transform(const Utf8Decoder(allowMalformed: true))
.join()
.timeout(timeout);
final j = jsonDecode(body);
if (j is! Map || j['ok'] != true) {
// 服务端的 reason 是给人看的(含"上限 2 GiB""额度已用 X"),原样抛出
final why = (j is Map ? j['reason'] : null) ?? 'HTTP ${resp.statusCode}';
throw StateError('$why');
}
final id = j['id'] as String;
final bps = (j['throttleBytesPerSec'] as num?)?.toInt() ?? 8 * 1024 * 1024;
return (
upload: LanRelayChannel(
id: id,
token: j['uploadToken'] as String,
serverBase: base,
bytesPerSec: bps,
),
download: LanRelayChannel(
id: id,
token: j['downloadToken'] as String,
serverBase: base,
bytesPerSec: bps,
),
);
} finally {
client.close(force: true);
}
}
/// 经中继上传(发送端)。
///
/// ⚠️ **必须等接收端先连上**:中继是"两条连接对接后直接 pipe",
/// 发送端早到了没地方去(服务端会回 404 / E_NO_DOWNLOADER)。
/// 所以这里**轮询重试**,直到对方来接、或等到超时。
/// (这样就不必为"对方开始收了"再加一个信令事件。)
Future<void> uploadViaRelay({
required LanRelayChannel channel,
required String path,
Duration totalTimeout = const Duration(minutes: 10),
void Function(int sent, int total)? onProgress,
}) async {
final file = File(path);
final total = await file.length();
final uri = Uri.parse(
'${channel.serverBase}/relay/${channel.id}'
'?t=${Uri.encodeQueryComponent(channel.token)}',
);
// ⚠️ **顺序:先写完 body,再 close。反过来会死锁。**
//
// 臣在这里栽过两次,值得写下来:
// 第一版是"先 `await req.close()` 拿响应头、确认对端在了再写 body" ——
// 但 Dart 的 `close()` 要等 body 完整才把请求发出去,于是它等一个永远
// 不会来的响应,**请求根本没出门**,服务端一直等到超时。
// 服务端用 curl 手测全绿(curl 是先发 body),**只有真客户端能暴露它**。
//
// 现在服务端改成"对端没到就挂着等",客户端因此**什么都不用协调**:
// 直接推即可 —— 更简单,也更接近"流式转发"本来的形态。
final client = HttpClient()..connectionTimeout = const Duration(seconds: 10);
try {
final req = await client.putUrl(uri);
req.headers.contentLength = total;
var sent = 0;
await for (final chunk in file.openRead()) {
req.add(chunk);
sent += chunk.length;
onProgress?.call(sent, total);
}
final resp = await req.close().timeout(totalTimeout); // 对端就绪前会挂着
if (resp.statusCode != 200) {
await resp.drain<void>().catchError((_) {});
throw HttpException(
'中继上传失败:HTTP ${resp.statusCode}(对端可能一直没来收)',
uri: uri,
);
}
await resp.drain<void>().catchError((_) {});
} finally {
client.close(force: true);
}
}
/// 经中继下载(接收端)。与局域网那条**共用同一份收尾校验**。
Future<LanDownloadResult> downloadViaRelay({
required LanRelayChannel channel,
required String savePath,
Duration timeout = const Duration(minutes: 30),
}) => receiveToFile(
url: Uri.parse(
'${channel.serverBase}/relay/${channel.id}'
'?t=${Uri.encodeQueryComponent(channel.token)}',
),
savePath: savePath,
timeout: timeout,
);
+143
View File
@@ -0,0 +1,143 @@
/// STUN 客户端(RFC 5389 最小子集)—— 只为拿到本机的**公网映射地址**(srflx)
///
/// ── 待接线(2026-10-25 决策:P2P 暂不上线)──────────────────────────
/// 本文件**生产路径未被调用**:`stunDiscover()` 全仓唯一的调用点是
/// `test/stun_test.dart`(那是**真测试**,打真 STUN 服务器,保留不动)。
/// `publishOffer` 的两个注入点(`main.dart` / `viewer_page.dart`)都没传
/// `candidates`,所以候选通道从"上报"这一环就断着 —— 不是"配了 STUN 就能用"。
/// 接线要动的地方(3–5 人日,含跨网真机验证):STUN 配置项、常驻 socket 上报
/// host/srflx 候选、两处 publishOffer 带上 candidates。在那之前,
/// 界面不提供 P2P 档位(见 `link/link_mode.dart` 的 isModeSelectable)——
/// 有代码 ≠ 功能上线,两者要分开看。
///
/// ── 范围纪律 ────────────────────────────────────────────────────────
/// 只实现两件事:发 Binding Request、解析 XOR-MAPPED-ADDRESS。
/// 不做:认证(MESSAGE-INTEGRITY)、长时凭据、TURN 分配、CHANGE-REQUEST。
/// 这些对"问一次我的公网地址"没有用,实现了就是白养的代码。
///
/// ── 为什么自己写而不引包 ────────────────────────────────────────────
/// 报文构造与解析是**纯函数**,共约 100 行;零新依赖是本项目传输层的
/// 既有纪律(见 pubspec 注释)。引一个包只为省这 100 行,换来的是
/// "它哪天改 API/停止维护"的风险。
///
/// ── 安全要点 ────────────────────────────────────────────────────────
/// 1. **校验事务号**:必须回包里的 transaction id 与我们发出的一致,
/// 否则是别人/迟到的包——当成没收到(防串包投毒)。
/// 2. 只认 Binding Success(0x0101);Error Response、未知类型一律丢弃。
/// 3. 报文字段越界(截断/属性长度超界)返回 null,**绝不抛**——
/// 这条路上任何异常都只是"这次探测失败"。
library;
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'candidates.dart';
/// STUN magic cookie(RFC 5389 §6)。
const int kStunMagicCookie = 0x2112A442;
/// 打包一条 Binding Request。20 字节:头 + 12 字节事务号。
List<int> buildBindingRequest(List<int> transactionId) {
assert(transactionId.length == 12, '事务号必须是 12 字节');
final b = ByteData(20);
b.setUint16(0, 0x0001); // Binding Request
b.setUint16(2, 0); // 无属性
b.setUint32(4, kStunMagicCookie);
for (var i = 0; i < 12; i++) {
b.setUint8(8 + i, transactionId[i]);
}
return b.buffer.asUint8List();
}
/// 解析 Binding Response,取出映射地址;任何不对都返回 null。
///
/// [expectTxid] 非空时必须与回包事务号一致(防串包)。
Candidate? parseBindingResponse(List<int> data, {List<int>? expectTxid}) {
if (data.length < 20) return null;
final b = ByteData.sublistView(Uint8List.fromList(data));
final type = b.getUint16(0);
if (type != 0x0101) return null; // 只认 Binding Success
if (b.getUint32(4) != kStunMagicCookie) return null;
if (expectTxid != null) {
for (var i = 0; i < 12 && 8 + i < data.length; i++) {
if (b.getUint8(8 + i) != expectTxid[i]) return null;
}
}
final msgLen = b.getUint16(2);
var off = 20;
final end = 20 + msgLen;
if (end > data.length) return null; // 截断
while (off + 4 <= end) {
final attrType = b.getUint16(off);
final attrLen = b.getUint16(off + 2);
final vStart = off + 4;
if (vStart + attrLen > end) return null;
if (attrType == 0x0020) {
// XOR-MAPPED-ADDRESS:family 0x01 IPv4
if (attrLen >= 8 && b.getUint8(vStart + 1) == 0x01) {
final xPort = b.getUint16(vStart + 2);
final port = xPort ^ (kStunMagicCookie >> 16);
final xIp = b.getUint32(vStart + 4);
final ip = xIp ^ kStunMagicCookie;
final ipStr = '${(ip >> 24) & 0xff}.${(ip >> 16) & 0xff}.'
'${(ip >> 8) & 0xff}.${ip & 0xff}';
return Candidate(type: CandidateType.srflx, ip: ipStr, port: port);
}
} else if (attrType == 0x0001) {
// MAPPED-ADDRESS(兼容老服务器):不 XOR
if (attrLen >= 8 && b.getUint8(vStart + 1) == 0x01) {
final port = b.getUint16(vStart + 2);
final ip = b.getUint32(vStart + 4);
final ipStr = '${(ip >> 24) & 0xff}.${(ip >> 16) & 0xff}.'
'${(ip >> 8) & 0xff}.${ip & 0xff}';
return Candidate(type: CandidateType.srflx, ip: ipStr, port: port);
}
}
off = vStart + ((attrLen + 3) & ~3); // 属性按 4 字节对齐
}
return null;
}
/// 向 STUN 服务器问一次「我的公网地址是什么」。
///
/// 失败(超时/报文不合法)返回 null——探测失败不是错误,
/// 只是"这条跨网 P2P 的路暂时拿不到候选"。
Future<Candidate?> stunDiscover({
required String server,
int port = 3478,
Duration timeout = const Duration(seconds: 2),
}) async {
RawDatagramSocket? socket;
StreamSubscription<RawSocketEvent>? sub;
Timer? timer;
try {
socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0);
final txid = List<int>.generate(12, (_) => Random.secure().nextInt(256));
final req = buildBindingRequest(txid);
final addr = InternetAddress.tryParse(server);
if (addr == null) return null;
socket.send(req, addr, port);
final completer = Completer<Candidate?>();
sub = socket.listen((e) {
if (e != RawSocketEvent.read) return;
final dg = socket?.receive();
if (dg == null) return;
final c = parseBindingResponse(dg.data, expectTxid: txid);
if (c != null && !completer.isCompleted) completer.complete(c);
});
timer = Timer(timeout, () {
if (!completer.isCompleted) completer.complete(null);
});
return await completer.future;
} catch (_) {
return null;
} finally {
timer?.cancel();
await sub?.cancel();
socket?.close();
}
}
+16 -1
View File
@@ -36,7 +36,7 @@ class ForegroundState {
class AndroidForeground {
AndroidForeground._();
static const MethodChannel _ch = MethodChannel('com.pmon/foreground');
static const MethodChannel _ch = MethodChannel('com.inkwatch/foreground');
static bool get isAndroid => Platform.isAndroid;
@@ -135,6 +135,21 @@ class AndroidForeground {
}
}
/// **保持屏幕常亮**(水墨屏副屏模式用)。
///
/// 为什么需要它:副屏的意义就是"一直亮着可读"。墨水屏**静止时不耗电**
/// (画面是物理保持的),所以常亮的代价极小;而息屏之后它就完全没用了。
///
/// 只在界面侧生效(服务/无 Activity 时如实返回 false,不静默假装成功)。
static Future<bool> setKeepScreenOn(bool on) async {
if (!isAndroid) return false;
try {
return await _ch.invokeMethod<bool>('setKeepScreenOn', {'on': on}) ?? false;
} on PlatformException {
return false;
}
}
/// 机型(用于拼可读的设备标识)
static Future<String> model() async {
if (!isAndroid) return '';
+3 -3
View File
@@ -10,7 +10,7 @@
/// 给出的 `FrameTiming` 含 build / raster 两段耗时,**release 包同样有效**。
///
/// ── 开法 ───────────────────────────────────────────────────────
/// flutter build apk --release --dart-define=PMON_LOG_JANK=true
/// flutter build apk --release --dart-define=INKWATCH_LOG_JANK=true
/// 默认关闭:正常发行不该刷日志(本项目日志有界纪律)。
/// 打开后每帧若 `totalSpan` 超阈值就打一行 `[jank]`,用 `adb logcat | grep jank` 读。
///
@@ -25,8 +25,8 @@ import 'dart:async';
import 'package:flutter/scheduler.dart';
/// 是否输出掉帧日志(默认关;构建时用 --dart-define=PMON_LOG_JANK=true 打开)
const bool kLogJank = bool.fromEnvironment('PMON_LOG_JANK');
/// 是否输出掉帧日志(默认关;构建时用 --dart-define=INKWATCH_LOG_JANK=true 打开)
const bool kLogJank = bool.fromEnvironment('INKWATCH_LOG_JANK');
/// 超过该耗时(毫秒)算一帧"卡了"。16.7ms = 60fps 的预算,
/// 留到 24ms 才报是为了不把「偶发抖动」也当成问题(真卡的帧通常 30ms+)。
+600 -41
View File
@@ -17,6 +17,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import '../host/account_api.dart';
import 'eink_mode.dart';
import 'theme.dart';
class AccountCard extends StatefulWidget {
@@ -45,26 +46,79 @@ class AccountCard extends StatefulWidget {
}
class _AccountCardState extends State<AccountCard> {
InkwatchPalette get _p => InkwatchPalette.of(context);
InkwatchTextStyles get _tx => InkwatchTextStyles.of(_p);
late final TextEditingController _emailCtrl;
late final TextEditingController _secretCtrl;
/// 改密用:原口令 / 新口令。**不复用登录框** —— 否则面板切换会互相冲掉输入。
late final TextEditingController _oldSecretCtrl;
late final TextEditingController _newSecretCtrl;
String _token = '';
AccountInfo? _info;
bool _busy = false;
String? _error;
/// 当前展开的面板:null / 'sessions' / 'secret' / 'danger'
String? _panel;
List<SessionInfo>? _sessions;
bool _panelBusy = false;
String? _note;
@override
void initState() {
super.initState();
_emailCtrl = TextEditingController(text: widget.initialEmail);
_secretCtrl = TextEditingController();
_oldSecretCtrl = TextEditingController();
_newSecretCtrl = TextEditingController();
_token = widget.initialToken;
if (_token.isNotEmpty) unawaited(_loadInfo());
}
/// ★ 外部改了登录态 → 同步进卡片。
///
/// ── 为什么必须有它(2026-09-16 真机反馈"查看端和采集端各要登录一次")──
/// 主壳是 `IndexedStack`,采集页与查看页各有一张 `AccountCard`,**两张都长期存活**。
/// 在采集页登录后,`ConfigStore` 的广播能让**页面**更新自己的 `_token`;
/// 但**卡片的 State 不会重跑 `initState`**(同一位置、无 key → State 被复用),
/// 于是卡片内部仍是那个旧的空 token,照样渲染"登录"表单。
///
/// 用户看到的现象就是"那边登了、这边还得再登一次" ——
/// 其实令牌早就共享(两边都写同一个 `AppConfig.accountToken`),
/// 只是这张卡片不知道而已。**不是登录两次,是登录态没同步。**
@override
void didUpdateWidget(AccountCard old) {
super.didUpdateWidget(old);
if (old.initialToken != widget.initialToken) {
final wasEmpty = _token.isEmpty;
setState(() {
_token = widget.initialToken;
if (_token.isEmpty) {
_info = null; // 登出:连账号信息一起清,否则会留着上个账号的档位与设备数
_error = null;
}
});
if (_token.isNotEmpty && wasEmpty) unawaited(_loadInfo());
}
// 邮箱:**只补空、不覆盖** —— 只在输入框还是空(或仍等于旧值)时同步,
// 免得把用户正在敲的内容冲掉。用户正在输入时,外部变化不该打断他。
if (old.initialEmail != widget.initialEmail &&
widget.initialEmail.isNotEmpty &&
(_emailCtrl.text.isEmpty || _emailCtrl.text == old.initialEmail)) {
_emailCtrl.text = widget.initialEmail;
}
}
@override
void dispose() {
_emailCtrl.dispose();
_secretCtrl.dispose();
_oldSecretCtrl.dispose();
_newSecretCtrl.dispose();
super.dispose();
}
@@ -116,7 +170,7 @@ class _AccountCardState extends State<AccountCard> {
Future<void> _logout() async {
setState(() => _busy = true);
await widget.api.logout(_token); // 失败也要清本地:否则用户以为没退成功
await widget.api.logout(_token); // 失败也要清本地:否则用户以为没退成功
if (!mounted) return;
setState(() {
_busy = false;
@@ -131,7 +185,7 @@ class _AccountCardState extends State<AccountCard> {
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
padding: EdgeInsets.all(14),
child: _loggedIn ? _loggedInView() : _loginView(),
),
);
@@ -141,61 +195,472 @@ class _AccountCardState extends State<AccountCard> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
Row(
children: [
Icon(Icons.person_outline, size: 18),
SizedBox(width: 8),
Text('账号',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
Text(
'账号',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
SizedBox(width: 6),
Text('未登录',
style: TextStyle(fontSize: 11.5, color: PmonColors.textSecondary)),
Text(
'未登录',
style: TextStyle(fontSize: 11.5, color: _p.textSecondary),
),
],
),
const SizedBox(height: 4),
SizedBox(height: 4),
// 只说一句:登录的收益。原第二句"没登录时只能看别人授权的设备"
// 已由设备行上的「他人授权」徽标承担 —— 同一件事不必说两遍(信息密度)
const Text(
Text(
'登录后,你自己名下的设备直接可见(免配对码)。',
style: TextStyle(fontSize: 11.5, color: PmonColors.textSecondary, height: 1.45),
style: TextStyle(
fontSize: 11.5,
color: _p.textSecondary,
height: 1.45,
),
),
const SizedBox(height: 10),
SizedBox(height: 10),
TextField(
controller: _emailCtrl,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: '邮箱', isDense: true, border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: '邮箱',
isDense: true,
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
SizedBox(height: 8),
TextField(
controller: _secretCtrl,
obscureText: true,
onSubmitted: (_) => unawaited(_submit(isRegister: false)),
decoration: const InputDecoration(
labelText: '口令(至少 8 位)', isDense: true, border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: '口令(至少 8 位)',
isDense: true,
border: OutlineInputBorder(),
),
),
if (_error != null) ...[
const SizedBox(height: 8),
Text(_error!,
style: const TextStyle(fontSize: 11.5, color: PmonColors.danger)),
SizedBox(height: 8),
Text(
_error!,
style: TextStyle(fontSize: 11.5, color: _p.danger),
),
],
const SizedBox(height: 10),
SizedBox(height: 10),
Row(
children: [
FilledButton(
onPressed: _busy ? null : () => unawaited(_submit(isRegister: false)),
onPressed: _busy
? null
: () => unawaited(_submit(isRegister: false)),
child: _busy
? const SizedBox(
width: 14, height: 14,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('登录'),
? WaitIndicator(size: 14, label: '登录中…')
: Text('登录'),
),
const SizedBox(width: 8),
SizedBox(width: 8),
OutlinedButton(
onPressed: _busy ? null : () => unawaited(_submit(isRegister: true)),
child: const Text('注册并登录'),
onPressed: _busy
? null
: () => unawaited(_submit(isRegister: true)),
child: Text('注册并登录'),
),
],
),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: _busy ? null : () => unawaited(_forgotSecret()),
child: Text('忘记口令?', style: TextStyle(fontSize: 12.5)),
),
),
],
);
}
// ══════════ 账号自救(2026-10-30):会话 / 改密 / 验证 / 注销 ══════════
//
// 这些按钮存在的理由只有一个:**账号出问题时用户能自己处理**。
// 在此之前,忘密码 = 账号报废、令牌泄露 = 无从处置、想注销 = 没有入口。
Future<void> _togglePanel(String name) async {
if (_panel == name) {
setState(() => _panel = null);
return;
}
setState(() {
_panel = name;
_note = null;
_error = null;
});
if (name == 'sessions') await _loadSessions();
}
Future<void> _loadSessions() async {
setState(() => _panelBusy = true);
final list = await widget.api.sessions(_token);
if (!mounted) return;
setState(() {
_sessions = list;
_panelBusy = false;
});
}
Future<void> _kick(SessionInfo s) async {
setState(() => _panelBusy = true);
final r = await widget.api.kickSession(_token, s.id);
if (!mounted) return;
setState(() {
_panelBusy = false;
_note = r.ok ? '已让「${s.displayLabel}」退出' : r.message;
});
if (r.ok) await _loadSessions();
}
Future<void> _logoutOthers() async {
setState(() => _panelBusy = true);
final r = await widget.api.logoutOtherSessions(_token);
if (!mounted) return;
setState(() {
_panelBusy = false;
_note = r.ok ? '已退出其它 ${r.revoked} 处登录' : r.message;
});
if (r.ok) await _loadSessions();
}
Future<void> _changeSecret() async {
final oldS = _oldSecretCtrl.text;
final newS = _newSecretCtrl.text;
if (oldS.isEmpty || newS.length < 8) {
setState(() => _error = '新口令至少 8 位');
return;
}
setState(() {
_panelBusy = true;
_error = null;
});
final r = await widget.api.changeSecret(_token, oldS, newS);
if (!mounted) return;
setState(() {
_panelBusy = false;
if (r.ok) {
_oldSecretCtrl.clear();
_newSecretCtrl.clear();
_note = r.message;
} else {
_error = r.message;
}
});
}
Future<void> _requestVerify() async {
setState(() => _panelBusy = true);
final r = await widget.api.requestVerify(_token);
if (!mounted) return;
setState(() {
_panelBusy = false;
if (!r.ok) {
_error = r.message;
} else if (r.alreadyVerified) {
_note = '邮箱已验证';
} else if (r.delivered) {
_note = '验证邮件已发出,请查收(24 小时内有效)';
} else {
// 如实说:服务端没配置发信通道时,邮件**并没有发出**
_note =
'服务端未配置发信通道,邮件没有真的发出。请联系部署方,'
'或在服务器上用 CLI 发码。';
}
});
if (r.ok && r.alreadyVerified) unawaited(_loadInfo());
}
Future<void> _deleteAccount() async {
final ctrl = TextEditingController();
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('注销账号'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('注销后有 30 天冷静期,期内用同一邮箱重新登录即可撤销。', style: _tx.body),
SizedBox(height: 8),
Text(
'名下设备会变成「无人可看」——设备与历史数据都保留,'
'但要重新认领才能恢复查看。',
style: _tx.caption,
),
SizedBox(height: 12),
TextField(
controller: ctrl,
obscureText: true,
decoration: InputDecoration(
labelText: '输入口令确认',
isDense: true,
border: OutlineInputBorder(),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text('确认注销'),
),
],
),
);
if (confirmed != true) {
ctrl.dispose();
return;
}
setState(() => _panelBusy = true);
final r = await widget.api.deleteAccount(_token, ctrl.text);
ctrl.dispose();
if (!mounted) return;
setState(() => _panelBusy = false);
if (!r.ok) {
setState(() => _error = r.message);
return;
}
_secretCtrl.clear();
setState(() {
_token = '';
_info = null;
_panel = null;
_sessions = null;
_note = null;
_error = null;
});
widget.onAuthChanged?.call(null, null);
}
/// 忘记口令:先发码、再填码与新口令。
///
/// 服务端对"邮箱不存在"也返回同样的成功文案(防账号枚举),所以这里的提示
/// 只说"如果该邮箱存在,验证码已发出"—— 不能替用户断言"发出去了"。
Future<void> _forgotSecret() async {
final emailCtrl = TextEditingController(text: _emailCtrl.text.trim());
final codeCtrl = TextEditingController();
final secretCtrl = TextEditingController();
String? status;
bool busy = false;
await showDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setD) => AlertDialog(
title: Text('找回口令'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('验证码会发到注册邮箱,15 分钟内有效且只能用一次。', style: _tx.caption),
SizedBox(height: 10),
TextField(
controller: emailCtrl,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: '邮箱',
isDense: true,
border: OutlineInputBorder(),
),
),
SizedBox(height: 8),
TextField(
controller: codeCtrl,
decoration: InputDecoration(
labelText: '验证码(邮件里 pm_rst_ 开头)',
isDense: true,
border: OutlineInputBorder(),
),
),
SizedBox(height: 8),
TextField(
controller: secretCtrl,
obscureText: true,
decoration: InputDecoration(
labelText: '新口令(至少 8 位)',
isDense: true,
border: OutlineInputBorder(),
),
),
if (status != null) ...[
SizedBox(height: 8),
Text(status!, style: _tx.caption),
],
],
),
actions: [
TextButton(
onPressed: busy
? null
: () async {
setD(() {
busy = true;
status = '发送中…';
});
final r = await widget.api.requestReset(
emailCtrl.text.trim(),
);
setD(() {
busy = false;
status = r.message;
});
},
child: Text('发送验证码'),
),
FilledButton(
onPressed: busy
? null
: () async {
if (secretCtrl.text.length < 8) {
setD(() => status = '新口令至少 8 位');
return;
}
setD(() {
busy = true;
status = '重置中…';
});
final r = await widget.api.confirmReset(
codeCtrl.text.trim(),
secretCtrl.text,
);
if (!ctx.mounted) return;
if (r.ok) {
Navigator.pop(ctx);
if (!mounted) return;
setState(() {
_emailCtrl.text = emailCtrl.text.trim();
_note = null;
_error = null;
});
return;
}
setD(() {
busy = false;
status = r.message;
});
},
child: Text('重置口令'),
),
],
),
),
);
emailCtrl.dispose();
codeCtrl.dispose();
secretCtrl.dispose();
}
static String _fmtTime(DateTime t) {
final l = t.toLocal();
String two(int n) => n.toString().padLeft(2, '0');
return '${two(l.month)}-${two(l.day)} ${two(l.hour)}:${two(l.minute)}';
}
Widget _panelBody() {
switch (_panel) {
case 'sessions':
return _sessionsPanel();
case 'secret':
return _secretPanel();
default:
return SizedBox.shrink();
}
}
Widget _sessionsPanel() {
final list = _sessions;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 8),
if (_panelBusy && list == null)
Text('读取中…', style: _tx.caption)
else if (list == null || list.isEmpty)
Text('读不到登录记录(不影响当前使用)', style: _tx.caption)
else ...[
for (final s in list)
Padding(
padding: EdgeInsets.only(bottom: 6),
child: Row(
children: [
Icon(
s.current ? Icons.smartphone : Icons.devices_other,
size: 14,
color: _p.textTertiary,
),
SizedBox(width: 6),
Expanded(
child: Text(
'${s.displayLabel}${s.current ? '(当前)' : ''} · ${_fmtTime(s.lastUsedAt)}',
style: _tx.caption,
overflow: TextOverflow.ellipsis,
),
),
if (!s.current)
TextButton(
onPressed: _panelBusy ? null : () => unawaited(_kick(s)),
child: Text('退出', style: TextStyle(fontSize: 12)),
),
],
),
),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton(
onPressed: _panelBusy ? null : () => unawaited(_logoutOthers()),
child: Text('退出其它设备'),
),
),
],
],
);
}
Widget _secretPanel() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 8),
TextField(
controller: _oldSecretCtrl,
obscureText: true,
decoration: InputDecoration(
labelText: '原口令',
isDense: true,
border: OutlineInputBorder(),
),
),
SizedBox(height: 8),
TextField(
controller: _newSecretCtrl,
obscureText: true,
decoration: InputDecoration(
labelText: '新口令(至少 8 位)',
isDense: true,
border: OutlineInputBorder(),
),
),
SizedBox(height: 8),
Row(
children: [
FilledButton(
onPressed: _panelBusy ? null : () => unawaited(_changeSecret()),
child: Text('修改口令'),
),
SizedBox(width: 8),
Expanded(
child: Text('改完其它设备会被退出登录', style: _tx.caption),
),
],
),
@@ -205,39 +670,133 @@ class _AccountCardState extends State<AccountCard> {
Widget _loggedInView() {
final info = _info;
// 元信息两行共用同一档样式 —— 它们原先挤在同一行,拆开后必须长得一样,
// 否则「档位比配额重要 / 次要」这种视觉暗示会凭空出现
final metaStyle = TextStyle(
fontSize: 11.5,
color: _p.textSecondary,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.person, size: 18),
const SizedBox(width: 8),
Icon(Icons.person, size: 18),
SizedBox(width: 8),
Expanded(
child: Text(
info?.label ?? widget.initialEmail,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
TextButton(
onPressed: _busy ? null : () => unawaited(_logout()),
child: const Text('退出登录'),
child: Text('退出登录'),
),
],
),
if (info != null) ...[
const SizedBox(height: 2),
SizedBox(height: 2),
// ── 配额行 ─────────────────────────────────────────────
// **任一数字缺失就整行不渲染**:服务端没给却写「0/0 台」,用户会以为
// 自己被降级了 —— 而那是编出来的数字(解析层已不再填默认值)。
if (info.ownedDeviceCount != null && info.deviceQuota != null)
Text(
'我的设备 ${info.ownedDeviceCount}/${info.deviceQuota} 台'
'${info.ownedDeviceCount! >= info.deviceQuota! ? ' · 已达上限' : ''}',
style: metaStyle,
),
// ── 档位行 ─────────────────────────────────────────────
// 中文档位名**只认服务端给的 plan_name**,拿不到就说「未知档位」。
// ① 绝不回落到机器键字段(free / pro / team):用户看不懂;
// ② 绝不按配额反推档位:free_device_limit 支持实例级覆盖,反推必然算错。
// 这两条都有源码护栏:test/plan_text_guard_test.dart
Text(
'${info.email} · 档位 ${info.plan} · 我的设备 ${info.ownedDeviceCount}/${info.deviceQuota} 台',
style: const TextStyle(fontSize: 11.5, color: PmonColors.textSecondary),
'${info.planName ?? '未知档位'} · 查看三档权益',
style: metaStyle,
),
SizedBox(height: 6),
Row(
children: [
// 邮箱验证状态:未验证不是"不能用",而是**额度被收紧到 1 台**,
// 所以这里必须说清楚"验证能换来什么",否则用户不会去点。
Icon(
info.emailVerified
? Icons.verified_outlined
: Icons.error_outline,
size: 14,
color: info.emailVerified
? _p.online
: _p.suspect,
),
SizedBox(width: 4),
Expanded(
child: Text(
info.emailVerified ? '邮箱已验证' : '邮箱未验证:最多绑 1 台设备,也不能邀请他人',
style: _tx.caption,
),
),
if (!info.emailVerified)
TextButton(
onPressed: _panelBusy
? null
: () => unawaited(_requestVerify()),
child: Text('验证', style: TextStyle(fontSize: 12)),
),
],
),
const SizedBox(height: 4),
const Text('名下设备免配对可见;他人授权的会标「他人授权」。',
style: TextStyle(fontSize: 11, color: PmonColors.textTertiary, height: 1.4)),
] else ...[
const SizedBox(height: 4),
const Text('正在读取账号信息…(读不到不影响已登录状态下查看设备)',
style: TextStyle(fontSize: 11.5, color: PmonColors.textTertiary)),
SizedBox(height: 4),
Text(
'正在读取账号信息…(读不到不影响已登录状态下查看设备)',
style: TextStyle(fontSize: 11.5, color: _p.textTertiary),
),
],
SizedBox(height: 6),
Wrap(
spacing: 4,
children: [
TextButton(
onPressed: () => unawaited(_togglePanel('sessions')),
child: Text(
_panel == 'sessions' ? '收起登录设备' : '登录设备',
style: TextStyle(fontSize: 12.5),
),
),
TextButton(
onPressed: () => unawaited(_togglePanel('secret')),
child: Text(
_panel == 'secret' ? '收起改密' : '修改口令',
style: TextStyle(fontSize: 12.5),
),
),
TextButton(
onPressed: _panelBusy ? null : () => unawaited(_deleteAccount()),
child: Text(
'注销账号',
style: TextStyle(fontSize: 12.5, color: _p.danger),
),
),
],
),
if (_panel != null) _panelBody(),
if (_note != null) ...[
SizedBox(height: 6),
Text(
_note!,
style: TextStyle(fontSize: 11.5, color: _p.online),
),
],
if (_error != null) ...[
SizedBox(height: 6),
Text(
_error!,
style: TextStyle(fontSize: 11.5, color: _p.danger),
),
],
],
);
+338
View File
@@ -0,0 +1,338 @@
/// 设备成员卡 —— 谁还能看我这台设备(角色 / 邀请 / 移除)
///
/// ── 为什么需要它 ────────────────────────────────────────────────
/// 在此之前,"谁能看"只有一个入口:设备端出 6 位配对码,对方**当场**输入。
/// 这条通道对"异地家人/同事"不可用 —— 于是要么不给权限,要么把码拍照发过去
/// (等于公开招租)。成员卡把这件事变成:设备归属人输对方邮箱 → 服务端发邀请
/// → 对方登录后接受。
///
/// ── 界面上的三条取舍 ────────────────────────────────────────────
/// · **角色徽标必须显示**:只写"已授权"会让用户以为对方能改设置。三级角色
/// (仅查看 / 可分享 / 可管理)由 `host/role_catalog.dart` 派生,与服务端同源。
/// · **按钮按角色隐藏而不是点了再报错**:viewer 看不到"邀请",因为点了必然 403。
/// · **归属人不能被移除**:那条不是授权行,是设备归属 —— 要交出去得用「转移归属」,
/// 界面直接不给他那个按钮,避免"点了没反应"。
library;
import 'dart:async';
import 'package:flutter/material.dart';
import '../host/account_api.dart';
import '../host/role_catalog.dart';
import 'eink_mode.dart';
import 'theme.dart';
class DeviceMembersCard extends StatefulWidget {
const DeviceMembersCard({
super.key,
required this.api,
required this.deviceId,
required this.token,
this.myRole,
this.deviceLabel,
});
final AccountApi api;
final String deviceId;
final String token;
/// 设备名(仅用于标题)。页面可能列着十几台设备,只写"成员"用户不知道管的是哪台。
/// 由调用方传入而不是自己查:本卡不该为了一个标题再打一次设备列表。
final String? deviceLabel;
/// 我在这个设备上的角色(来自设备列表的 `role` 字段)。
/// 为空时不显示任何写操作 —— 宁可不给按钮,也不要给一个必然失败的按钮。
final String? myRole;
@override
State<DeviceMembersCard> createState() => _DeviceMembersCardState();
}
class _DeviceMembersCardState extends State<DeviceMembersCard> {
InkwatchPalette get _p => InkwatchPalette.of(context);
InkwatchTextStyles get _tx => InkwatchTextStyles.of(_p);
final _emailCtrl = TextEditingController();
List<MemberInfo> _members = const [];
String? _myRole;
String? _ownerEmail;
bool _loading = true;
bool _busy = false;
String? _note;
String? _error;
String _inviteRole = 'viewer';
@override
void initState() {
super.initState();
_myRole = widget.myRole;
unawaited(_load());
}
@override
void dispose() {
_emailCtrl.dispose();
super.dispose();
}
bool get _canManage =>
canByRole(_myRole, 'MANAGE_MEMBERS', isOwner: _myRole == 'owner');
bool get _canInvite =>
canByRole(_myRole, 'INVITE_MEMBER', isOwner: _myRole == 'owner');
bool get _canView =>
canByRole(_myRole, 'VIEW_MEMBERS', isOwner: _myRole == 'owner');
Future<void> _load() async {
if (widget.token.isEmpty || widget.deviceId.isEmpty) {
setState(() => _loading = false);
return;
}
if (!_canView) {
setState(() => _loading = false);
return;
}
final r = await widget.api.members(widget.token, widget.deviceId);
if (!mounted) return;
setState(() {
_loading = false;
if (r.ok) {
_members = r.members;
_myRole = r.myRole ?? _myRole;
_ownerEmail = r.ownerEmail;
} else {
_error = r.message;
}
});
}
Future<void> _invite() async {
final email = _emailCtrl.text.trim();
if (email.isEmpty) {
setState(() => _error = '请填写对方邮箱');
return;
}
setState(() {
_busy = true;
_error = null;
_note = null;
});
final r = await widget.api.inviteMember(
widget.token,
widget.deviceId,
email,
_inviteRole,
);
if (!mounted) return;
setState(() {
_busy = false;
if (r.ok) {
_emailCtrl.clear();
// 两种结果要分开说:已注册 → 直接生效;未注册 → 发的是邀请码
_note = r.mode == 'invite'
? '邀请已发出(对方注册后接受即可,7 天有效)'
: '已授权给该账号(角色:${roleLabel(_inviteRole)})';
} else {
_error = r.message;
}
});
if (r.ok) await _load();
}
Future<void> _setRole(MemberInfo m, String role) async {
setState(() {
_busy = true;
_error = null;
});
final r = await widget.api.setMemberRole(
widget.token,
widget.deviceId,
m.accountId,
role,
);
if (!mounted) return;
setState(() {
_busy = false;
if (r.ok) {
_note = '已把「${m.label}」设为 ${roleLabel(role)}';
} else {
_error = r.message;
}
});
if (r.ok) await _load();
}
Future<void> _remove(MemberInfo m) async {
setState(() {
_busy = true;
_error = null;
});
final r = await widget.api.removeMember(
widget.token,
widget.deviceId,
m.accountId,
);
if (!mounted) return;
setState(() {
_busy = false;
if (r.ok) {
_note = '已移除「${m.label}」';
} else {
_error = r.message;
}
});
if (r.ok) await _load();
}
@override
Widget build(BuildContext context) {
// 无权看成员列表时整卡隐藏:显示一个"你没权限"的框只是在提醒用户"这里有东西"。
if (widget.token.isEmpty || !_canView) return SizedBox.shrink();
return Card(
child: Padding(
padding: EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.group_outlined, size: 18),
SizedBox(width: 8),
Text('成员', style: _tx.section),
if (widget.deviceLabel != null &&
widget.deviceLabel!.isNotEmpty) ...[
SizedBox(width: 6),
Flexible(
child: Text(
'· ${widget.deviceLabel}',
style: _tx.caption,
overflow: TextOverflow.ellipsis,
),
),
],
SizedBox(width: 8),
if (_myRole != null)
Text('我的角色:${roleLabel(_myRole)}', style: _tx.caption),
],
),
SizedBox(height: 6),
if (_loading)
Text('读取中…', style: _tx.caption)
else ...[
if (_ownerEmail != null && _ownerEmail!.isNotEmpty)
Text(
'归属人:$_ownerEmail(不可移除,需用「转移归属」)',
style: _tx.caption,
),
SizedBox(height: 4),
if (_members.isEmpty)
Text('还没有其它成员。只有归属人能看这台设备。', style: _tx.caption)
else
for (final m in _members) _memberRow(m),
if (_canInvite) ...[
SizedBox(height: 10),
TextField(
controller: _emailCtrl,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: '对方邮箱',
isDense: true,
border: OutlineInputBorder(),
),
),
SizedBox(height: 8),
Row(
children: [
DropdownButton<String>(
value: _inviteRole,
isDense: true,
onChanged: (v) =>
setState(() => _inviteRole = v ?? 'viewer'),
items: [
for (final r in const ['viewer', 'manager'])
DropdownMenuItem(value: r, child: Text(roleLabel(r))),
],
),
SizedBox(width: 10),
FilledButton(
onPressed: _busy ? null : () => unawaited(_invite()),
child: _busy
? WaitIndicator(size: 14, label: '发送中…')
: Text('邀请'),
),
SizedBox(width: 8),
Expanded(
child: Text(
'未注册的邮箱会收到邀请码,不会替对方建号',
style: _tx.caption,
),
),
],
),
],
if (_note != null) ...[
SizedBox(height: 8),
Text(
_note!,
style: TextStyle(
fontSize: 11.5,
color: _p.online,
),
),
],
if (_error != null) ...[
SizedBox(height: 8),
Text(
_error!,
style: TextStyle(
fontSize: 11.5,
color: _p.danger,
),
),
],
],
],
),
),
);
}
Widget _memberRow(MemberInfo m) {
// 归属人本身不会出现在 `members` 里(那是 device_grants,归属是 devices.owner_id),
// 所以这里每一行都是"被授权的成员",改角色/移除对 owner 都成立。
return Padding(
padding: EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Expanded(
child: Text(
'${m.label} · ${roleLabel(m.role)}',
style: _tx.bodyMuted,
overflow: TextOverflow.ellipsis,
),
),
if (_canManage)
PopupMenuButton<String>(
tooltip: '改角色',
onSelected: _busy ? null : (r) => unawaited(_setRole(m, r)),
itemBuilder: (_) => [
for (final r in const ['viewer', 'manager', 'owner'])
PopupMenuItem(value: r, child: Text(roleLabel(r))),
],
child: Icon(
Icons.tune,
size: 16,
color: _p.textTertiary,
),
),
if (_canManage)
TextButton(
onPressed: _busy ? null : () => unawaited(_remove(m)),
child: Text('移除', style: TextStyle(fontSize: 12)),
),
],
),
);
}
}
+678
View File
@@ -0,0 +1,678 @@
/// 水墨屏看板 —— 单台设备、大字、常驻(`feature/eink` 分支专属)
///
/// ══════════════════════════════════════════════════════════════════════
/// 这个页面为 **iReader Neo 2**(6″ Carta 1300 · 1072×1448 · 300 PPI ·
/// **黑白 256 灰阶** · MT8175B 四核 2GHz + **2GB RAM** · **Android 10 (API 29)** ·
/// **零传感器**)当**副屏**而做(参数为实测值,非资料推测)。
///
/// 墨水屏的物理决定了它与彩色版的取向**相反**,四条约束贯穿全文件:
///
/// | 约束 | 做法 | 为什么 |
/// |---|---|---|
/// | **零无限动画** | 全部静止(见 `eink_mode.dart`) | 每次重绘 = **一次真实屏刷新**;无限动画 = 持续刷新 + 残影 + 耗电 |
/// | **刷新节流** | 由宿主 `viewer_page` 按间隔重建 | 副屏看趋势,1 秒一刷会把屏刷花 |
/// | **状态不靠颜色** | `statusGlyph()` ●/◐/○/◌ | 256 灰阶下绿(#16A34A)与橙(#D97706)相对亮度 0.42 vs 0.45 → **分不出来** |
/// | **字号统一** | 数值按全屏最长那条统一缩放([einkUniformValueScale]);界面里**不得写就地字号** | 逐格 `FittedBox` 会让 `310 kb/s` 比 `24.8%` 小一号,一屏字号参差 |
///
/// ══════════════════════════════════════════════════════════════════════
/// 布局(2026-09-16 产品负责人要求:横屏为默认、可切竖屏、隐藏次要信息)
/// ══════════════════════════════════════════════════════════════════════
///
/// ```
/// 横屏(宽 > 高) 3 列 × 2 行 竖屏(高 > 宽) 2 列 × 3 行
/// ┌──────────────────────────────┐ ┌──────────────────┐
/// │ ● MacBook Pro · M3 Max │ │ ● MacBook Pro │
/// ├────────┬────────┬────────────┤ ├─────────┬────────┤
/// │CPU 使用率│内存使用率│ 已用内存 │ │CPU 使用率│内存使用率│
/// │ 35.3% │ 67.6% │ 88.6 GB │ │ 35.3% │ 67.6% │
/// ├────────┼────────┼────────────┤ ├─────────┼────────┤
/// │ 磁盘可用 │下行速率 │ 上行速率 │ │ 已用内存 │磁盘可用 │
/// │ 3.10 TB│1.42Mb/s│ 310 kb/s │ │ 88.6 GB │3.10 TB │
/// └────────┴────────┴────────────┘ ├─────────┼────────┤
/// 20:18:52 · 每 10 秒 [刷新][转屏] │ 下行速率 │上行速率 │
/// │1.42Mb/s │310kb/s │
/// └─────────┴────────┘
/// ```
///
/// **列数按宽高比决定**,不是写死 —— 6″/300PPI 且系统 density 未知,
/// 逻辑尺寸可能是 430~800dp 之间的任何值(见 [einkGridColumns])。
///
/// **次要信息只在异常时出现**:在线时顶部只有「● 设备名」(形状已表达在线),
/// 末帧说明与状态文字都不铺出来;一旦离线/疑似/未知才补上 ——
/// 这是"隐藏非必要信息"的落点:**常态极简,异常显眼**。
///
/// ══════════════════════════════════════════════════════════════════════
/// 本文件刻意**只做展示**:不碰连接、不订阅、不持状态、不管屏幕方向
/// (方向由宿主锁),数据由 `viewer_page` 节流后传进来 ——
/// 这样它可以被 widget 测试直接喂数据断言(墨水屏上没法一边改一边看)。
library;
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../host/metric_display.dart';
import '../host/metric_format.dart';
import 'theme.dart';
/// 看板格子数(一屏放下、不用滚动)
const int kEinkCellCount = 6;
/// 看板要跳过的指标。
///
/// `cpu_cores` 是**数组型**(每核一个占用率),一格放不下一个标量。
/// 这不是"不重要",而是"形态不适合"——要做的话得单独画一列小条。
const Set<String> kEinkSkipKeys = {'cpu_cores'};
/// 按可用空间的宽高比决定列数(纯函数,可测)。
///
/// 为什么不是"横屏 3 列 / 竖屏 2 列"这样按方向写死:Flutter 拿到的只是
/// **一个矩形**,它并不知道设备的物理方向(分屏、折叠、多窗口都会让两者不一致)。
/// 按矩形本身判断,任何形态都对:
/// 宽 > 高 → 3 列(宽屏铺开,格子更接近正方,数值更好读)
/// 否则 → 2 列
int einkGridColumns(double width, double height) => width > height ? 3 : 2;
/// 从指标目录里挑出**这块屏上真实该显示的 6 项**(纯函数,可测)。
///
/// 规则(顺序即优先级,且**同一设备每次结果一致** —— 看板布局必须稳定):
/// 1. 先取目录里标了 `primary` 的(那是"首屏主线指标"的唯一真相源,
/// 采集页也用同一个标记 —— 不另立一份"核心指标"清单);
/// 2. 跳过 [kEinkSkipKeys](数组型)与 hiddenKeys(该设备声明得不到的);
/// 3. 不足 6 个时,按目录顺序用其余**可得**指标补齐 ——
/// 宁可多显示一个次要指标,也不要留一格显示"—"
/// (安卓设备的 primary 里 `net_rx_mbps`/`net_tx_mbps` 恒不可得,
/// 不补齐的话 6 格里有两格永远是空的,看着像坏了)。
List<MetricDisplaySpec> einkBoardSpecs(Set<String> hiddenKeys) {
final pool = kMetricDisplayCatalog
.where((s) => !kEinkSkipKeys.contains(s.key))
.toList();
bool ok(MetricDisplaySpec s) => !hiddenKeys.contains(s.key);
final out = <MetricDisplaySpec>[];
for (final s in pool) {
if (s.primary && ok(s)) out.add(s);
}
for (final s in pool) {
if (out.length >= kEinkCellCount) break;
if (s.primary || !ok(s) || out.contains(s)) continue;
out.add(s);
}
return out.take(kEinkCellCount).toList();
}
/// 算出**全屏统一**的数值缩放比例(纯函数,可测)。
///
/// ── 为什么不能用 `FittedBox(fit: scaleDown)` 逐格缩 ────────────────
/// 逐格缩是"每格各自适配自己的宽高",于是 `310 kb/s`(长)会比 `24.8%`(短)
/// 小好几号 —— 一屏里数值字号参差,正是产品负责人明确要求避免的。
/// 这里改成:先量**全屏最长**的那条值,按它算一个比例,**所有格子共用**。
/// 结果:同屏数值大小一致,且长值不会溢出(比例是按最长那条定的)。
///
/// 只缩不放(上限 1.0):放大到超过基准字号既没必要,也会打乱长短值的相对关系。
double einkUniformValueScale({
required List<String> texts,
required double maxWidth,
required double maxHeight,
required TextStyle base,
List<double>? fixedWidths,
}) {
if (texts.isEmpty || maxWidth <= 0 || maxHeight <= 0) return 1.0;
final baseSize = base.fontSize ?? 14;
final rooms = <double>[];
var tallest = 0.0;
for (var i = 0; i < texts.length; i++) {
final m = measureEinkText(texts[i], base);
if (m.height > tallest) tallest = m.height;
// 每格"不随数值缩放"的那部分(单位字宽 + 间距)要从可用宽度里扣掉
final fixed =
(fixedWidths != null && i < fixedWidths.length) ? fixedWidths[i] : 0.0;
rooms.add(maxWidth - fixed);
}
if (tallest <= 0) return 1.0;
// ── 第一步:按"基准宽度 × 比例"估算(线性近似)
var s = math.min(1.0, maxHeight / tallest);
for (var i = 0; i < texts.length; i++) {
final w = measureEinkText(texts[i], base).width;
if (w <= 0 || rooms[i] <= 0) continue;
final si = rooms[i] / w;
if (si < s) s = si;
}
// ── 第二步:**把字号真的调小后重新量一遍**再收一次。
//
// 为什么必须有这一步:文本实际宽度与字号**不是严格线性**的
//(字形度量与像素对齐会带来几个百分点偏差),只按第一步做,
// 在"极窄格 + 极长值"时会横向溢出 —— 真机上实测溢出 3.7~9.3px。
//
// 为什么不用 `FittedBox(fit: scaleDown)` 兜底:那是在**渲染期偷偷缩**,
// 于是同一个值在不同格子里看起来不一样(正是要避免的"字号不一"),
// 而且它让 `getSize` 拿不到真实视觉尺寸,测试也就断言不了。
// 这里改成显式测量、显式收敛:结果确定、可测。
for (var pass = 0; pass < 3; pass++) {
var need = 1.0;
final at = base.copyWith(fontSize: baseSize * s);
for (var i = 0; i < texts.length; i++) {
if (rooms[i] <= 0) continue;
final w = measureEinkText(texts[i], at).width;
if (w > rooms[i]) {
final k = rooms[i] / w;
if (k < need) need = k;
}
}
if (need >= 1.0) break;
s *= need;
}
return s >= 1.0 ? 1.0 : s;
}
/// 去掉数值里**无意义的零尾**:`310.00 Mb/s` → `310 Mb/s`、`1.50` → `1.5`。
///
/// ── 为什么该做(2026-09-16 量出来的)────────────────────────────────
/// 副屏的 6 个大数**远看只认量级**,`310.00` 与 `310` 在 6″ 屏上完全一样,
/// 但那两个多余的零要多占约 15% 宽度 —— 而宽度直接决定统一缩放比例:
/// 实测 `310.00 Mb/s` 宽 440px 而单元格只有 182px,比例被拖到 0.41,
/// **一屏 6 个数值全被它一个人压小**。删掉尾零,全场字号立刻回升。
///
/// ── 边界(不许越界)──────────────────────────────────────────────
/// **只删"本来就等于没有"的字符,绝不四舍五入**:
/// `1.42` 原样、`976.56` 原样(该几位就是几位)、`100.0` → `100`。
/// 换句话说:读数一个字都不改,只是把"数学上无信息的 0"去掉。
String compactEinkValue(String s) {
return s.replaceAllMapped(
RegExp(r'(\d)\.(\d*?)0+(?=[^\d]|$)'),
(m) {
final frac = m.group(2)!;
return frac.isEmpty ? m.group(1)! : '${m.group(1)}.$frac';
},
);
}
/// 把「数值 + 单位」拆开:`1.42 Mb/s` → (`1.42`, `Mb/s`)、`24.8%` → (`24.8`, `%`)。
///
/// ── 为什么必须拆(2026-09-16 量出来的)──────────────────────────────
/// 一开始数值与单位一起用大字号渲染,结果 `1.42 Mb/s` 在 40px 下**宽 360px**,
/// 而 3 列时每格只有 207px —— 统一比例被它拖到 0.575,**一屏 6 个数全被压小**。
/// 单位占的宽度是大数字的三分之一以上,却完全不承担"一眼看量级"的职责。
/// 拆开后:数值仍 40px,单位降到 label 档(14px),所需宽度立刻回到一格之内。
/// 这也是仪表盘的通行做法:**大数字、小单位**。
///
/// 规则:取开头的数字前缀(允许负号/小数点/千分位)为数值,其余为**单位**。
/// 不是数字开头(`—`、`N/A`)就整体当数值、单位留空 —— 不猜、不编。
(String value, String unit) splitEinkValueUnit(String s) {
final m = RegExp(r'^[-\d][\d.,]*').firstMatch(s);
if (m == null) return (s, '');
final v = m.group(0)!;
return (v, s.substring(v.length).trim());
}
/// 量一段**单行**文本的尺寸。
///
/// 抽成公开函数是为了让缩放算法能被单独测(TextPainter 需要 widget binding,
/// 故测试要放在 `testWidgets` 里)。
Size measureEinkText(String text, TextStyle style) {
final tp = TextPainter(
text: TextSpan(text: text, style: style),
maxLines: 1,
textDirection: TextDirection.ltr,
)..layout();
return tp.size;
}
/// 水墨屏看板
class EinkBoard extends StatelessWidget {
const EinkBoard({
super.key,
required this.deviceName,
required this.statusKey,
required this.agoText,
required this.frame,
required this.hiddenKeys,
required this.formatter,
required this.lastRefreshText,
required this.refreshNote,
required this.onRefresh,
this.onToggleOrientation,
this.onExit,
this.showDiagnostics = false,
this.onToggleDiagnostics,
this.diagnostics = const [],
});
/// 上报的设备名(如 `MacBook Pro · M3 Max`)
final String deviceName;
/// `online` / `suspect` / `offline` / 其它(未知)
final String statusKey;
/// 末帧距今的人话描述(`已 5 分钟未上报数据` / `数据新鲜(每秒上报)`)
final String agoText;
/// 最新一帧的指标原文;空表示还没数据
final Map<String, dynamic> frame;
/// 该设备声明不可得的指标 key(能力集 / 推定)
final Set<String> hiddenKeys;
/// 值 → 显示文本的**唯一出口**(不在这里自己算单位换算)
final MetricFormatter formatter;
/// 最后一次刷新时刻,如 `20:18:52`
final String lastRefreshText;
/// 刷新节奏说明,如 `每 10 秒` / `仅手动`
final String refreshNote;
final VoidCallback onRefresh;
/// 横竖屏切换(null 时不显示该按钮)。**屏幕方向由宿主锁**,本组件只发指令。
final VoidCallback? onToggleOrientation;
/// 退出看板(回彩色界面)。为 null 时不显示入口。
final VoidCallback? onExit;
/// 是否显示设备自检面板。
///
/// 为什么要有它(2026-09-16):Neo2 上 **USB 调试开不了**,
/// `wm size` / `wm density` 这条常规取数路径断了 —— 而看板版面
/// (列数、字号、格高)**完全取决于逻辑尺寸 dp**,拿不到就是瞎调。
/// 于是把自检做进应用:**把关键数据直接画在屏幕上**,
/// 一张截图就能取到 density / dpr / 逻辑尺寸 / 内存,不依赖 adb。
/// 对一台"没有调试通道"的定制墨水屏设备,这是唯一可靠的取数方式。
final bool showDiagnostics;
final VoidCallback? onToggleDiagnostics;
/// 宿主提供的诊断行(刷新间隔、已收帧数、最后刷新时刻…)。
/// MediaQuery 相关的行由看板自己补 —— 那是展示层的事。
///
/// 每行是 `(标签, 值, 值前面的图标?)`。图标单列一个槽位而不是把 `✓` 拼进值:
/// 状态符号一律走 Material Icons —— 字符符号在水墨屏上会渲染成方框,
/// 且无法随主题着色(与 `ui/theme.dart` 那条「样式只许一处定义」同源)。
final List<(String, String, IconData?)> diagnostics;
@override
Widget build(BuildContext context) {
final specs = einkBoardSpecs(hiddenKeys);
final hasData = frame.isNotEmpty;
return Theme(
data: einkTheme(),
child: Scaffold(
backgroundColor: InkwatchEink.page,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
InkwatchSpace.lg, InkwatchSpace.md, InkwatchSpace.lg, InkwatchSpace.sm),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(),
const SizedBox(height: InkwatchSpace.sm),
Expanded(
child: showDiagnostics
? _diagnostics(context)
: (hasData ? _grid(specs) : _noData()),
),
const SizedBox(height: InkwatchSpace.sm),
_footer(),
],
),
),
),
),
);
}
/// 顶部一行:状态形状 + 设备名(+ **仅异常时**的状态与末帧说明)。
///
/// 为什么不放底色分区:墨水屏上灰底要整块重绘且会残留,改用**一条黑线**分隔
/// —— 墨水屏上最省墨、最清楚的分区手段。
Widget _header() {
final abnormal = statusKey != 'online';
final st = statusStyle(statusKey, InkwatchPalette.eink);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
// 形状而非颜色:实心/半实心/空心/虚线 与 在线/疑似/离线/未知 一一对应
Text(statusGlyph(statusKey), style: InkwatchEinkText.glyph),
const SizedBox(width: InkwatchSpace.sm),
Expanded(
child: Text(deviceName,
style: InkwatchEinkText.title,
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
// ★ 常态极简:在线时「在线」二字与「数据新鲜」都是冗余 ——
// ● 已经说了在线,右下的刷新时刻已经说了数据在动。
// 只有**异常**才把状态与原因铺出来(那时它们才是有用信息)。
if (abnormal) ...[
const SizedBox(width: InkwatchSpace.sm),
Flexible(
child: Text('${st.label} · $agoText',
style: InkwatchEinkText.label,
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
],
],
),
const SizedBox(height: 6),
const Divider(height: 2, thickness: 2, color: InkwatchEink.rule),
],
);
}
/// 6 大格。列数随宽高比([einkGridColumns]),格高由**可用空间**等分。
///
/// ⚠️ 高度必须由空间驱动而不是内容:曾用 `Table`(行高由内容决定),
/// 真机上第 3 行被底部栏压住。看板的硬要求是"一屏放下、永不滚动"。
Widget _grid(List<MetricDisplaySpec> specs) {
return LayoutBuilder(builder: (context, c) {
final cols = einkGridColumns(c.maxWidth, c.maxHeight);
final rows = (specs.length / cols).ceil();
// 数值与单位**分开量**:单位走 label 档且**不参与缩放**,
// 它们占的那部分宽度要先从可用宽度里扣掉(见 `splitEinkValueUnit` 的说明)。
final parts = specs.map((s) => splitEinkValueUnit(_valueText(s))).toList();
final texts = parts.map((p) => p.$1).toList();
const unitGap = 4.0;
final fixedWidths = parts
.map((p) => p.$2.isEmpty
? 0.0
: unitGap + measureEinkText(' ${p.$2}', InkwatchEinkText.label).width)
.toList();
// 单格**内容区**(扣掉内边距、标签行与标签下的间距)—— 用来定统一缩放比例
const padH = 12.0;
final cellW = c.maxWidth / cols - padH * 2;
final labelH = measureEinkText('内存使用率', InkwatchEinkText.label).height;
// ★ 矮屏要**让位给数值**(2026-09-16 真机暴露):
// F50 横屏只有 ~366dp 高,标签 + 内边距吃掉了几乎整格,
// 结果数值被缩到比标签还小 —— 6 个大数才是这个页面的主角。
// 故先按常规排版估算,若留给数值的高度不足就切**紧凑排版**
// (压内边距与标签间距),把空间还给数值。
// 注意:无论哪种排版,比例都仍是**全屏统一**算一个(字号一致这条不让步)。
final rowH = c.maxHeight / rows;
final roomy = rowH - 10.0 * 2 - labelH - 6.0;
final tight = roomy < rowH * 0.45;
final padV = tight ? 4.0 : 10.0;
final labelGap = tight ? 2.0 : 6.0;
final cellH = rowH - padV * 2 - labelH - labelGap;
final scale = einkUniformValueScale(
texts: texts,
maxWidth: cellW,
maxHeight: cellH,
base: InkwatchEinkText.value,
fixedWidths: fixedWidths,
);
// ★ 全屏共用**一个**数值字号(唯一允许改字号的地方,且是算出来的)
final valueStyle = InkwatchEinkText.value
.copyWith(fontSize: InkwatchEinkText.value.fontSize! * scale);
final rows2 = <Widget>[];
for (var r = 0; r < rows; r++) {
final cells = <Widget>[];
for (var col = 0; col < cols; col++) {
final i = r * cols + col;
if (i >= specs.length) {
cells.add(const Expanded(child: SizedBox.shrink()));
continue;
}
cells.add(Expanded(
child: _cell(specs[i], parts[i], valueStyle,
padV: padV,
labelGap: labelGap,
rightBorder: col < cols - 1,
bottomBorder: r < rows - 1),
));
}
rows2.add(Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: cells,
),
));
}
return Container(
decoration:
BoxDecoration(border: Border.all(color: InkwatchEink.rule, width: 1.5)),
child: Column(children: rows2),
);
});
}
/// 指标 → 显示文本。null 用 `—` 而不是 `0`
/// —— 与全项目"读不到就报 null、不报 0"同一条诚实边界。
String _valueText(MetricDisplaySpec s) {
final raw = frame[s.key];
if (raw == null) return '—'; // 读不到就 —,不报 0
return compactEinkValue(formatter.format(s.key, s, raw));
}
Widget _cell(MetricDisplaySpec s, (String, String) parts, TextStyle valueStyle,
{required double padV,
required double labelGap,
bool rightBorder = false,
bool bottomBorder = false}) {
final raw = frame[s.key];
return Container(
decoration: BoxDecoration(
border: Border(
right: rightBorder
? const BorderSide(color: InkwatchEink.rule, width: 1)
: BorderSide.none,
bottom: bottomBorder
? const BorderSide(color: InkwatchEink.rule, width: 1)
: BorderSide.none,
),
),
padding: EdgeInsets.symmetric(horizontal: 12, vertical: padV),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(s.label,
// key 只为让测试能**精确定位**数值与标签:
// "同屏字号必须一致"这条要求只有在能逐格取值时才可断言。
key: ValueKey('eink-label-${s.key}'),
style: InkwatchEinkText.label,
maxLines: 1,
overflow: TextOverflow.ellipsis),
SizedBox(height: labelGap),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
// ⚠️ 这层 `FittedBox` 是**兜底**,不是主力:
// 主力是"全屏统一比例"(同屏数值同号,见 einkUniformValueScale),
// 它已按**实测字形**收敛过,常态下精确。
// 但渲染期文本宽度无法被完全预测(边框/内边距取整、字形度量细节),
// 在"极窄格 + 极长值"时实测仍会溢出 3.7~9.3px。
// 兜底只在这种降级场景生效(正常尺寸下不动,同号不受影响)——
// 宁可某格微量收一下,也绝不让内容被裁掉。
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
// ★ 大数值 —— 唯一的主角
Text(
parts.$1,
key: ValueKey('eink-value-${s.key}'),
style: raw == null
? valueStyle.copyWith(color: InkwatchEink.inkSoft)
: valueStyle,
maxLines: 1,
),
// 单位降为注脚(label 档、**不随数值缩放**)——
// 它不承担"一眼看量级"的职责,却占三成以上宽度
if (parts.$2.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
parts.$2,
key: ValueKey('eink-unit-${s.key}'),
style: InkwatchEinkText.label
.copyWith(color: InkwatchEink.inkSoft),
maxLines: 1,
),
),
],
),
),
),
),
],
),
);
}
/// 还没收到首帧:**静态**提示,不放转圈/骨架动画。
///
/// 这一条是墨水屏的硬要求:转圈与呼吸会持续触发局部刷新,
/// 在一台"刷新就是成本"的设备上,等待期的动画纯粹是伤害。
Widget _noData() {
final abnormal = statusKey != 'online';
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.hourglass_empty, size: 40, color: InkwatchEink.ink),
const SizedBox(height: InkwatchSpace.md),
const Text('等待这台设备上报第一帧', style: InkwatchEinkText.title),
const SizedBox(height: InkwatchSpace.sm),
Text(agoText,
style: InkwatchEinkText.caption,
textAlign: TextAlign.center,
maxLines: 2),
if (abnormal) ...[
const SizedBox(height: InkwatchSpace.xs),
Text(statusStyle(statusKey, InkwatchPalette.eink).label, style: InkwatchEinkText.caption),
],
],
),
);
}
/// 设备自检面板 —— **静态文本**,无动画、无滚动(一屏放下)。
///
/// 内容分两类:
/// · 展示层自有:由 `MediaQuery` 直接读出(逻辑尺寸 / dpr / 物理像素)
/// · 宿主提供:`diagnostics`(刷新间隔、已收帧数、内存…)
///
/// 留这页的真正原因见构造参数 [showDiagnostics] 的注释:
/// 设备上开不了 USB 调试时,**屏幕就是唯一的取数通道**。
Widget _diagnostics(BuildContext context) {
final mq = MediaQuery.of(context);
return LayoutBuilder(builder: (context, c) {
// ★ 布局尺寸取自**约束**而不是 MediaQuery:
// 应用内旋转时 `RotatedBox` 会交换约束,于是两者**不相等** ——
// 这正好是最硬的证据(一眼看出"系统没转、是应用自己在转")。
final rows = <(String, String, IconData?)>[
('窗口(物理)',
'${mq.size.width.round()} × ${mq.size.height.round()} dp', null),
('布局(看板用)',
'${c.maxWidth.round()} × ${c.maxHeight.round()} dp', null),
('设备像素比', mq.devicePixelRatio.toStringAsFixed(3), null),
('物理像素', '${(mq.size.width * mq.devicePixelRatio).round()} × '
'${(mq.size.height * mq.devicePixelRatio).round()}', null),
('看板列数', '${einkGridColumns(c.maxWidth, c.maxHeight)} 列', null),
...diagnostics,
];
return Container(
decoration:
BoxDecoration(border: Border.all(color: InkwatchEink.rule, width: 1.5)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < rows.length; i++)
Expanded(
child: Container(
decoration: BoxDecoration(
border: Border(
bottom: i < rows.length - 1
? const BorderSide(color: InkwatchEink.rule, width: 1)
: BorderSide.none,
),
),
child: Row(
children: [
Expanded(
flex: 2,
child: Text(rows[i].$1, style: InkwatchEinkText.label),
),
Expanded(
flex: 3,
child: Row(
children: [
if (rows[i].$3 != null) ...[
Icon(rows[i].$3, size: 16, color: InkwatchEink.ink),
const SizedBox(width: 4),
],
Flexible(
child: Text(rows[i].$2,
style: InkwatchEinkText.label
.copyWith(color: InkwatchEink.ink),
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
],
),
),
],
),
),
),
],
),
);
});
}
/// 底部一行:刷新时刻 + 节奏 + 操作按钮。
///
/// 只留**一行**小字:原先两行("最后刷新 xx" 与 "每 N 秒自动刷新")说的是
/// 同一件事,合并成 `20:18:52 · 每 10 秒` 即可 —— 副屏角落的字越少越好。
Widget _footer() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Divider(height: 2, thickness: 2, color: InkwatchEink.rule),
const SizedBox(height: InkwatchSpace.sm),
Row(
children: [
Expanded(
child: Text('$lastRefreshText · $refreshNote',
style: InkwatchEinkText.caption,
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
const SizedBox(width: InkwatchSpace.sm),
OutlinedButton(onPressed: onRefresh, child: const Text('刷新')),
if (onToggleOrientation != null) ...[
const SizedBox(width: InkwatchSpace.sm),
OutlinedButton(
onPressed: onToggleOrientation, child: const Text('转屏')),
],
if (onToggleDiagnostics != null) ...[
const SizedBox(width: InkwatchSpace.xs),
TextButton(
onPressed: onToggleDiagnostics,
child: Text(showDiagnostics ? '返回' : '自检')),
],
if (onExit != null) ...[
const SizedBox(width: InkwatchSpace.xs),
TextButton(onPressed: onExit, child: const Text('退出')),
],
],
),
],
);
}
}
+119
View File
@@ -0,0 +1,119 @@
/// 水墨屏模式的**全局开关**与"零动画"等待指示
///
/// ── 为什么要一个全局开关,而不是层层传参 ──────────────────────────
/// "这块屏是不是墨水屏"是**运行时的整机属性**,不是某个组件的属性:
/// 一旦开着,**每一个**会动的控件都要变成不动(转圈、呼吸、水波纹、进度条…)。
/// 若靠层层传 `bool eink` 下去,漏传一个地方就是个持续重绘的动画在墨水屏上转,
/// 而"漏传"这种事在 5 处调用点上是必然会发生的。
///
/// 故这里放一个**显式的全局开关**,并诚实记下它的代价:
/// · 它不参与 Flutter 的依赖通知 —— 改它的人**必须自己触发重建**
/// (实际只在两处改:启动读配置、用户在设置里切换,两处都会重建整棵树);
/// · 它不是"自动检测":Android 没有可靠的"是不是墨水屏"接口,
/// iReader 是深度定制系统也不暴露刷新模式 —— 硬猜会猜错。
/// 所以这个值**来自用户设置**(`AppConfig.einkMode`),一次开启、永久记住。
///
/// ── 为什么"等待"要专门做 ─────────────────────────────────────────
/// `CircularProgressIndicator` 是**无限重复动画**:只要它挂在树上,
/// Flutter 就每帧重绘它。在彩色屏上这只是费一点电;在墨水屏上是
/// **持续局部刷新** —— 残影迅速累积、屏幕发花、耗电,而且它转得越久越糊。
/// 故墨水屏上等待必须是**静止**的(一个方块 + 一句"处理中…")。
library;
import 'package:flutter/material.dart';
import 'theme.dart';
/// 全局:当前是否处于水墨屏模式。见文件头(含"必须自己触发重建"的约定)。
class InkwatchEinkMode {
InkwatchEinkMode._();
static bool enabled = false;
/// 测试用:恢复默认(避免用例之间互相污染)
static void reset() => enabled = false;
}
/// 全局:**看板是否正在前台全屏显示**。
///
/// ── 为什么它必须是"可监听"的,而 [InkwatchEinkMode] 不必 ──────────────
/// `InkwatchEinkMode` 只在整棵树重建时被读一次,静态 bool 够用;
/// 但**主壳要据此收起自己的底部导航** —— 看板渲染在 `IndexedStack` 的 body 里,
/// 而主壳的 `bottomNavigationBar` 是一个**独立区域**,它不感知 body 里发生了什么。
/// 不发通知的话,副屏底下会一直挂着"采集 / 查看"两个页签(实测反馈)。
///
/// 故这里用 `ValueNotifier`,主壳用 `ValueListenableBuilder` 订阅。
class InkwatchEinkBoardMode {
InkwatchEinkBoardMode._();
static final ValueNotifier<bool> active = ValueNotifier<bool>(false);
/// 测试用:恢复默认(避免用例之间互相污染)
static void reset() => active.value = false;
}
/// 等待指示:彩色屏转圈;**墨水屏静止**。
///
/// [size] 与原来的 `CircularProgressIndicator(strokeWidth: 2)` 对得上,
/// 换掉之后布局不跳。
class WaitIndicator extends StatelessWidget {
const WaitIndicator({super.key, this.size = 18, this.label = '处理中…'});
final double size;
/// 墨水屏上显示的静态文字(彩色屏不显示:转圈已经表达了)
final String label;
@override
Widget build(BuildContext context) {
if (InkwatchEinkMode.enabled) {
// 静止:实心方块 + 文字。不用 Icon 的 spinner(那些也是动画)
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: size * 0.6, height: size * 0.6, color: InkwatchEink.fill),
SizedBox(width: 6),
Text(label, style: InkwatchEinkText.caption),
],
);
}
return SizedBox(
width: size,
height: size,
child: CircularProgressIndicator(strokeWidth: 2),
);
}
}
/// 骨架块:彩色屏给静态灰块(本来就是静止的),墨水屏改为**黑描边空心块**。
///
/// 为什么墨水屏不用灰块填充:大面积浅灰在墨水屏上要整块重绘,
/// 且比白底"脏"(灰底会残留)。画个细边框既省墨又清楚。
class EinkAwareSkeleton extends StatelessWidget {
const EinkAwareSkeleton({super.key, required this.width, required this.height});
final double width;
final double height;
@override
Widget build(BuildContext context) {
final p = InkwatchPalette.of(context);
if (InkwatchEinkMode.enabled) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
border: Border.all(color: InkwatchEink.rule, width: 1),
),
);
}
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: p.surfaceMuted,
borderRadius: BorderRadius.circular(InkwatchRadius.sm),
),
);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
/// · 标签 / 单位 / 小数位 / 分组 / 是否可画折线 / 不可得原因 → 纯 Dart 层
/// · 图标 → 本文件(唯一新增,且必须与目录键集合一致,由一致性测试锁定)
///
/// 运行校验:cd pmon/tests && node schema-consistency-test.js
/// 运行校验:cd inkwatch/tests && node schema-consistency-test.js
library;
import 'package:flutter/material.dart';
+72 -73
View File
@@ -19,6 +19,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import '../host/account_api.dart';
import 'eink_mode.dart';
import 'theme.dart';
class PairingCard extends StatefulWidget {
@@ -61,6 +62,8 @@ class PairingCard extends StatefulWidget {
}
class _PairingCardState extends State<PairingCard> {
InkwatchPalette get _p => InkwatchPalette.of(context);
late final PairingApi _api;
Timer? _tick;
@@ -177,7 +180,7 @@ class _PairingCardState extends State<PairingCard> {
/// 每秒刷新剩余有效期(到期后自动撤下大字码,避免用户拿过期码去念)
void _startCountdown() {
_tick?.cancel();
_tick = Timer.periodic(const Duration(seconds: 1), (_) {
_tick = Timer.periodic(Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() => _nowMs = DateTime.now().millisecondsSinceEpoch);
final exp = _issued?.expiresAtMs;
@@ -215,30 +218,30 @@ class _PairingCardState extends State<PairingCard> {
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.shield_outlined, size: 18),
const SizedBox(width: 8),
const Text('谁能看我',
Icon(Icons.shield_outlined, size: 18),
SizedBox(width: 8),
Text('谁能看我',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
const Spacer(),
Spacer(),
IconButton(
tooltip: '刷新授权清单',
onPressed: _loadingAccounts ? null : () => unawaited(_loadAccounts()),
icon: const Icon(Icons.refresh, size: 18),
icon: Icon(Icons.refresh, size: 18),
),
],
),
const SizedBox(height: 2),
SizedBox(height: 2),
Text('本机标识 ${widget.deviceId}',
style: const TextStyle(fontSize: 11.5, color: PmonColors.textSecondary)),
const SizedBox(height: 6),
style: TextStyle(fontSize: 11.5, color: _p.textSecondary)),
SizedBox(height: 6),
_ownershipRow(),
const SizedBox(height: 10),
SizedBox(height: 10),
if (_error != null) _errorBox(_error!),
if (_issued != null) _codeBox(_issued!, _remainSec),
@@ -248,73 +251,70 @@ class _PairingCardState extends State<PairingCard> {
FilledButton.icon(
onPressed: _issuing ? null : () => unawaited(_issueCode()),
icon: _issuing
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.vpn_key_outlined, size: 18),
label: const Text('生成配对码'),
? WaitIndicator(size: 14, label: '生成中…')
: Icon(Icons.vpn_key_outlined, size: 18),
label: Text('生成配对码'),
),
const SizedBox(width: 10),
const Expanded(
SizedBox(width: 10),
Expanded(
child: Text('生成后念给对方,由对方在查看端输入',
style: TextStyle(fontSize: 11.5, color: PmonColors.textSecondary)),
style: TextStyle(fontSize: 11.5, color: _p.textSecondary)),
),
],
),
const SizedBox(height: 14),
const Divider(height: 1),
const SizedBox(height: 10),
SizedBox(height: 14),
Divider(height: 1),
SizedBox(height: 10),
Row(
children: [
const Text('已授权可查看本机的账号',
Text('已授权可查看本机的账号',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
const SizedBox(width: 6),
SizedBox(width: 6),
Text('(${_accounts.length})',
style: const TextStyle(fontSize: 12, color: PmonColors.textSecondary)),
style: TextStyle(fontSize: 12, color: _p.textSecondary)),
],
),
const SizedBox(height: 6),
SizedBox(height: 6),
if (_loadingAccounts)
const Padding(
Padding(
padding: EdgeInsets.symmetric(vertical: 6),
child: Text('读取中…',
style: TextStyle(fontSize: 12, color: PmonColors.textTertiary)),
style: TextStyle(fontSize: 12, color: _p.textTertiary)),
)
else if (_accounts.isEmpty)
const Padding(
Padding(
padding: EdgeInsets.symmetric(vertical: 4),
child: Text('还没有其他人获得查看授权。本产品不提供任何绕过对方操作的监控能力。',
style: TextStyle(fontSize: 11.5, color: PmonColors.textSecondary, height: 1.4)),
style: TextStyle(fontSize: 11.5, color: _p.textSecondary, height: 1.4)),
)
else
Column(
children: _accounts
.map((a) => Padding(
padding: const EdgeInsets.only(bottom: 4),
padding: EdgeInsets.only(bottom: 4),
child: Row(
children: [
const Icon(Icons.person_outline,
size: 16, color: PmonColors.textTertiary),
const SizedBox(width: 6),
Icon(Icons.person_outline,
size: 16, color: _p.textTertiary),
SizedBox(width: 6),
Expanded(
child: Text(a,
style: const TextStyle(fontSize: 12.5),
style: TextStyle(fontSize: 12.5),
overflow: TextOverflow.ellipsis),
),
TextButton(
onPressed: () => unawaited(_revoke(a)),
child: const Text('解除'),
child: Text('解除'),
),
],
),
))
.toList(),
),
const SizedBox(height: 2),
const Text('解除后对方立即失去订阅权限(无需重启服务)。',
style: TextStyle(fontSize: 11, color: PmonColors.textTertiary)),
SizedBox(height: 2),
Text('解除后对方立即失去订阅权限(无需重启服务)。',
style: TextStyle(fontSize: 11, color: _p.textTertiary)),
],
),
),
@@ -325,11 +325,11 @@ class _PairingCardState extends State<PairingCard> {
if (widget.accountToken.isEmpty) {
return Row(
children: [
const Icon(Icons.link_off, size: 14, color: PmonColors.textTertiary),
const SizedBox(width: 6),
const Expanded(
Icon(Icons.link_off, size: 14, color: _p.textTertiary),
SizedBox(width: 6),
Expanded(
child: Text('本机尚未绑定账号。登录后点「认领」,你的查看端即可免配对看到它。',
style: TextStyle(fontSize: 11, color: PmonColors.textSecondary, height: 1.35)),
style: TextStyle(fontSize: 11, color: _p.textSecondary, height: 1.35)),
),
],
);
@@ -337,29 +337,28 @@ class _PairingCardState extends State<PairingCard> {
if (_ownership == 'mine') {
return Row(
children: [
const Icon(Icons.verified_user_outlined, size: 14, color: PmonColors.online),
const SizedBox(width: 6),
Icon(Icons.verified_user_outlined, size: 14, color: _p.online),
SizedBox(width: 6),
Expanded(
child: Text('已绑定到 ${widget.accountEmail.isEmpty ? "当前账号" : widget.accountEmail}',
style: const TextStyle(fontSize: 11.5, color: PmonColors.online)),
style: TextStyle(fontSize: 11.5, color: _p.online)),
),
],
);
}
return Row(
children: [
const Icon(Icons.link, size: 14, color: PmonColors.suspect),
const SizedBox(width: 6),
const Expanded(
Icon(Icons.link, size: 14, color: _p.suspect),
SizedBox(width: 6),
Expanded(
child: Text('本机还未绑定到当前账号',
style: TextStyle(fontSize: 11.5, color: PmonColors.suspect)),
style: TextStyle(fontSize: 11.5, color: _p.suspect)),
),
TextButton(
onPressed: _claiming ? null : () => unawaited(_claim()),
child: _claiming
? const SizedBox(
width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('认领到当前账号'),
? WaitIndicator(size: 12, label: '认领中…')
: Text('认领到当前账号'),
),
],
);
@@ -367,22 +366,22 @@ class _PairingCardState extends State<PairingCard> {
Widget _errorBox(String msg) => Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(10),
margin: EdgeInsets.only(bottom: 10),
padding: EdgeInsets.all(10),
decoration: BoxDecoration(
color: PmonColors.dangerSoft,
color: _p.dangerSoft,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: PmonColors.danger),
border: Border.all(color: _p.danger),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.error_outline, size: 15, color: PmonColors.danger),
const SizedBox(width: 6),
Icon(Icons.error_outline, size: 15, color: _p.danger),
SizedBox(width: 6),
Expanded(
child: Text(msg,
style: const TextStyle(
fontSize: 11.5, color: PmonColors.textPrimary, height: 1.4)),
style: TextStyle(
fontSize: 11.5, color: _p.textPrimary, height: 1.4)),
),
],
),
@@ -390,35 +389,35 @@ class _PairingCardState extends State<PairingCard> {
Widget _codeBox(PairingOutcome out, int remain) => Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 14),
margin: EdgeInsets.only(bottom: 10),
padding: EdgeInsets.symmetric(vertical: 12, horizontal: 14),
decoration: BoxDecoration(
color: PmonColors.primarySoft,
color: _p.primarySoft,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: PmonColors.primarySoft),
border: Border.all(color: _p.primarySoft),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('配对码(念给对方)',
style: TextStyle(fontSize: 11.5, color: PmonColors.primary)),
const SizedBox(height: 4),
Text('配对码(念给对方)',
style: TextStyle(fontSize: 11.5, color: _p.primary)),
SizedBox(height: 4),
Text(
out.code ?? '------',
style: const TextStyle(
style: TextStyle(
fontSize: 34,
fontWeight: FontWeight.w700,
letterSpacing: 10,
color: PmonColors.primary,
color: _p.primary,
fontFeatures: [FontFeature.tabularFigures()],
),
),
const SizedBox(height: 4),
SizedBox(height: 4),
Text(
remain > 0
? '$remain 秒后失效 · 只能用一次 · 授权后可在下方随时解除'
: '已过期,请重新生成',
style: const TextStyle(fontSize: 11.5, color: PmonColors.primary),
style: TextStyle(fontSize: 11.5, color: _p.primary),
),
],
),
+388
View File
@@ -0,0 +1,388 @@
/// 「常驻采集」卡 —— 桌面端把采集交给系统自启托管的入口(P0-5)
///
/// ── 这张卡要解决什么 ──────────────────────────────────────────────
/// 关窗即停:`main.dart` 的 `dispose()` 会 `_agent?.stop()`,而"关掉窗口继续采"
/// 需要常驻进程 + 自启注册。`bin/inkwatch_agent.dart`(常驻进程)与
/// `bin/inkwatch_install.dart`(注册/卸载/状态)早就就绪,但 GUI **一个入口都没有** ——
/// 用户得自己敲命令,等于这个能力不存在。
///
/// ── 三条设计纪律 ─────────────────────────────────────────────────
/// 1. **判据只有一处**:状态来自 `inkwatch-install status --json`
/// (见 `host/resident_service.dart`),本卡不自己 stat 文件、不自己 pgrep。
/// 2. **不在 GUI 里做 launchctl/systemd**:自启产物内容由 Rust 生成、落盘由
/// 非沙箱 CLI 执行(沙箱写不了 LaunchAgents)。GUI 再写一套就是第二套自启逻辑。
/// 3. **失败要说清原因**:installer 已返回非零并打印"哪条命令失败",
/// 这里把那段原文回显,绝不用"安装失败"四个字盖掉。
///
/// 只在桌面端挂载:Android/iOS 的常驻是前台服务/系统托管,语义不同,别混。
library;
import 'dart:async';
import 'package:flutter/material.dart';
import '../host/resident_service.dart';
import 'theme.dart';
class ResidentCard extends StatefulWidget {
const ResidentCard({
super.key,
required this.ops,
this.serverUrl = '',
this.deviceId = '',
this.onResidentStarted,
this.refreshInterval = const Duration(seconds: 5),
});
/// 状态与动作的实现(生产是 [ResidentService],测试注入假件)
final ResidentOps ops;
/// 安装时要写进配置的网关地址与设备标识(空则沿用现有配置)
final String serverUrl;
final String deviceId;
/// 常驻采集**起来之后**回调 —— 宿主据此停掉 GUI 内的采集。
///
/// 为什么必须有:两个进程同时采集会把两条 seq 流混进同一个 device_id,
/// 服务端的按会话去重窗口判不清 → 丢帧或重复帧,且**不报错**。
final VoidCallback? onResidentStarted;
final Duration refreshInterval;
@override
State<ResidentCard> createState() => _ResidentCardState();
}
class _ResidentCardState extends State<ResidentCard> {
InkwatchPalette get _p => InkwatchPalette.of(context);
InkwatchTextStyles get _tx => InkwatchTextStyles.of(_p);
ResidentStatus _st = ResidentStatus.unknown;
bool _busy = false;
/// 失败原因(原样显示给用户,含 CLI 的 stderr 尾部)
String? _error;
/// 成功动作的简述(例如"已提交安装,正在等进程起来")
String? _note;
Timer? _timer;
@override
void initState() {
super.initState();
unawaited(_refresh());
// 低频轮询:状态来自外部进程,靠事件拿不到;5 秒足够"改完能看见",
// 又不至于每帧都去 fork 一个 CLI。
_timer = Timer.periodic(widget.refreshInterval, (_) => unawaited(_refresh()));
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
Future<void> _refresh() async {
try {
final s = await widget.ops.status();
if (!mounted) return;
setState(() => _st = s);
} catch (e) {
if (!mounted) return;
setState(() => _st = ResidentStatus(
state: ResidentState.unknown,
detail: '读取状态失败:$e',
));
}
}
Future<void> _run(Future<ResidentActionResult> Function() act,
{bool startsResident = false}) async {
setState(() {
_busy = true;
_error = null;
_note = null;
});
final r = await act();
if (!mounted) return;
setState(() {
_busy = false;
if (r.ok) {
_note = r.message.isEmpty ? null : r.message;
} else {
_error = r.message;
}
});
if (r.ok && startsResident) {
// 必须**先**把"装完之后"的真实状态取回来,再决定要不要停本界面采集 ——
// 因为停的判据是"有正面证据证明它在跑",不是"installer 返回了 0"。
await _refresh();
if (!mounted) return;
_applyStartOutcome(r);
} else {
await _refresh();
}
}
/// 是否拿到了"采集端**确实在跑**"的正面证据。
///
/// ⚠️ 判据不是 `r.ok`:installer 退出码 0 只承诺"**已注册**",而"已注册"
/// 与"已在跑"在 Windows 上根本不是一回事 —— `reg add HKCU\...\Run` 是
/// 登录时启动、`schtasks /SC ONLOGON` 也是登录时触发,**整个 Windows 分支
/// 没有任何一条"现在启动进程"的命令**(对比 Linux 的 `systemctl --user
/// enable --now`,那个 `--now` 才等于"现在就起")。
///
/// `probeAvailable == false` 表示 CLI 明说"我没查" —— 那不是证据,是空白。
/// `null`(老 CLI 没这个字段)不算"查不了":那种二进制报的 `running`
/// 就是从 `pgrep` 真查出来的,照旧采信(见 `ResidentStatus.fromJson` 的三态说明)。
bool get _hasRunningEvidence {
final st = _st;
if (st.state != ResidentState.running) return false;
return st.probeAvailable != false;
}
/// 安装动作之后:**有证据才停**本界面采集;没证据就不停,并把原因说清楚。
///
/// ── 为什么"没证据就不停"是对的(而不是"宁可少采")──────────────
/// 双写的风险**只存在于探针可用的平台**(macOS / Linux)—— 那些平台恰好
/// 能拿到正面证据,于是会停。Windows 拿不到证据,但它压根不可能有两个
/// 进程同时在跑(安装命令里没有 start 这一步),所以"不停"也不会双写。
/// **双写风险与"探针不可用"的交集为空**,故这条规则既不放过双写,也不误停。
///
/// 反过来(拿不到证据还照停)的代价在 Windows 上是**必然**发生:
/// 停掉 GUI 采集 + 常驻要等下次登录才起 → 两个都没在采,而卡片还很平静。
void _applyStartOutcome(ResidentActionResult r) {
if (_hasRunningEvidence) {
// 常驻确实在跑 → 停掉 GUI 采集,避免两条 seq 流混进同一个 device_id
// (服务端按会话去重分不清它们,历史曲线会出现无法解释的毛刺且不报错)
widget.onResidentStarted?.call();
return;
}
final prefix = r.message.isEmpty ? '' : '${r.message}\n';
final String why;
if (_st.state == ResidentState.unknown) {
// Windows 现状:装完了,但这个平台没有进程探针
why = '已注册;本平台需重新登录或手动「开始」才会运行。'
'本界面继续采集,不会断数据。';
} else {
// 探针可用但确实没跑起来
why = '安装已完成,但采集进程没有起来(原因见上)。'
'本界面继续采集,不会断数据。';
}
setState(() => _note = '$prefix$why');
}
@override
Widget build(BuildContext context) {
final (dot, label) = switch (_st.state) {
ResidentState.running => (_p.online, '在跑'),
ResidentState.installedNotRunning => (_p.suspect, '已安装但没起来'),
ResidentState.notInstalled => (_p.offline, '未安装'),
ResidentState.unknown => (_p.offline, '状态未知'),
};
final effectiveConfig = _effectiveConfigNote();
return Card(
child: Padding(
padding: EdgeInsets.all(InkwatchSpace.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.memory_outlined, size: 18, color: _p.primary),
SizedBox(width: InkwatchSpace.sm),
Text('常驻采集', style: _tx.title),
Spacer(),
Container(
padding: EdgeInsets.symmetric(
horizontal: InkwatchSpace.sm, vertical: InkwatchSpace.xs),
decoration: BoxDecoration(
color: _p.surfaceMuted,
borderRadius: BorderRadius.circular(InkwatchRadius.pill),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: dot, shape: BoxShape.circle),
),
SizedBox(width: InkwatchSpace.sm),
Text(label, style: _tx.caption.copyWith(color: dot)),
]),
),
],
),
SizedBox(height: InkwatchSpace.sm),
Text(
'由系统自启托管:**关掉这个窗口也会继续采集**。'
'本卡片只调安装器 CLI 显示与操作,不在这里重写自启逻辑。',
style: _tx.caption,
),
SizedBox(height: InkwatchSpace.md),
_statusLine(),
if (effectiveConfig != null) ...[
SizedBox(height: InkwatchSpace.sm),
effectiveConfig,
],
if (_error != null) ...[
SizedBox(height: InkwatchSpace.sm),
_errorBox(_error!),
],
if (_note != null && _error == null) ...[
SizedBox(height: InkwatchSpace.sm),
Text(_note!, style: _tx.caption),
],
SizedBox(height: InkwatchSpace.md),
Row(
children: [
if (_st.state != ResidentState.running)
FilledButton.tonal(
onPressed: _busy
? null
: () => _run(
() => widget.ops.install(
serverUrl: widget.serverUrl,
deviceId: widget.deviceId,
),
startsResident: true,
),
child: Text(_st.state == ResidentState.notInstalled
? '安装并启动'
: '重新安装并启动'),
),
if (_st.state != ResidentState.running) SizedBox(width: InkwatchSpace.sm),
if (_st.state != ResidentState.notInstalled)
OutlinedButton(
onPressed: _busy
? null
: () => _run(widget.ops.uninstall),
child: Text('卸载'),
),
if (_busy) ...[
SizedBox(width: InkwatchSpace.md),
SizedBox(
width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)),
],
],
),
],
),
),
);
}
/// 「采集端**实际读到**的配置」—— 诚实标注,不是"修好"
///
/// ── 为什么必须显示它(而不是显示界面自己那份)────────────────────
/// macOS GUI 是**真沙箱**(`com.apple.security.app-sandbox`,`HOME` 指向
/// `~/Library/Containers/com.inkwatch.inkwatchClient/Data`),它读写的 `ConfigStore`
/// 在应用容器里;而常驻采集端由 launchd 拉起(**非沙箱**),读真实家目录。
/// 于是磁盘上**两份 config 同时活着**:用户在界面里改网关地址,只改了容器那份,
/// 采集端看到的还是旧的 —— 而两边都不会报错。这比"读不到配置"更隐蔽。
///
/// ── 为什么本轮不做同步 ──────────────────────────────────────────
/// 三条路都不通:①安装器双写 —— 沙箱 GUI 无权写容器外;②GUI 调 CLI 去写 ——
/// 沙箱的**子进程继承沙箱**,一样写不了;③让 agent 改读容器 —— 没装过 GUI 的
/// 机器根本没有容器,给主路径加了一条"必须装过 GUI"的依赖。
/// 故本版只做**诚实标注**:把采集端真正读到的东西摆出来,不一致就明说。
/// 真解要等 `files.user-selected.read-write` 或非沙箱 helper。
Widget? _effectiveConfigNote() {
final st = _st;
// 未安装 / 问不到:没有"采集端读到的配置"这回事,别凭空造一行出来
if (st.state == ResidentState.notInstalled ||
st.state == ResidentState.unknown) {
return null;
}
final effUrl = st.serverUrl;
final effId = st.deviceId;
if (effUrl.isEmpty && effId.isEmpty) {
return Text(
'采集端还没读到配置(${st.configPath.isEmpty ? '路径未知' : st.configPath})。',
style: _tx.caption,
);
}
final wantUrl = widget.serverUrl;
final wantId = widget.deviceId;
// 只比"两边都有值且不同":空值表示"沿用现有配置",那不算冲突
final mismatch =
(wantUrl.isNotEmpty && effUrl.isNotEmpty && wantUrl != effUrl) ||
(wantId.isNotEmpty && effId.isNotEmpty && wantId != effId);
final effective = [effUrl, effId].where((s) => s.isNotEmpty).join(' · ');
final wanted = [wantUrl, wantId].where((s) => s.isNotEmpty).join(' · ');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('采集端当前读到:$effective', style: _tx.caption),
SizedBox(height: InkwatchSpace.xs),
if (mismatch)
Text(
'与上方输入框($wanted)不一致:常驻采集读的是安装器那份配置。'
'点一次「重新安装并启动」即可让它改成上面填的值。',
style: _tx.caption.copyWith(color: _p.suspect),
)
else
Text(
'(常驻采集读的是安装器那份配置,与本界面容器内的配置是两份;'
'本版尚未打通,故如实标注)',
style: _tx.caption,
),
],
);
}
Widget _statusLine() {
final st = _st;
switch (st.state) {
case ResidentState.running:
final pid = st.pids.isEmpty ? '' : 'pid ${st.pids.join(",")}';
return Text(
'正在后台采集${pid.isEmpty ? '' : '($pid)'}。本页不再重复采集,'
'避免同一设备被两个进程同时上报。',
style: _tx.bodyMuted,
);
case ResidentState.installedNotRunning:
// 装了但没起来是最需要解释的一态:用户会以为"装过就好了"
final why = !st.autostartRegistered
? '自启项不存在(可能被卸载或首次安装未完成)'
: (st.autostartLoaded ? '自启项已加载但进程没有起来' : '自启项未加载');
return Text('$why。点「重新安装并启动」可重试;仍不行请查看日志:${st.logPath}',
style: _tx.bodyMuted);
case ResidentState.notInstalled:
return Text(
'尚未安装。安装后会由系统在登录时启动采集进程,关掉本窗口也继续采。',
style: _tx.bodyMuted,
);
case ResidentState.unknown:
return Text(st.detail ?? '拿不到状态', style: _tx.bodyMuted);
}
}
Widget _errorBox(String msg) {
return Container(
width: double.infinity,
padding: EdgeInsets.all(InkwatchSpace.md),
decoration: BoxDecoration(
color: _p.dangerSoft,
borderRadius: BorderRadius.circular(InkwatchRadius.sm),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('操作失败(原因如下,来自安装器输出)',
style: TextStyle(
fontSize: 12, fontWeight: FontWeight.w600, color: _p.danger)),
SizedBox(height: InkwatchSpace.xs),
// 原样显示 CLI 的失败输出:哪条命令失败、错在哪,都在这里
SelectableText(msg,
style: TextStyle(
fontSize: 12, height: 1.45, color: _p.textPrimary)),
],
),
);
}
}
+18 -13
View File
@@ -1,6 +1,6 @@
/// 骨架屏(加载占位)—— 让"正在取数"看起来是**有意的状态**,而不是"界面坏了"
///
/// ── 为什么要它(2026-09-15 <user>反馈)────────────────────────────
/// ── 为什么要它(2026-09-15 产品负责人反馈)────────────────────────────
/// "刚进入软件,采集界面是灰色的,应该是在加载数据中,但给人的体验感很差。"
/// 问题不在"要等",而在**等的样子**:启动时界面上是一片灰色的 `—`(未取到值的占位符),
/// 用户读到的是"是不是坏了/是不是没权限"。骨架屏把同一段时间表达成
@@ -25,7 +25,8 @@ class SkeletonBox extends StatelessWidget {
super.key,
this.width,
this.height = 12,
this.radius = 6,
// 圆角走既有刻度,不写就地 6:与 viewer_chart 的字号收敛同一纪律
this.radius = InkwatchRadius.sm,
});
final double? width;
@@ -34,12 +35,13 @@ class SkeletonBox extends StatelessWidget {
@override
Widget build(BuildContext context) {
final p = InkwatchPalette.of(context);
return Container(
width: width,
height: height,
decoration: BoxDecoration(
// 用 surfaceMuted 而不是更浅的灰:浅色主题下要"看得见但不像正文"
color: PmonColors.surfaceMuted,
color: p.surfaceMuted,
borderRadius: BorderRadius.circular(radius),
),
);
@@ -54,17 +56,18 @@ class SkeletonMetricCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final p = InkwatchPalette.of(context);
return Container(
height: height,
padding: const EdgeInsets.symmetric(
horizontal: PmonSpace.lg, vertical: PmonSpace.md),
decoration: PmonCard.standard(),
padding: EdgeInsets.symmetric(
horizontal: InkwatchSpace.lg, vertical: InkwatchSpace.md),
decoration: InkwatchCard.standard(palette: p),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: const [
SkeletonBox(width: 64, height: 11), // 标签位置
SizedBox(height: PmonSpace.md),
SizedBox(height: InkwatchSpace.md),
SkeletonBox(width: 88, height: 18), // 数值位置
],
),
@@ -89,11 +92,11 @@ class SkeletonMetricGrid extends StatelessWidget {
return GridView.count(
crossAxisCount: cols,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: PmonSpace.md,
crossAxisSpacing: PmonSpace.md,
physics: NeverScrollableScrollPhysics(),
mainAxisSpacing: InkwatchSpace.md,
crossAxisSpacing: InkwatchSpace.md,
mainAxisExtent: cols == 1 ? 66 : cellHeight,
children: List.generate(cells, (_) => const SkeletonMetricCard()),
children: List.generate(cells, (_) => SkeletonMetricCard()),
);
});
}
@@ -106,6 +109,8 @@ class LoadingHint extends StatelessWidget {
final String text;
@override
Widget build(BuildContext context) =>
Text(text, style: PmonText.caption);
Widget build(BuildContext context) {
final tx = InkwatchTextStyles.of(InkwatchPalette.of(context));
return Text(text, style: tx.caption);
}
}
+739 -78
View File
@@ -12,7 +12,7 @@
///
/// 1. **颜色/间距/圆角/字号只在这里定义**。页面里出现 `Color(0x…)` 即判红
/// (由 `test/theme_guard_test.dart` 源码结构断言锁定)。
/// 2. **语义优先于色值**:写 `PmonColors.online` 而不是 `Colors.green` ——
/// 2. **语义优先于色值**:写 `InkwatchColors.online` 而不是 `Colors.green` ——
/// 否则"在线"换成别的色时,要满地找哪几处是它。
/// 3. **只提供必要刻度**,不做无限可调的间距体系(4/8/12/16/20/24 够用)。
/// 刻度一多,同一个视觉决策又会有三种写法。
@@ -26,7 +26,7 @@ library;
import 'package:flutter/material.dart';
/// 色板与语义色
abstract final class PmonColors {
abstract final class InkwatchColors {
// ── 中性(浅色极简的骨架)──────────────────────────────────────
/// 页面底色(比卡片略深,让白卡片浮起来 —— 用明度差代替阴影)
static const page = Color(0xFFF6F7F9);
@@ -48,7 +48,7 @@ abstract final class PmonColors {
static const textSecondary = Color(0xFF4B5563);
/// 辅助文字(单位、时间戳、脚注)
static const textTertiary = Color(0xFF8A93A3);
static const textTertiary = Color(0xFF646D7D);
// ── 主色(唯一强调色:主操作 + 选中态 + 链接)──────────────────
static const primary = Color(0xFF2563EB);
@@ -72,6 +72,26 @@ abstract final class PmonColors {
static const danger = Color(0xFFDC2626);
static const dangerSoft = Color(0xFFFDECEC);
// ── 状态文字色(软底上的字形/文字,对比度 AA 以上)──────────────
/// 在线徽标文字(onlineSoft 底 4.68:1)
static const onlineText = Color(0xFF117E39);
/// 疑似离线徽标文字(suspectSoft 底 4.88:1)
static const suspectText = Color(0xFFA45904);
/// 危险提示文字(dangerSoft 底 5.67:1)
static const dangerText = Color(0xFFB81E1E);
/// 离线徽标文字。**与 [textSecondary] 同值也必须独立命名**:
/// 语义优先于色值,将来离线文字换色不必满地找哪些地方借用了正文色。
static const offlineText = Color(0xFF4B5563);
/// 未知徽标文字(同上,语义独立别名,不复用 offlineText 的名字)
static const unknownText = Color(0xFF4B5563);
/// 信息条 / 选中态文字(primarySoft 底 6.73:1)
static const infoText = Color(0xFF124BC4);
/// 图表主色(与主色同源,单独命名以便日后独立调整)
static const chartLine = primary;
@@ -79,8 +99,72 @@ abstract final class PmonColors {
static const gap = Color(0xFFB6BCC8);
}
/// 深色皮肤语义色 —— 与管理台唯一真相源 `tokens.css` 的
/// `html[data-iw-theme="dark"]` 块(05 §1.4,58 个同名键覆写)一一对应。
///
/// ── 只转录「Flutter 端确实会消费」的键 ─────────────────────────────
/// 颜色 / 状态 / 图表 / 品牌 / 反馈里,凡 Web 专属的交互态与预混实色一律
/// **保持缺省、不写进 Dart**,理由与浅色侧 `kWebOnlyTokens` 完全一致:
/// · hover/active/五种 `*-border` 预混实色 → Flutter 用 Material `WidgetState`
/// 处理交互态、用 `withValues(alpha:)` 动态算描边(见 [InkwatchCard.notice]);
/// · `series-2/3/4`、`chart-area/grid/axis`、`*-gap-band/threshold-*` →
/// `viewer_chart.dart` 只画单序列,无多序列 / 预混面积需求;
/// · `logo-*` 四色 → Dart 侧没有消费点;
/// · `mask` / `overlay-shadow` / `focus-ring` 是 CSS 复合值,Flutter 无对应概念。
/// 未转录的 28 个键在 `test/token_parity_test.dart` 的深色豁免表里逐项登记
/// (反向缺项判红),防止有人"好心"补一套用不到的常量。
///
/// 几何 / 排印 / 描边宽度 / 动效 / 端侧派生 / E-Ink 六色**本就不在 dark 块里**
/// (05 §1.4「应保持缺省」),Flutter 已有 `InkwatchSpace` / `InkwatchRadius` /
/// `InkwatchText` 语义档位,一套都不新增。
///
/// ⚠️ [textTertiary] 保持 `#8A93A3`(深底 5.02–6.24:1 达标),**显式不同步**
/// 浅色点修值 `#646D7D` —— 同步会让深底对比跌到 2.98–3.70:1,制造新违规。
abstract final class InkwatchDarkColors {
// ── 中性面(--iw-page / surface / surface-muted / line)──────────
static const page = Color(0xFF0B0E14);
static const surface = Color(0xFF151A23);
static const line = Color(0xFF333B4C);
static const surfaceMuted = Color(0xFF1E2430);
// ── 文字(--iw-text-primary / secondary / tertiary / on-accent)──
static const textPrimary = Color(0xFFF2F4F9);
static const textSecondary = Color(0xFFB9C2D0);
static const textTertiary = Color(0xFF8A93A3);
/// D3 反相:亮蓝主色底上的文字改深墨色(7.23:1)
static const textOnAccent = Color(0xFF0B0E14);
// ── 主色(--iw-primary / primary-soft / info-text)───────────────
static const primary = Color(0xFF6E9CFF);
static const primarySoft = Color(0xFF18243D);
// ── 状态(--iw-online / suspect / offline / danger 各 base+soft+text)──
static const online = Color(0xFF5CE0A0);
static const onlineSoft = Color(0xFF0D2117);
static const onlineText = Color(0xFF9AEAC3);
static const suspect = Color(0xFFD8790E);
static const suspectSoft = Color(0xFF261B0D);
static const suspectText = Color(0xFFF2A32C);
static const offline = Color(0xFF9CA3AF);
static const offlineText = Color(0xFFB9C2D0);
static const unknownText = Color(0xFFB9C2D0);
static const danger = Color(0xFFF87171);
static const dangerSoft = Color(0xFF2E1518);
static const dangerText = Color(0xFFFCA5A5);
static const infoText = Color(0xFFA8C5FF);
// ── 图表(--iw-chart-line / series-1 / gap)─────────────────────
/// 与 [primary] 同源(= `--iw-chart-line` / `--iw-series-1` 深值)
static const chartLine = primary;
static const gap = Color(0xFF6B7488);
// ── 反馈(--iw-snackbar-bg)────────────────────────────────────
/// D3 反相:深色 Toast 底变浅,文字自动用深 [surface](15.85:1 AAA)
static const snackbarBg = Color(0xFFF2F4F9);
}
/// 间距刻度(只此七档;新增刻度前先问"是否真的需要")
abstract final class PmonSpace {
abstract final class InkwatchSpace {
static const xs = 4.0;
static const sm = 8.0;
static const md = 12.0;
@@ -90,7 +174,7 @@ abstract final class PmonSpace {
}
/// 圆角刻度
abstract final class PmonRadius {
abstract final class InkwatchRadius {
static const sm = 8.0;
static const md = 12.0;
static const lg = 16.0;
@@ -98,194 +182,771 @@ abstract final class PmonRadius {
}
/// 文字样式(三级 + 标题 + 数值)
abstract final class PmonText {
abstract final class InkwatchText {
/// 页面/卡片大标题
static const title = TextStyle(
fontSize: 17, fontWeight: FontWeight.w600, color: PmonColors.textPrimary, height: 1.3);
fontSize: 17, fontWeight: FontWeight.w600, color: InkwatchColors.textPrimary, height: 1.3);
/// 分区标题(卡片头)
static const section = TextStyle(
fontSize: 14, fontWeight: FontWeight.w600, color: PmonColors.textPrimary, height: 1.35);
fontSize: 14, fontWeight: FontWeight.w600, color: InkwatchColors.textPrimary, height: 1.35);
/// 正文
static const body = TextStyle(
fontSize: 13, color: PmonColors.textPrimary, height: 1.5);
fontSize: 13, color: InkwatchColors.textPrimary, height: 1.5);
/// 正文(次要)
static const bodyMuted = TextStyle(
fontSize: 13, color: PmonColors.textSecondary, height: 1.5);
fontSize: 13, color: InkwatchColors.textSecondary, height: 1.5);
/// 说明文字(脚注、解释、诚实边界文案)
static const caption = TextStyle(
fontSize: 12, color: PmonColors.textTertiary, height: 1.5);
fontSize: 12, color: InkwatchColors.textTertiary, height: 1.5);
/// 图表轴标签 / Tooltip 的最小档(11px)。
/// 与 E-Ink 分支同纪律:界面里不写就地字号,需要小字就走这一档
/// (`viewer_chart.dart` 的 10 / 10.5 / 11.5 全部收敛到此处)。
static const micro = TextStyle(
fontSize: 11, color: InkwatchColors.textTertiary, height: 1.4);
/// 指标数值(等宽数字,避免跳动)
static const metric = TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: PmonColors.textPrimary,
color: InkwatchColors.textPrimary,
height: 1.15,
fontFeatures: [FontFeature.tabularFigures()]);
/// 指标单位/后缀
static const metricUnit = TextStyle(
fontSize: 12, fontWeight: FontWeight.w500, color: PmonColors.textTertiary);
fontSize: 12, fontWeight: FontWeight.w500, color: InkwatchColors.textTertiary);
/// 小统计块数值
static const stat = TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: PmonColors.textPrimary,
color: InkwatchColors.textPrimary,
fontFeatures: [FontFeature.tabularFigures()]);
}
/// 卡片与容器装饰(**统一在这里造**,页面不要自己拼 BoxDecoration)
abstract final class PmonCard {
/// 标准卡片:白面 + 发丝线 + 大圆角。不用阴影(极简取向,且阴影在深色/低端屏上更难控)
static BoxDecoration standard({Color? tint}) => BoxDecoration(
color: tint ?? PmonColors.surface,
borderRadius: BorderRadius.circular(PmonRadius.md),
border: Border.all(color: PmonColors.line),
);
abstract final class InkwatchCard {
/// 标准卡片:面 + 发丝线 + 大圆角。不用阴影(极简取向,且阴影在深色/低端屏上更难控)
///
/// [palette] 缺省时回退 [InkwatchPalette.light](与改造前逐字段一致),
/// 页面自绘一律传 `InkwatchPalette.of(context)` —— 深色下卡片才真正变深。
static BoxDecoration standard({Color? tint, InkwatchPalette? palette}) {
final p = palette ?? InkwatchPalette.light;
return BoxDecoration(
color: tint ?? p.surface,
borderRadius: BorderRadius.circular(InkwatchRadius.md),
border: Border.all(color: p.line),
);
}
/// 信息条(提示/错误/成功)—— 语义色浅底 + 同色描边
/// 信息条(提示/错误/成功)—— 语义色浅底 + 同色描边。
/// [base] / [soft] 由调用方按当前色板传入(见 [statusStyle])。
static BoxDecoration notice(Color base, Color soft) => BoxDecoration(
color: soft,
borderRadius: BorderRadius.circular(PmonRadius.sm),
borderRadius: BorderRadius.circular(InkwatchRadius.sm),
border: Border.all(color: base.withValues(alpha: 0.28)),
);
/// 选中态容器(主色浅底 + 主色描边)
static BoxDecoration selected() => BoxDecoration(
color: PmonColors.primarySoft,
borderRadius: BorderRadius.circular(PmonRadius.md),
border: Border.all(color: PmonColors.primary, width: 1.5),
);
/// 选中态容器(主色底 + 主色描边)
static BoxDecoration selected({InkwatchPalette? palette}) {
final p = palette ?? InkwatchPalette.light;
return BoxDecoration(
color: p.primarySoft,
borderRadius: BorderRadius.circular(InkwatchRadius.md),
border: Border.all(color: p.primary, width: 1.5),
);
}
}
/// Material 主题装配。**只在这里 new ThemeData** —— 保证控件(输入框/菜单/SnackBar/
/// 对话框)与自绘卡片是同一套颜色与圆角,不会出现"系统控件是旧蓝、自绘是新品"。
ThemeData buildPmonTheme() {
///
/// 浅色 / 深色共用**同一段控件装配**([_assemble]),只传入不同的语义色板
/// ([_lightPalette] / [_darkPalette])—— 不另写一套分叉的控件配置。
/// `buildInkwatchDarkTheme()` 也走这里,故「只在 theme.dart 内 new ThemeData」的
/// 规矩对深色同样成立(页面里就地 `ThemeData(` 由 theme_guard_test.dart 判红)。
ThemeData buildInkwatchTheme() => _assemble(_lightPalette, Brightness.light)
.copyWith(
extensions: const <ThemeExtension<dynamic>>[InkwatchPalette.light]);
/// 深色皮肤主题。色值来自 `tokens.css` 的 `html[data-iw-theme="dark"]`
/// (05 §1.4 的 58 个同名键覆写),由 [InkwatchDarkColors] 逐项转录。
///
/// 装配结构与 [buildInkwatchTheme] 完全相同,仅语义色替换为深色板:
/// 圆角 / 间距 / 字号刻度一律沿用 InkwatchSpace / InkwatchRadius / InkwatchText,
/// 不新增任何刻度。E-Ink 看板走独立的 [einkTheme],不受本主题影响。
ThemeData buildInkwatchDarkTheme() => _assemble(_darkPalette, Brightness.dark)
.copyWith(
extensions: const <ThemeExtension<dynamic>>[InkwatchPalette.dark]);
/// 控件装配用到的语义色子集。**不是新令牌** —— 只是把两套色板喂给同一段装配,
/// 避免把 Web 的「预混实色 / 交互态」概念搬进 Dart。
class _InkwatchPalette {
const _InkwatchPalette({
required this.page,
required this.surface,
required this.line,
required this.surfaceMuted,
required this.textPrimary,
required this.textSecondary,
required this.textTertiary,
required this.textOnAccent,
required this.primary,
required this.primarySoft,
required this.snackbarBg,
required this.snackbarText,
});
final Color page;
final Color surface;
final Color line;
final Color surfaceMuted;
final Color textPrimary;
final Color textSecondary;
final Color textTertiary;
final Color textOnAccent;
final Color primary;
final Color primarySoft;
final Color snackbarBg;
final Color snackbarText;
}
const _lightPalette = _InkwatchPalette(
page: InkwatchColors.page,
surface: InkwatchColors.surface,
line: InkwatchColors.line,
surfaceMuted: InkwatchColors.surfaceMuted,
textPrimary: InkwatchColors.textPrimary,
textSecondary: InkwatchColors.textSecondary,
textTertiary: InkwatchColors.textTertiary,
textOnAccent: Colors.white,
primary: InkwatchColors.primary,
primarySoft: InkwatchColors.primarySoft,
// 浅色 Toast:深底白字(--iw-snackbar-bg 浅色 = textPrimary #111827)
snackbarBg: InkwatchColors.textPrimary,
snackbarText: Colors.white,
);
const _darkPalette = _InkwatchPalette(
page: InkwatchDarkColors.page,
surface: InkwatchDarkColors.surface,
line: InkwatchDarkColors.line,
surfaceMuted: InkwatchDarkColors.surfaceMuted,
textPrimary: InkwatchDarkColors.textPrimary,
textSecondary: InkwatchDarkColors.textSecondary,
textTertiary: InkwatchDarkColors.textTertiary,
textOnAccent: InkwatchDarkColors.textOnAccent,
primary: InkwatchDarkColors.primary,
primarySoft: InkwatchDarkColors.primarySoft,
// D3 反相:深色 Toast 底 = 浅色 #F2F4F9,文字改用深 surface(15.85:1 AAA)
snackbarBg: InkwatchDarkColors.snackbarBg,
snackbarText: InkwatchDarkColors.surface,
);
ThemeData _assemble(_InkwatchPalette p, Brightness brightness) {
final baseScheme = ColorScheme.fromSeed(
seedColor: p.primary,
brightness: brightness,
surface: p.surface,
);
// 深底必须让主色严格落在语义令牌上:fromSeed 会把 primary 推到 tonal 近似值
// (实测 #6E9CFF → #AFC6FF),FilledButton / 选中态就会用到非令牌蓝。
// 浅色分支保持 fromSeed 原始结果,确保 buildInkwatchTheme() 逐字段不变。
final colorScheme = brightness == Brightness.dark
? baseScheme.copyWith(
primary: p.primary,
onPrimary: p.textOnAccent,
onSurface: p.textPrimary,
)
: baseScheme;
final base = ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorScheme: ColorScheme.fromSeed(
seedColor: PmonColors.primary,
brightness: Brightness.light,
surface: PmonColors.surface,
),
scaffoldBackgroundColor: PmonColors.page,
brightness: brightness,
colorScheme: colorScheme,
scaffoldBackgroundColor: p.page,
splashFactory: InkSparkle.splashFactory,
);
return base.copyWith(
appBarTheme: const AppBarTheme(
backgroundColor: PmonColors.surface,
appBarTheme: AppBarTheme(
backgroundColor: p.surface,
surfaceTintColor: Colors.transparent,
foregroundColor: PmonColors.textPrimary,
foregroundColor: p.textPrimary,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
titleTextStyle: PmonText.title,
titleTextStyle: InkwatchText.title.copyWith(color: p.textPrimary),
),
dividerTheme: const DividerThemeData(
color: PmonColors.line, thickness: 1, space: 1),
dividerTheme: DividerThemeData(color: p.line, thickness: 1, space: 1),
cardTheme: base.cardTheme.copyWith(
color: PmonColors.surface,
color: p.surface,
surfaceTintColor: Colors.transparent,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(PmonRadius.md),
side: const BorderSide(color: PmonColors.line),
borderRadius: BorderRadius.circular(InkwatchRadius.md),
side: BorderSide(color: p.line),
),
),
inputDecorationTheme: InputDecorationTheme(
isDense: true,
filled: true,
fillColor: PmonColors.surface,
fillColor: p.surface,
contentPadding: const EdgeInsets.symmetric(
horizontal: PmonSpace.md, vertical: PmonSpace.md),
hintStyle: PmonText.caption,
horizontal: InkwatchSpace.md, vertical: InkwatchSpace.md),
hintStyle: InkwatchText.caption.copyWith(color: p.textTertiary),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(PmonRadius.sm),
borderSide: const BorderSide(color: PmonColors.line),
borderRadius: BorderRadius.circular(InkwatchRadius.sm),
borderSide: BorderSide(color: p.line),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(PmonRadius.sm),
borderSide: const BorderSide(color: PmonColors.line),
borderRadius: BorderRadius.circular(InkwatchRadius.sm),
borderSide: BorderSide(color: p.line),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(PmonRadius.sm),
borderSide: const BorderSide(color: PmonColors.primary, width: 1.5),
borderRadius: BorderRadius.circular(InkwatchRadius.sm),
borderSide: BorderSide(color: p.primary, width: 1.5),
),
),
snackBarTheme: base.snackBarTheme.copyWith(
behavior: SnackBarBehavior.floating,
backgroundColor: PmonColors.textPrimary,
contentTextStyle: const TextStyle(fontSize: 13, color: Colors.white),
backgroundColor: p.snackbarBg,
contentTextStyle: TextStyle(fontSize: 13, color: p.snackbarText),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(PmonRadius.sm)),
borderRadius: BorderRadius.circular(InkwatchRadius.sm)),
),
popupMenuTheme: base.popupMenuTheme.copyWith(
color: PmonColors.surface,
color: p.surface,
surfaceTintColor: Colors.transparent,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(PmonRadius.sm),
side: const BorderSide(color: PmonColors.line),
borderRadius: BorderRadius.circular(InkwatchRadius.sm),
side: BorderSide(color: p.line),
),
textStyle: PmonText.body,
textStyle: InkwatchText.body.copyWith(color: p.textPrimary),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: PmonColors.surface,
backgroundColor: p.surface,
surfaceTintColor: Colors.transparent,
elevation: 0,
// 选中态用主色浅底 + 主色图标/文字。
// 不设的话 M3 会按 seed 自动生成一个偏紫的 secondaryContainer,
// 与我们的蓝主色不搭(真机截图里底部导航是紫色的)。
indicatorColor: PmonColors.primarySoft,
indicatorColor: p.primarySoft,
labelTextStyle: WidgetStateProperty.resolveWith((states) => TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: states.contains(WidgetState.selected)
? PmonColors.primary
: PmonColors.textTertiary,
? p.primary
: p.textTertiary,
)),
iconTheme: WidgetStateProperty.resolveWith((states) => IconThemeData(
size: 22,
color: states.contains(WidgetState.selected)
? PmonColors.primary
: PmonColors.textTertiary,
? p.primary
: p.textTertiary,
)),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: PmonColors.primary,
foregroundColor: p.primary,
textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
),
listTileTheme: const ListTileThemeData(
iconColor: PmonColors.textTertiary,
textColor: PmonColors.textPrimary,
listTileTheme: ListTileThemeData(
iconColor: p.textTertiary,
textColor: p.textPrimary,
),
);
}
/// 状态 → (文字, 颜色, 浅底)的**唯一映射**。
/// 状态 → (文字, 颜色, 浅底, 文字色)的**唯一映射**。
///
/// 为什么单独抽出来:状态颜色曾在采集页/查看页/设备列表三处各写一套 if-else,
/// 于是"疑似离线"在一处是橙、在另一处是黄。这类"同一语义两处判定"是本仓库的老病,
/// 故收敛成一条函数,新增状态只改这里。
({String label, Color color, Color soft}) statusStyle(String key) {
///
/// 只扩到 **4 字段、不带 `border`**:Flutter 的语义描边由
/// `base.withValues(alpha: 0.28)` 动态生成(见 [InkwatchCard.notice]),
/// 没有 Web 端「预混实色描边」这一需求,不要把 CSS 的实现概念搬进 Dart。
/// [palette] 缺省时回退 [InkwatchPalette.light](与改造前逐字段一致);
/// 页面自绘一律传当前色板,深色 / E-Ink 下状态色才跟着换。
({String label, Color color, Color soft, Color text}) statusStyle(
String key, [InkwatchPalette? palette]) {
final p = palette ?? InkwatchPalette.light;
switch (key) {
case 'online':
return (label: '在线', color: PmonColors.online, soft: PmonColors.onlineSoft);
return (label: '在线', color: p.online,
soft: p.onlineSoft, text: p.onlineText);
case 'suspect':
return (label: '疑似离线', color: PmonColors.suspect, soft: PmonColors.suspectSoft);
return (label: '疑似离线', color: p.suspect,
soft: p.suspectSoft, text: p.suspectText);
case 'offline':
return (label: '离线', color: PmonColors.offline, soft: PmonColors.surfaceMuted);
return (label: '离线', color: p.offline,
soft: p.surfaceMuted, text: p.offlineText);
case 'unknown':
// 未知**必须**是中性灰:把"不知道"染成告警色等于编造结论(诚实边界)
return (label: '状态未知', color: p.offline,
soft: p.surfaceMuted, text: p.unknownText);
case 'info':
return (label: '提示', color: p.primary,
soft: p.primarySoft, text: p.infoText);
default:
return (label: '状态未知', color: PmonColors.offline, soft: PmonColors.surfaceMuted);
return (label: '状态未知', color: p.offline,
soft: p.surfaceMuted, text: p.unknownText);
}
}
// ══════════════════════════════════════════════════════════════════
// 水墨屏(E-Ink)令牌 —— iReader Neo 2 这类 6″ 黑白 256 灰阶设备
// ══════════════════════════════════════════════════════════════════
/// 水墨屏配色。
///
/// ── 为什么不能照搬上面的浅色主题(2026-09-15)──────────────────────
/// 墨水屏的显示物理与彩色 LCD 完全不同,上面那套令牌搬过去会"糊":
///
/// | 浅色主题的写法 | 在墨水屏上的后果 |
/// |---|---|
/// | 浅色辅助文字色(`textTertiary` 浅灰字) | 300PPI 墨水屏下也发虚,且**黑色残影**明显 |
/// | `page = #F6F7F9` 浅灰底 | 大面积灰底要全屏重绘(慢),且灰底上再写灰字几乎看不清 |
/// | `line = #E5E7EB` 发丝线 | 灰阶下与底几乎同色 → 等于没有分隔 |
/// | `online` 绿 / `suspect` 橙 | **亮度接近**,灰阶化后两个状态长得一样 |
/// | 任何动画(转圈、呼吸) | 持续局部刷新 → 残影、闪、发热、耗电(墨水屏最忌) |
///
/// 故墨水屏只用**三档明度**(纯黑 / 深灰 / 纯白),分层靠**字重与描边**而不是底色,
/// 状态靠**形状**而不是颜色(见 [statusGlyph])。
class InkwatchEink {
InkwatchEink._();
/// 底:纯白。墨水屏的"原生"是白纸,浅灰底只会浪费对比度
static const page = Color(0xFFFFFFFF);
/// 主文字:纯黑(不要用 #111827 —— 深灰在黑字旁边会显得发虚)
static const ink = Color(0xFF000000);
/// 次级文字/单位:深灰。**不要用浅灰** —— 那是 LCD 的习惯,墨水屏上会糊
static const inkSoft = Color(0xFF444444);
/// 描边与分隔:黑细线(墨水屏上灰线等于没有)
static const rule = Color(0xFF000000);
/// 卡片/格子:白底 + 黑描边(不靠灰底分区)
static const box = Color(0xFFFFFFFF);
/// 需要"填充对比"的地方(条形/进度),与白底形成明确两级
static const fill = Color(0xFF000000);
}
/// 水墨屏文字样式 —— **全分支唯一的字号刻度**。
///
/// ── 两条纪律(2026-09-16 立,为了让"字号不一"不可能再出现)──────────
/// 1. **界面里不得写就地字号**(`fontSize: 数字`)。要新尺寸就在下面加一档,
/// 并写清它对应什么语义 —— 否则同一类元素很快会变成 13/13.5/14 三种写法
/// (彩色主题当年就是这样烂掉的,见 `theme.dart` 文件头)。
/// 2. **同一屏的数值必须共用同一字号**。数值缩放要按**全屏最长的那条**统一算
/// (`einkUniformValueScale`),不能用 `FittedBox` 逐格各自缩 ——
/// 逐格缩的结果是 `310 kb/s` 比 `24.8%` 小一号,一屏里字号参差。
///
/// 为什么全部加粗(最低 w600):墨水屏墨滴扩散会让细笔画发虚,
/// w400 正文在 6″ 300PPI 上远看会断笔。
class InkwatchEinkText {
InkwatchEinkText._();
/// 数值(**基准字号**)。实际字号 = 本档 × `einkUniformValueScale()`
/// 算出的全屏统一比例(只会缩、不会放)。
static const value = TextStyle(
fontSize: 40, fontWeight: FontWeight.w700, color: InkwatchEink.ink, height: 1.0);
/// 状态形状(●/◐/○/◌)—— 与设备名同行,比它略大以便一眼可辨
static const glyph = TextStyle(
fontSize: 22, fontWeight: FontWeight.w700, color: InkwatchEink.ink, height: 1.0);
/// 设备名
static const title = TextStyle(
fontSize: 20, fontWeight: FontWeight.w700, color: InkwatchEink.ink, height: 1.15);
/// 指标标签 / 按钮文字(同号,语义不同则用字重区分,不靠字号)
static const label = TextStyle(
fontSize: 14, fontWeight: FontWeight.w600, color: InkwatchEink.inkSoft, height: 1.2);
/// 按钮文字:与 [label] **同号**,只提字重与颜色(不靠改字号来表达重要)
static const action = TextStyle(
fontSize: 14, fontWeight: FontWeight.w700, color: InkwatchEink.ink, height: 1.2);
/// 角落说明(刷新时刻、异常时的末帧提示)
static const caption = TextStyle(
fontSize: 12, fontWeight: FontWeight.w600, color: InkwatchEink.inkSoft, height: 1.3);
}
/// 状态 → **形状**的唯一映射(水墨屏用)。
///
/// 为什么必须有它:24 位色下"在线绿 / 疑似橙 / 离线灰"是三个可辨的颜色,
/// 而 256 灰阶下 `#16A34A`(绿) 与 `#D97706`(橙) 的**相对亮度接近**(0.42 vs 0.45),
/// 灰阶化后几乎同色 —— 界面就在说谎(用户分不清设备到底在不在线)。
///
/// 故同一语义在墨水屏上必须换一条**不依赖颜色**的表达通道:
/// 实心=在线、半实心=疑似离线、空心=离线(与颜色版语义一一对应,由测试钉住)。
String statusGlyph(String key) {
switch (key) {
case 'online':
return '●';
case 'suspect':
return '◐';
case 'offline':
return '○';
default:
// 状态未知(服务端没告知在线态)—— 用"虚线圆"与"确定离线"的实线圆区分开。
// 这两者处置完全不同:未知要等/查,离线要叫对方开机。
return '◌';
}
}
// ══════════════════════════════════════════════════════════════════
// 页面自绘色板(ThemeExtension)—— 整屏深浅 / E-Ink 的唯一查询入口
// ══════════════════════════════════════════════════════════════════
/// 页面自绘语义色板。
///
/// ── 为什么需要它(整屏深色化)────────────────────────────────────
/// [ThemeData] 只负责系统控件(输入框 / 菜单 / SnackBar / 对话框…),
/// 而本项目的卡片、状态灯、说明文字是**自绘**的(BoxDecoration / TextStyle /
/// Icon color)。它们过去直接引用 [InkwatchColors] 静态常量,于是深色下
/// 「系统控件变深了、卡片和文字仍是浅色」—— 整屏并没有真正深色。
///
/// 故把页面自绘会消费的全部语义色收进一份 [ThemeExtension],
/// 由 [buildInkwatchTheme] / [buildInkwatchDarkTheme] / [einkTheme] 各挂一份实例。
/// 页面统一用 [InkwatchPalette.of] 取当前色板:
/// · [light] 逐项等于 [InkwatchColors] —— 浅色视觉一字不变;
/// · [dark] 逐项等于 [InkwatchDarkColors] —— 深色整屏生效;
/// · [eink] 全部映射到 [InkwatchEink] 六色 —— 纯黑白、无彩色。
///
/// 色值**定义源**仍是 [InkwatchColors] / [InkwatchDarkColors] / [InkwatchEink] 静态常量
/// (parity / 护栏测试依赖,一个不动);本类只做「按主题选取」,不新增色值、
/// 不新增圆角 / 间距 / 字号刻度。
class InkwatchPalette extends ThemeExtension<InkwatchPalette> {
const InkwatchPalette({
required this.page,
required this.surface,
required this.surfaceMuted,
required this.line,
required this.textPrimary,
required this.textSecondary,
required this.textTertiary,
required this.primary,
required this.primarySoft,
required this.online,
required this.onlineSoft,
required this.onlineText,
required this.suspect,
required this.suspectSoft,
required this.suspectText,
required this.offline,
required this.offlineText,
required this.unknownText,
required this.danger,
required this.dangerSoft,
required this.dangerText,
required this.infoText,
required this.chartLine,
required this.gap,
});
final Color page;
final Color surface;
final Color surfaceMuted;
final Color line;
final Color textPrimary;
final Color textSecondary;
final Color textTertiary;
final Color primary;
final Color primarySoft;
final Color online;
final Color onlineSoft;
final Color onlineText;
final Color suspect;
final Color suspectSoft;
final Color suspectText;
final Color offline;
final Color offlineText;
final Color unknownText;
final Color danger;
final Color dangerSoft;
final Color dangerText;
final Color infoText;
final Color chartLine;
final Color gap;
/// 当前主题的色板。裸 MaterialApp(widget 测试未挂 extensions)回退 [light],
/// 与改造前视觉一致,也不会因 extension 缺失而崩。
static InkwatchPalette of(BuildContext context) =>
Theme.of(context).extension<InkwatchPalette>() ?? light;
/// 浅色:逐项 = [InkwatchColors]。
static const light = InkwatchPalette(
page: InkwatchColors.page,
surface: InkwatchColors.surface,
surfaceMuted: InkwatchColors.surfaceMuted,
line: InkwatchColors.line,
textPrimary: InkwatchColors.textPrimary,
textSecondary: InkwatchColors.textSecondary,
textTertiary: InkwatchColors.textTertiary,
primary: InkwatchColors.primary,
primarySoft: InkwatchColors.primarySoft,
online: InkwatchColors.online,
onlineSoft: InkwatchColors.onlineSoft,
onlineText: InkwatchColors.onlineText,
suspect: InkwatchColors.suspect,
suspectSoft: InkwatchColors.suspectSoft,
suspectText: InkwatchColors.suspectText,
offline: InkwatchColors.offline,
offlineText: InkwatchColors.offlineText,
unknownText: InkwatchColors.unknownText,
danger: InkwatchColors.danger,
dangerSoft: InkwatchColors.dangerSoft,
dangerText: InkwatchColors.dangerText,
infoText: InkwatchColors.infoText,
chartLine: InkwatchColors.chartLine,
gap: InkwatchColors.gap,
);
/// 深色:逐项 = [InkwatchDarkColors]。
static const dark = InkwatchPalette(
page: InkwatchDarkColors.page,
surface: InkwatchDarkColors.surface,
surfaceMuted: InkwatchDarkColors.surfaceMuted,
line: InkwatchDarkColors.line,
textPrimary: InkwatchDarkColors.textPrimary,
textSecondary: InkwatchDarkColors.textSecondary,
textTertiary: InkwatchDarkColors.textTertiary,
primary: InkwatchDarkColors.primary,
primarySoft: InkwatchDarkColors.primarySoft,
online: InkwatchDarkColors.online,
onlineSoft: InkwatchDarkColors.onlineSoft,
onlineText: InkwatchDarkColors.onlineText,
suspect: InkwatchDarkColors.suspect,
suspectSoft: InkwatchDarkColors.suspectSoft,
suspectText: InkwatchDarkColors.suspectText,
offline: InkwatchDarkColors.offline,
offlineText: InkwatchDarkColors.offlineText,
unknownText: InkwatchDarkColors.unknownText,
danger: InkwatchDarkColors.danger,
dangerSoft: InkwatchDarkColors.dangerSoft,
dangerText: InkwatchDarkColors.dangerText,
infoText: InkwatchDarkColors.infoText,
chartLine: InkwatchDarkColors.chartLine,
gap: InkwatchDarkColors.gap,
);
/// 水墨屏:全部映射到 [InkwatchEink] 六色(白 / 黑 / 深灰,R=G=B 无彩色)。
/// 看板状态靠字形([statusGlyph])而非颜色,此处只需保证绝无彩色。
static const eink = InkwatchPalette(
page: InkwatchEink.page,
surface: InkwatchEink.box,
surfaceMuted: InkwatchEink.page,
line: InkwatchEink.rule,
textPrimary: InkwatchEink.ink,
textSecondary: InkwatchEink.inkSoft,
textTertiary: InkwatchEink.inkSoft,
primary: InkwatchEink.ink,
primarySoft: InkwatchEink.page,
online: InkwatchEink.ink,
onlineSoft: InkwatchEink.page,
onlineText: InkwatchEink.ink,
suspect: InkwatchEink.ink,
suspectSoft: InkwatchEink.page,
suspectText: InkwatchEink.ink,
offline: InkwatchEink.inkSoft,
offlineText: InkwatchEink.inkSoft,
unknownText: InkwatchEink.inkSoft,
danger: InkwatchEink.ink,
dangerSoft: InkwatchEink.page,
dangerText: InkwatchEink.ink,
infoText: InkwatchEink.ink,
chartLine: InkwatchEink.ink,
gap: InkwatchEink.inkSoft,
);
@override
InkwatchPalette copyWith({
Color? page,
Color? surface,
Color? surfaceMuted,
Color? line,
Color? textPrimary,
Color? textSecondary,
Color? textTertiary,
Color? primary,
Color? primarySoft,
Color? online,
Color? onlineSoft,
Color? onlineText,
Color? suspect,
Color? suspectSoft,
Color? suspectText,
Color? offline,
Color? offlineText,
Color? unknownText,
Color? danger,
Color? dangerSoft,
Color? dangerText,
Color? infoText,
Color? chartLine,
Color? gap,
}) =>
InkwatchPalette(
page: page ?? this.page,
surface: surface ?? this.surface,
surfaceMuted: surfaceMuted ?? this.surfaceMuted,
line: line ?? this.line,
textPrimary: textPrimary ?? this.textPrimary,
textSecondary: textSecondary ?? this.textSecondary,
textTertiary: textTertiary ?? this.textTertiary,
primary: primary ?? this.primary,
primarySoft: primarySoft ?? this.primarySoft,
online: online ?? this.online,
onlineSoft: onlineSoft ?? this.onlineSoft,
onlineText: onlineText ?? this.onlineText,
suspect: suspect ?? this.suspect,
suspectSoft: suspectSoft ?? this.suspectSoft,
suspectText: suspectText ?? this.suspectText,
offline: offline ?? this.offline,
offlineText: offlineText ?? this.offlineText,
unknownText: unknownText ?? this.unknownText,
danger: danger ?? this.danger,
dangerSoft: dangerSoft ?? this.dangerSoft,
dangerText: dangerText ?? this.dangerText,
infoText: infoText ?? this.infoText,
chartLine: chartLine ?? this.chartLine,
gap: gap ?? this.gap,
);
@override
InkwatchPalette lerp(covariant InkwatchPalette? other, double t) {
if (other == null) return this;
return InkwatchPalette(
page: Color.lerp(page, other.page, t)!,
surface: Color.lerp(surface, other.surface, t)!,
surfaceMuted: Color.lerp(surfaceMuted, other.surfaceMuted, t)!,
line: Color.lerp(line, other.line, t)!,
textPrimary: Color.lerp(textPrimary, other.textPrimary, t)!,
textSecondary: Color.lerp(textSecondary, other.textSecondary, t)!,
textTertiary: Color.lerp(textTertiary, other.textTertiary, t)!,
primary: Color.lerp(primary, other.primary, t)!,
primarySoft: Color.lerp(primarySoft, other.primarySoft, t)!,
online: Color.lerp(online, other.online, t)!,
onlineSoft: Color.lerp(onlineSoft, other.onlineSoft, t)!,
onlineText: Color.lerp(onlineText, other.onlineText, t)!,
suspect: Color.lerp(suspect, other.suspect, t)!,
suspectSoft: Color.lerp(suspectSoft, other.suspectSoft, t)!,
suspectText: Color.lerp(suspectText, other.suspectText, t)!,
offline: Color.lerp(offline, other.offline, t)!,
offlineText: Color.lerp(offlineText, other.offlineText, t)!,
unknownText: Color.lerp(unknownText, other.unknownText, t)!,
danger: Color.lerp(danger, other.danger, t)!,
dangerSoft: Color.lerp(dangerSoft, other.dangerSoft, t)!,
dangerText: Color.lerp(dangerText, other.dangerText, t)!,
infoText: Color.lerp(infoText, other.infoText, t)!,
chartLine: Color.lerp(chartLine, other.chartLine, t)!,
gap: Color.lerp(gap, other.gap, t)!,
);
}
}
/// 文字样式出口:把 [InkwatchText] 的档位(含浅色 color)按当前色板覆盖文字色。
///
/// 页面自绘统一走 `InkwatchTextStyles.of(p).caption`,避免在几十处散写
/// `InkwatchText.caption.copyWith(color: ...)` —— 那是同一份映射散落多处。
/// [InkwatchText] 静态常量本身**保留不动**(护栏 / parity 测试依赖它)。
class InkwatchTextStyles {
const InkwatchTextStyles(this.p);
final InkwatchPalette p;
static InkwatchTextStyles of(InkwatchPalette p) => InkwatchTextStyles(p);
TextStyle get title => InkwatchText.title.copyWith(color: p.textPrimary);
TextStyle get section => InkwatchText.section.copyWith(color: p.textPrimary);
TextStyle get body => InkwatchText.body.copyWith(color: p.textPrimary);
TextStyle get bodyMuted =>
InkwatchText.bodyMuted.copyWith(color: p.textSecondary);
TextStyle get caption => InkwatchText.caption.copyWith(color: p.textTertiary);
TextStyle get micro => InkwatchText.micro.copyWith(color: p.textTertiary);
TextStyle get metric => InkwatchText.metric.copyWith(color: p.textPrimary);
TextStyle get metricUnit =>
InkwatchText.metricUnit.copyWith(color: p.textTertiary);
TextStyle get stat => InkwatchText.stat.copyWith(color: p.textPrimary);
}
/// 水墨屏专用 Material 主题。
///
/// 与彩色版的三点不同,都是为了**减少刷新**:
/// 1. `splashFactory: NoSplash` —— 去掉点击水波纹(那是每帧重绘的动画);
/// 2. 全部 `elevation: 0` —— 阴影在墨水屏上只会变成一圈脏灰;
/// 3. 颜色走 [InkwatchEink] 三档明度。
///
/// **只在 `theme.dart` 里 new ThemeData**(与彩色版同一条规矩)——
/// 页面里就地 `ThemeData(` 会被 `test/theme_guard_test.dart` 判红。
///
/// 挂 [InkwatchPalette.eink] 是必须的:看板内部若改用 [InkwatchPalette.of] 查询色板,
/// 缺了这一份会取到 null 而崩。
ThemeData einkTheme() => ThemeData(
useMaterial3: true,
extensions: const <ThemeExtension<dynamic>>[InkwatchPalette.eink],
scaffoldBackgroundColor: InkwatchEink.page,
colorScheme: ColorScheme.fromSeed(
seedColor: InkwatchEink.ink,
brightness: Brightness.light,
).copyWith(
surface: InkwatchEink.page,
onSurface: InkwatchEink.ink,
primary: InkwatchEink.ink,
onPrimary: InkwatchEink.page,
),
splashFactory: NoSplash.splashFactory,
splashColor: const Color(0x00000000),
highlightColor: const Color(0x00000000),
appBarTheme: const AppBarTheme(
backgroundColor: InkwatchEink.page,
foregroundColor: InkwatchEink.ink,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
),
cardTheme: const CardThemeData(
color: InkwatchEink.box,
elevation: 0,
margin: EdgeInsets.zero,
),
dividerTheme: const DividerThemeData(
color: InkwatchEink.rule,
thickness: 1,
space: 1,
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: InkwatchEink.ink,
side: const BorderSide(color: InkwatchEink.rule, width: 1.5),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
textStyle: InkwatchEinkText.action,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: InkwatchEink.ink,
textStyle: InkwatchEinkText.action,
),
),
snackBarTheme: SnackBarThemeData(
backgroundColor: InkwatchEink.ink,
contentTextStyle: InkwatchEinkText.label.copyWith(color: InkwatchEink.page),
behavior: SnackBarBehavior.floating,
elevation: 0,
),
);
+717
View File
@@ -0,0 +1,717 @@
/// 设备间传文件 —— 发出与接收两个区,共用同一个内网直连底座。
///
/// ── 一条贯穿全卡片的纪律:「先确认再收」 ────────────────────────────
/// 接收是**两段式**的:先粘贴领取码 → 看清"将收到什么(来自哪台机、什么名字、
/// 多大)" → 再点收下。**不给"粘了码就自动开下"的路径** ——
/// 文件会落到接收方磁盘上,用户有权在写盘之前知道那是什么。
/// (这与本项目对"破坏性操作要先列清单再确认"的态度是一致的。)
///
/// ── 为什么不做成"自动出现在对端"──────────────────────────────────
/// 那需要服务端信令(给采集端加寻址 + 改下游事件白名单 + 部署)。
/// 眼下先用领取码把**可用闭环**做出来:数据本身仍是**两端直连、不经服务器**,
/// 服务端一分钟都不参与 —— 这正是"不落服务端、不受其磁盘与流量限制"的实现方式。
library;
import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import '../host/lan_service.dart';
import 'theme.dart';
/// 把"我这儿有个文件"发信令出去。返回 false = 当前发不了
/// (没有内网端点,或链路没连)—— 那时界面要告诉用户"请把码发过去"。
typedef OfferPublisher = bool Function(
String token,
String name,
int size, {
({String id, String token, int bytesPerSec})? relay,
});
/// 跨网中继的**上限提示**(纯函数,便于单测)。
///
/// 返回 null = 这个文件可以走中继;返回字符串 = 直接给用户看的原因。
///
/// 为什么抽成纯函数:这条判断必须在"点发送之后、真正开始传输之前"生效,
/// 而那段流程依赖 `FilePicker.pickFiles()`(静态调用,测试注入不了)。
/// 把判据与文案单独拎出来,才能用测试钉住"文案在、且说清了能怎么办"。
///
/// 文案两条硬约束:
/// · 不得出现"监视/监听/追踪"类词(合规红线);
/// · 不能只说"传不了",必须给出**能做什么**(换到同一局域网)。
String? relayLimitWarning(int size) {
if (size <= kRelayMaxOnceBytes) return null;
return '这个文件 ${fmtSize(size)} 超过跨网中继的当前单次上限 '
'${fmtSize(kRelayMaxOnceBytes)},没有开始传输。'
'请把两台设备接到同一局域网再传(局域网直连不受此限制)。';
}
class FileTransferCard extends StatefulWidget {
const FileTransferCard({super.key, this.publishOffer, this.incomingOffers});
/// 可选的**免粘贴通道**。不给也能用 —— 退回到"人工把码发过去"。
///
/// 之所以做成可选:采集端页与查看端页各自持有不同的会话对象
/// (`ClientAgent` / `ViewerSession`),卡片不该知道它们是谁。
final OfferPublisher? publishOffer;
/// 来自对端的待领文件
final Stream<LanIncomingOffer>? incomingOffers;
@override
State<FileTransferCard> createState() => _FileTransferCardState();
}
class _FileTransferCardState extends State<FileTransferCard> {
InkwatchPalette get _p => InkwatchPalette.of(context);
InkwatchTextStyles get _tx => InkwatchTextStyles.of(_p);
// ── 发出侧 ──
LanOutgoing? _offer;
String? _code;
String? _sendErr;
bool _sendBusy = false;
/// 中继上传进度(-1 = 已结束/未开始,≥0 = 已发字节)
int _relaySent = -1;
/// 「已通过服务器通知对端」或「没通知成,请把码发过去」——
/// **这两句话必须分开说**:前者用户什么都不用做,后者要他去转发。
///
/// 状态图标单独一槽(`icon` + `tint`),不拼进文本:
/// Unicode 的 `✓`/`⚠` 在水墨屏上会渲染成方框,且无法随主题着色;
/// `test/symbol_guard_test.dart` 按码位扫 lib/ 防止这类写法回归。
({IconData? icon, Color? tint, String text})? _offerHint;
// ── 免粘贴:对端主动送来的待领文件 ──
StreamSubscription<LanIncomingOffer>? _offerSub;
LanIncomingOffer? _incoming;
bool _incomingBusy = false;
/// 本次收的是不是走中继(界面据此明示"消耗了服务器流量")
bool _viaRelay = false;
// ── 接收侧 ──
final _codeCtrl = TextEditingController();
LanTicket? _ticket;
String? _recvErr;
LanDownloadResult? _result;
bool _recvBusy = false;
@override
void initState() {
super.initState();
_offerSub = widget.incomingOffers?.listen((off) {
if (!mounted) return;
setState(() => _incoming = off);
});
}
@override
void didUpdateWidget(FileTransferCard old) {
super.didUpdateWidget(old);
// ⚠️ **与 AccountCard 同一类问题**:宿主(采集端页/查看端页)的会话对象是
// **异步创建**的 —— 卡片先建、`_agent`/`_session` 后到。若只在 initState 订阅,
// 这个通道会被**永久漏掉**,表现为"免粘贴功能时灵时不灵",且极难定位。
if (old.incomingOffers != widget.incomingOffers) {
_offerSub?.cancel();
_offerSub = widget.incomingOffers?.listen((off) {
if (!mounted) return;
setState(() => _incoming = off);
});
}
}
@override
void dispose() {
_offerSub?.cancel();
_codeCtrl.dispose();
super.dispose();
}
/// 接收下来的文件放哪。
///
/// 优先**下载目录**(Android 上是 app-specific 的外部目录,
/// 系统文件管理器**看得到**);拿不到才退回应用私有目录。
/// 存在用户翻不到的地方,等于"传了个寂寞"。
Future<String> _saveDir() async {
final base =
await getDownloadsDirectory() ?? await getApplicationDocumentsDirectory();
final dir = Directory('${base.path}/InkWatch');
await dir.create(recursive: true);
return dir.path;
}
Future<void> _pickAndOffer() async {
setState(() {
_sendBusy = true;
_sendErr = null;
});
try {
final s = LanService.instance;
if (!s.running) {
await s.start();
}
if (!s.running) {
throw StateError(
'这台机器没有可用的内网地址,无法对外提供文件(${s.lastError})');
}
// file_picker 13.x:静态调用,返回**列表**;用户取消时返回**空列表**
// (不是 null)—— "取消"不是错误,别把它渲染成红色提示。
final picked = await FilePicker.pickFiles();
if (picked.isEmpty) {
setState(() => _sendBusy = false);
return;
}
final path = picked.first.path;
if (path == null) {
throw StateError('拿不到该文件的本地路径,无法提供下载');
}
final o = await s.offer(path);
if (!mounted) return;
// ── 三层降级:内网直连 → (对端侧)公网直连 → 中继兜底 ──
// 优先走**快的**:有内网端点就直连;没有才开中继。
var pushed = widget.publishOffer?.call(o.token, o.name, o.size) ?? false;
var viaRelay = false;
var relayBps = 8 * 1024 * 1024; // 仅用于文案里的耗时估算
if (!pushed) {
// 没有可用的内网端点 → 只能走服务器中继。中继有**单次上限**(服务端会 409),
// 故在真正开始传输之前先说清、并且**不发起** —— 否则就是用户看到的
// "选了文件、点了发送、进度条不动"(E2 类缺陷的界面版本:不报错也不动)。
final warn = relayLimitWarning(o.size);
if (warn != null) {
LanService.instance.revoke(o.token); // 没发出去,本地票据一并撤销
setState(() {
_sendErr = warn;
_sendBusy = false;
});
return;
}
// 没有可用的内网端点 → 开一条中继兜底(服务端会先查四道限制)
final r = await openRelayForSend(size: o.size);
pushed = widget.publishOffer?.call(
'', o.name, o.size,
relay: (id: r.id, token: r.downloadToken, bytesPerSec: r.bytesPerSec),
) ??
false;
if (pushed) {
viaRelay = true;
relayBps = r.bytesPerSec;
// 中继是"两条连接对接后直接 pipe",**必须等对方来收**。
// 这里后台等着并推过去(对方点「收下」时服务端才建立起管道)。
unawaited(uploadViaRelay(
channel: r.upload,
path: path,
onProgress: (sent, total) {
if (!mounted) return;
setState(() => _relaySent = sent);
},
).catchError((Object e) {
if (!mounted) return;
setState(() => _sendErr = '中继传输中断:$e');
}).whenComplete(() {
if (!mounted) return;
setState(() => _relaySent = -1); // 传完/结束
}));
}
}
setState(() {
_offer = o;
_code = encodeLanTicket(
endpoint: s.endpoint ?? '中继', token: o.token, name: o.name, size: o.size);
_offerHint = !pushed
? (
icon: Icons.warning_amber,
tint: _p.suspect,
text: '没能通知对端,也开不了中继 —— 请把下面的码发给它。',
)
: viaRelay
? (
icon: Icons.warning_amber,
tint: _p.suspect,
text: '已走**中继**(两台设备不在同一局域网)。'
'文件经服务器中转、不落盘,但会消耗流量包;'
'对方点「收下」后开始传输(限速 ${_fmtBps(relayBps)})。',
)
: (
icon: Icons.check_circle,
tint: _p.online,
text: '已走**局域网直连**(最快、且不消耗服务器流量)。'
'对端会直接出现待领提示。',
);
_sendBusy = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_sendErr = '$e';
_sendBusy = false;
});
}
}
void _revoke() {
final o = _offer;
if (o != null) LanService.instance.revoke(o.token);
setState(() {
_offer = null;
_code = null;
_sendErr = null;
_offerHint = null;
});
}
/// 解析领取码 —— **只解析、不下载**(这是"先确认"的那一步)。
void _parseCode() {
final t = parseLanTicket(_codeCtrl.text);
setState(() {
_result = null;
if (t == null) {
_ticket = null;
_recvErr = '这不是一个有效的领取码。请确认复制完整(以 INKW-FT1. 开头)。';
} else {
_ticket = t;
_recvErr = null;
}
});
}
Future<void> _receive() async {
final t = _ticket;
if (t == null) return; // 没确认过就不给下载
await _download(t, onBusy: (v) => _recvBusy = v);
}
/// 收下"对端送来的待领文件"。
///
/// ⚠️ 与手工粘贴**共用同一个下载实现** —— 两条入口、一套逻辑。
/// 分开写迟早会出现"一条路径修了、另一条没修"。
Future<void> _receiveFromIncoming() async {
final off = _incoming;
if (off == null) return;
setState(() {
_incomingBusy = true;
_recvErr = null;
});
try {
// ── 三层:内网直连 → 公网直连 → 中继兜底。**顺序就是"优先选快的"** ──
// ① 内网(最快、不耗流量)
var hit = off.endpoints.isEmpty
? null
: await pickReachableLanEndpoint(off.endpoints);
if (!mounted) return;
// ② 公网直连(次之:不耗服务器流量,但取决于对端有没有可达地址)
if (hit == null && off.wanEndpoints.isNotEmpty) {
hit = await pickReachableLanEndpoint(off.wanEndpoints);
if (!mounted) return;
}
if (hit != null) {
final t = off.toTicket(hit);
setState(() => _viaRelay = false);
await _download(t, onBusy: (v) => _incomingBusy = v);
return;
}
// ③ 中继兜底(会烧流量包 —— 服务端已限速+配额,界面明示)
if (!off.hasRelay) {
setState(() {
_incomingBusy = false;
_recvErr = '三条路都不通:内网 ${off.endpoints.length} 个端点、'
'公网 ${off.wanEndpoints.length} 个端点都连不上,且对端没有开中继。';
});
return;
}
final dir = await _saveDir();
final safe = off.name.replaceAll(RegExp(r'[/\\]'), '_');
setState(() => _viaRelay = true);
final res = await downloadViaRelay(
channel: LanRelayChannel(
id: off.relayId!, token: off.relayToken!,
serverBase: LanService.instance.relayCreds?.serverUrl ?? '',
bytesPerSec: off.relayBytesPerSec ?? 8 * 1024 * 1024,
),
savePath: '$dir/$safe',
);
if (!mounted) return;
setState(() {
_result = res;
_incomingBusy = false;
_recvErr = res.verified ? null : '文件已收下(经中继),但**校验未通过**,请勿直接使用。';
});
} finally {
if (mounted && _incomingBusy) setState(() => _incomingBusy = false);
}
}
/// 两条入口共用的下载实现。
Future<void> _download(LanTicket t, {required void Function(bool) onBusy}) async {
setState(() => onBusy.call(true));
try {
final dir = await _saveDir();
// 文件名来自对端,**只取 basename** —— 不让对端用路径做别的事
final safe = t.name.replaceAll(RegExp(r'[/\\]'), '_');
final res = await downloadLanFile(
endpoint: t.endpoint,
token: t.token,
savePath: '$dir/$safe',
timeout: Duration(minutes: 10),
);
if (!mounted) return;
setState(() {
_result = res;
onBusy.call(false);
if (!res.verified) {
_recvErr = '文件已收下,但**校验未通过** —— 内容可能与原件不一致,请勿直接使用。';
}
});
} catch (e) {
if (!mounted) return;
setState(() {
_recvErr = '接收失败:$e\n(常见原因:两台设备不在同一局域网,或发送方已撤销/超时)';
onBusy.call(false);
});
}
}
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(Icons.swap_horiz, size: 18, color: _p.primary),
SizedBox(width: 8),
Text('设备间传文件',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
]),
SizedBox(height: 4),
Text(
'同一局域网内两台设备直传,文件**不经过服务器** —— 所以不受服务器磁盘与流量限制。',
style: TextStyle(
fontSize: 11.5, color: _p.textTertiary, height: 1.4),
),
SizedBox(height: 12),
Divider(height: 1),
SizedBox(height: 12),
_sendSection(),
SizedBox(height: 16),
Divider(height: 1),
SizedBox(height: 12),
_recvSection(),
],
),
),
);
}
// ── 发出 ──
Widget _sendSection() {
final o = _offer;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('发出',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
SizedBox(height: 8),
if (o == null) ...[
FilledButton.tonal(
onPressed: _sendBusy ? null : () => _pickAndOffer(),
child: Row(mainAxisSize: MainAxisSize.min, children: [
if (_sendBusy) ...[
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2)),
SizedBox(width: 8),
],
Text(_sendBusy ? '处理中…' : '选择文件'),
]),
),
SizedBox(height: InkwatchSpace.sm),
// ⚠️ 这句话必须在**点发送之前**就摆在界面上:跨网只能走服务器中继,
// 而中继当前有 1 MiB 单次上限。等到进度条不动再说,等于骗人。
Text('跨网(两台设备不在同一局域网)时走服务器中继,'
'当前单次上限 ${fmtSize(kRelayMaxOnceBytes)};'
'更大的文件请在两台设备处于同一局域网时传(直连不受此限制)。',
style: _tx.caption),
] else ...[
Text('${o.name} ${fmtSize(o.size)}',
style: TextStyle(fontSize: 12.5),
overflow: TextOverflow.ellipsis),
SizedBox(height: 6),
if (_offerHint != null) ...[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_offerHint!.icon != null) ...[
Padding(
padding: const EdgeInsets.only(top: 1.5),
child: Icon(_offerHint!.icon,
size: 14, color: _offerHint!.tint),
),
const SizedBox(width: 4),
],
Expanded(
child: Text(_offerHint!.text,
style: TextStyle(
fontSize: 11.5,
color: _p.textSecondary,
height: 1.4)),
),
],
),
SizedBox(height: 6),
],
// 中继上传进度。**只在真走中继时出现** —— 直连那条路是秒传的,
// 给它加进度条只会让人以为"卡住了"。
if (_relaySent >= 0 && o.size > 0) ...[
LinearProgressIndicator(
value: o.size > 0 ? (_relaySent / o.size).clamp(0.0, 1.0) : null,
minHeight: 4),
SizedBox(height: 4),
Text('经中继已发送 ${fmtSize(_relaySent)} / ${fmtSize(o.size)}',
style: TextStyle(fontSize: 11, color: _p.textTertiary)),
SizedBox(height: 6),
],
Text('或者把这段码发给对方(复制即可):',
style: TextStyle(fontSize: 11.5, color: _p.textTertiary)),
SizedBox(height: 6),
Container(
width: double.infinity,
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
color: _p.surfaceMuted,
borderRadius: BorderRadius.circular(6),
),
child: SelectableText(_code ?? '',
style: TextStyle(fontSize: 11, fontFamily: 'monospace')),
),
SizedBox(height: 8),
Row(children: [
OutlinedButton(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: _code ?? ''));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('领取码已复制')));
},
child: Text('复制'),
),
SizedBox(width: 8),
TextButton(onPressed: _revoke, child: Text('撤销')),
]),
],
if (_sendErr != null) ...[
SizedBox(height: 8),
Text(_sendErr!,
style: TextStyle(fontSize: 11.5, color: _p.danger)),
],
],
);
}
// ── 接收 ──
Widget _recvSection() {
final t = _ticket;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('接收',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
// ★ 免粘贴:对端通过服务器送来的待领提示。
// 它摆在粘贴框**上面** —— 有它的时候用户根本不需要去粘贴。
if (_incoming != null) ...[
SizedBox(height: 8),
Container(
width: double.infinity,
padding: EdgeInsets.all(10),
decoration: BoxDecoration(
color: _p.primarySoft,
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_incoming!.from == 'agent'
? '设备「${_incoming!.deviceId}」要给你一个文件'
: '另一台查看端要给你一个文件',
style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.w600)),
SizedBox(height: 2),
Text('${_incoming!.name} ${fmtSize(_incoming!.size)}',
style: TextStyle(fontSize: 12.5)),
SizedBox(height: 8),
Row(children: [
FilledButton(
onPressed: _incomingBusy ? null : () => _receiveFromIncoming(),
child: Text(_incomingBusy ? '接收中…' : '收下'),
),
SizedBox(width: 8),
TextButton(
onPressed: _incomingBusy
? null
: () => setState(() => _incoming = null),
child: Text('忽略'),
),
]),
],
),
),
],
SizedBox(height: 8),
TextField(
controller: _codeCtrl,
maxLines: 2,
style: TextStyle(fontSize: 11.5),
decoration: InputDecoration(
labelText: '粘贴对方给你的领取码',
hintText: 'INKW-FT1.…',
isDense: true,
),
onChanged: (_) {
// 码一变就把上一次的确认结果作废 ——
// 否则会出现"看着 A 的信息、收的却是 B"这种最不该有的错。
if (_ticket != null || _result != null) {
setState(() {
_ticket = null;
_result = null;
_recvErr = null;
});
}
},
),
SizedBox(height: 8),
Row(children: [
OutlinedButton(
onPressed: _recvBusy ? null : _parseCode,
child: Text('确认要收什么')),
if (t != null) ...[
SizedBox(width: 8),
FilledButton(
onPressed: _recvBusy ? null : _receive,
child: Row(mainAxisSize: MainAxisSize.min, children: [
if (_recvBusy) ...[
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2)),
SizedBox(width: 8),
],
Text(_recvBusy ? '接收中…' : '收下'),
]),
),
],
]),
if (t != null && _result == null) ...[
SizedBox(height: 10),
Container(
width: double.infinity,
padding: EdgeInsets.all(10),
decoration: BoxDecoration(
color: _p.primarySoft,
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('来自 ${t.endpoint}',
style: TextStyle(
fontSize: 11.5, color: _p.textSecondary)),
SizedBox(height: 2),
Text('${t.name} ${fmtSize(t.size)}',
style: TextStyle(
fontSize: 13, fontWeight: FontWeight.w600)),
],
),
),
],
if (_result != null) ...[
SizedBox(height: 10),
Container(
width: double.infinity,
padding: EdgeInsets.all(10),
decoration: BoxDecoration(
color: _result!.verified
? _p.onlineSoft
: _p.dangerSoft,
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// 状态图标走 Material Icons,不拼进文本:
// 字符符号在水墨屏上渲染成方框,也无法随主题着色
Icon(
_result!.verified
? Icons.check_circle
: Icons.error_outline,
size: 15,
color: _result!.verified ? _p.online : _p.danger,
),
const SizedBox(width: 4),
Flexible(
child: Text(
_result!.verified ? '已收下并校验通过' : '已收下但校验未通过',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: _result!.verified ? _p.online : _p.danger),
),
),
],
),
SizedBox(height: 4),
Text('${fmtSize(_result!.bytes)} 保存于 ${_result!.path}',
style: TextStyle(
fontSize: 11, color: _p.textSecondary)),
if (_viaRelay) ...[
SizedBox(height: 2),
// ★ 明示:用户有权知道"这次文件从哪条路来的、花了什么代价"
Text('本次经**中继**中转(不落盘)—— 消耗了服务器流量包',
style: TextStyle(
fontSize: 11, color: _p.suspect)),
] else ...[
SizedBox(height: 2),
Text('本次走**局域网/公网直连** —— 未消耗服务器流量',
style: TextStyle(fontSize: 11, color: _p.online)),
],
],
),
),
],
if (_recvErr != null) ...[
SizedBox(height: 8),
Text(_recvErr!,
style: TextStyle(fontSize: 11.5, color: _p.danger)),
],
],
);
}
}
/// 把"字节/秒"说成人话。用在"中继限速多少、大概要多久"这类**知情**文案里 ——
/// 用户同意走中继的前提是知道它有多快/多慢。
String _fmtBps(int bps) {
if (bps >= 1024 * 1024) return '${(bps / 1024 / 1024).toStringAsFixed(0)} MiB/s';
if (bps >= 1024) return '${(bps / 1024).toStringAsFixed(0)} KiB/s';
return '$bps B/s';
}
/// 字节数说成人话(本卡片与「中继上限」文案共用一份,别再写第二份)
String fmtSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB';
}
return '${(bytes / 1024 / 1024 / 1024).toStringAsFixed(2)} GB';
}
+20 -22
View File
@@ -37,9 +37,11 @@ class ViewerChart extends StatelessWidget {
@override
Widget build(BuildContext context) {
final p = InkwatchPalette.of(context);
final tx = InkwatchTextStyles.of(p);
final pts = _numericPoints();
if (pts.isEmpty) {
return SizedBox(height: height, child: _emptyHint());
return SizedBox(height: height, child: _emptyHint(p, tx));
}
final minY = pts.map((p) => p.y).reduce((a, b) => a < b ? a : b);
@@ -70,7 +72,7 @@ class ViewerChart extends StatelessWidget {
horizontalInterval: null,
// 网格用最淡的发丝线:它的作用是"帮助读数",不是"分割版面"
getDrawingHorizontalLine: (v) =>
const FlLine(color: PmonColors.line, strokeWidth: 1),
FlLine(color: p.line, strokeWidth: 1),
),
borderData: FlBorderData(show: false),
lineTouchData: LineTouchData(
@@ -79,9 +81,9 @@ class ViewerChart extends StatelessWidget {
getTooltipItems: (spots) => spots
.map((s) => LineTooltipItem(
'${_fmt(s.y)}\n${_clockOf(s.x)}',
const TextStyle(
// 浮层是深底 → 字号走 micro 档,只覆写字色/字重
tx.micro.copyWith(
color: Colors.white,
fontSize: 11.5,
fontWeight: FontWeight.w600),
))
.toList(),
@@ -93,7 +95,7 @@ class ViewerChart extends StatelessWidget {
verticalLines: gapXs
.map((x) => VerticalLine(
x: x,
color: PmonColors.gap,
color: p.gap,
strokeWidth: 1,
dashArray: const [4, 4],
))
@@ -101,20 +103,18 @@ class ViewerChart extends StatelessWidget {
),
titlesData: FlTitlesData(
topTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 46,
getTitlesWidget: (v, meta) {
if (v == meta.min || v == meta.max) {
return const SizedBox.shrink();
return SizedBox.shrink();
}
return Text(_fmt(v),
style: const TextStyle(
fontSize: 10.5, color: PmonColors.textTertiary));
return Text(_fmt(v), style: tx.micro);
},
),
),
@@ -124,10 +124,8 @@ class ViewerChart extends StatelessWidget {
reservedSize: 22,
interval: _xInterval(pts),
getTitlesWidget: (v, meta) => Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(_clockOf(v),
style:
const TextStyle(fontSize: 10, color: PmonColors.textTertiary)),
padding: EdgeInsets.only(top: 4),
child: Text(_clockOf(v), style: tx.micro),
),
),
),
@@ -137,12 +135,12 @@ class ViewerChart extends StatelessWidget {
spots: seg,
// 不弯曲:指标是离散采样,平滑曲线会暗示"中间有数据"
isCurved: false,
color: PmonColors.chartLine,
color: p.chartLine,
barWidth: 2,
dotData: const FlDotData(show: false),
dotData: FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
color: PmonColors.chartLine.withValues(alpha: 0.06),
color: p.chartLine.withValues(alpha: 0.06),
),
))
.toList(),
@@ -158,15 +156,15 @@ class ViewerChart extends StatelessWidget {
return '${v.toStringAsFixed(spec?.digits == 0 ? 0 : 1)}${spec?.unit ?? ''}';
}
Widget _emptyHint() {
Widget _emptyHint(InkwatchPalette p, InkwatchTextStyles tx) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.show_chart, size: 28, color: PmonColors.textTertiary),
const SizedBox(height: 8),
Icon(Icons.show_chart, size: 28, color: p.textTertiary),
SizedBox(height: 8),
Text('该指标暂无数值(平台不可得或设备未上报)',
style: TextStyle(fontSize: 12, color: PmonColors.textTertiary)),
style: tx.caption),
],
),
);
File diff suppressed because it is too large. Load diff
@@ -5,6 +5,8 @@
import FlutterMacOS
import Foundation
import file_picker_darwin
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
}
@@ -64,7 +64,7 @@
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
33CC10ED2044A3C60003C045 /* pmon_client.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "pmon_client.app"; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10ED2044A3C60003C045 /* inkwatch_client.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "inkwatch_client.app"; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
@@ -131,7 +131,7 @@
33CC10EE2044A3C60003C045 /* Products */ = {
isa = PBXGroup;
children = (
33CC10ED2044A3C60003C045 /* pmon_client.app */,
33CC10ED2044A3C60003C045 /* inkwatch_client.app */,
331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
);
name = Products;
@@ -217,7 +217,7 @@
);
name = Runner;
productName = Runner;
productReference = 33CC10ED2044A3C60003C045 /* pmon_client.app */;
productReference = 33CC10ED2044A3C60003C045 /* inkwatch_client.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
@@ -385,10 +385,10 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.pmon.pmonClient.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.inkwatch.eink.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/pmon_client.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/pmon_client";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/inkwatch_client.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/inkwatch_client";
};
name = Debug;
};
@@ -399,10 +399,10 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.pmon.pmonClient.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.inkwatch.eink.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/pmon_client.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/pmon_client";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/inkwatch_client.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/inkwatch_client";
};
name = Release;
};
@@ -413,10 +413,10 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.pmon.pmonClient.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.inkwatch.eink.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/pmon_client.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/pmon_client";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/inkwatch_client.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/inkwatch_client";
};
name = Profile;
};
@@ -15,7 +15,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "pmon_client.app"
BuildableName = "inkwatch_client.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@@ -31,7 +31,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "pmon_client.app"
BuildableName = "inkwatch_client.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@@ -66,7 +66,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "pmon_client.app"
BuildableName = "inkwatch_client.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@@ -83,7 +83,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "pmon_client.app"
BuildableName = "inkwatch_client.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
+3 -3
View File
@@ -5,10 +5,10 @@
// 'flutter create' template.
// The application's name. By default this is also the title of the Flutter window.
PRODUCT_NAME = pmon_client
PRODUCT_NAME = inkwatch_client
// The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = com.pmon.pmonClient
PRODUCT_BUNDLE_IDENTIFIER = com.inkwatch.eink
// The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2026 com.pmon. All rights reserved.
PRODUCT_COPYRIGHT = Copyright © 2026 com.inkwatch. All rights reserved.
+286 -1
View File
@@ -1,6 +1,22 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
android_file_picker:
dependency: transitive
description:
name: android_file_picker
sha256: "14ab27769b54c48d5a8a71aa9858372b7a3ae77572ea0a8aee744643c5d8c53d"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.0"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
@@ -33,6 +49,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.3"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "828110d598123b5ea96c00c9f3c72105bf79f8ee36c20a39b26209ade421ec57"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.0"
collection:
dependency: transitive
description:
@@ -41,6 +65,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.3.5+5"
crypto:
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
@@ -49,6 +89,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.9"
dbus:
dependency: transitive
description:
name: dbus
sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.15"
equatable:
dependency: transitive
description:
@@ -73,6 +121,54 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.1.2"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: "98c0b156b6380ba55bc767a14e2e6b3feb246e713ad40092424596fa79005bea"
url: "https://pub.flutter-io.cn"
source: hosted
version: "13.1.0"
file_picker_darwin:
dependency: transitive
description:
name: file_picker_darwin
sha256: deb3ff69d58656b885c85bc1627f09ecb79860f272a5e7577149582ede4dce5d
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.0"
file_picker_linux:
dependency: transitive
description:
name: file_picker_linux
sha256: e7db600f50672ce5ebbe422ac0906e0f3d2a476188cc93e7126afee12bf08063
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.0"
file_picker_platform_interface:
dependency: transitive
description:
name: file_picker_platform_interface
sha256: bbdc085a6f168e63f147e9ce8988f79fa0800a58e7f25a48f762c60837107ba5
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.0.0"
file_picker_web:
dependency: transitive
description:
name: file_picker_web
sha256: b3004268da0c1b52baa18df430e7ec4438194de855a807d26b447cbf07ef3496
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.0.0"
fl_chart:
dependency: "direct main"
description:
@@ -99,6 +195,43 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
hooks:
dependency: transitive
description:
name: hooks
sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
jni:
dependency: transitive
description:
name: jni
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.3"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.3"
jni_util:
dependency: transitive
description:
name: jni_util
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.0"
leak_tracker:
dependency: transitive
description:
@@ -163,6 +296,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.19.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "2f9a00fff06cc27846da6e8bcda5e64c41a1ea656dcc4dc355ac717f8a272413"
url: "https://pub.flutter-io.cn"
source: hosted
version: "9.6.1"
package_config:
dependency: transitive
description:
name: package_config
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.0"
path:
dependency: "direct main"
description:
@@ -171,6 +320,94 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.6"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
name: platform
sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.0"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.8"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.1"
record_use:
dependency: transitive
description:
name: record_use
sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
sky_engine:
dependency: transitive
description: flutter
@@ -240,6 +477,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.12"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
@@ -272,6 +517,46 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.1"
win32:
dependency: transitive
description:
name: win32
sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.4.0"
windows_file_picker:
dependency: transitive
description:
name: windows_file_picker
sha256: "9f3aa833068b09e380fdc59560ad260a4a14e9397173abb533699f967443602e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.4"
sdks:
dart: ">=3.13.3 <4.0.0"
flutter: ">=3.27.4"
flutter: ">=3.38.4"
+12 -2
View File
@@ -1,4 +1,4 @@
name: pmon_client
name: inkwatch_client
description: "墨观 InkWatch —— 全平台设备性能监控(采集端 + 查看端 + 水墨屏看板)"
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.9+9
version: 1.3.0+21
environment:
sdk: ^3.13.3
@@ -42,6 +42,16 @@ dependencies:
ffi: ^2.2.0
path: ^1.9.1
fl_chart: ^1.2.0
# 文件传输的**端到端校验**用。大文件传坏了必须能被发现 ——
# 没有校验的"传完了"只是"字节写进去了",不等于内容对。
# Dart 官方包、纯 Dart、极小,不引原生依赖。
crypto: ^3.0.6
# 设备间传文件的界面层依赖 —— 与"传输层零原生依赖"不冲突:
# A 方案的价值在于不用 WebRTC,而不在省一个系统文件选择器。
# ⚠️ 版本不能低于 13:8.x 自身用 android-34 编译,而它的传递依赖
# (flutter_plugin_android_lifecycle)要求 36+,Android 构建会直接失败。
file_picker: ^13.0.0
path_provider: ^2.1.5 # 拿"用户找得到"的保存目录
dev_dependencies:
flutter_test:
sdk: flutter
+38 -8
View File
@@ -14,7 +14,7 @@
# 这两个符号在 API 24 才引入;按默认 API 21 链接会报
# `undefined symbol: freeifaddrs`。Flutter 默认 minSdk 也是 24,正好对齐。
#
# 3. 必须带 `--lib`。crate 里还有个 bin 目标 `pmon-demo`(桌面演示程序),
# 3. 必须带 `--lib`。crate 里还有个 bin 目标 `inkwatch-demo`(桌面演示程序),
# 在 Android 上链接不过;不带 --lib 会连它一起编从而失败。
set -euo pipefail
@@ -28,11 +28,21 @@ if [ ${#ABIS[@]} -eq 0 ]; then
ABIS=(arm64-v8a)
fi
: "${ANDROID_NDK_HOME:=$HOME/Library/Android/sdk/ndk/$(ls "$HOME/Library/Android/sdk/ndk" 2>/dev/null | sort -V | tail -1)}"
# NDK 位置:优先显式 ANDROID_NDK_HOME,其次按 ANDROID_SDK_ROOT/ANDROID_HOME 推导,
# 最后才回落到 macOS 的默认安装路径(2026-11-10 加后两者,为在 Linux CI 上可直接跑)。
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
for root in "${ANDROID_SDK_ROOT:-}" "${ANDROID_HOME:-}" "$HOME/Library/Android/sdk" "$HOME/Android/Sdk"; do
[ -n "$root" ] || continue
if [ -d "$root/ndk" ]; then
ANDROID_NDK_HOME="$root/ndk/$(ls "$root/ndk" | sort -V | tail -1)"
break
fi
done
fi
export ANDROID_NDK_HOME
if [ ! -d "$ANDROID_NDK_HOME" ]; then
echo "找不到 NDK: $ANDROID_NDK_HOME" >&2
echo "请先安装:sdkmanager --install 'ndk/30.0.16248370'" >&2
if [ ! -d "${ANDROID_NDK_HOME:-}" ]; then
echo "找不到 NDK: ${ANDROID_NDK_HOME:-<未设置>}" >&2
echo "请先安装:sdkmanager --install 'ndk/28.2.13676358'" >&2
exit 1
fi
@@ -44,6 +54,13 @@ fi
JNI_LIBS="$JNI_LIBS"
mkdir -p "$JNI_LIBS"
# 删掉改名前的旧产物(pmon → inkwatch 时漏了重编 Android 库,2026-11-10 发现):
# Gradle 会把 jniLibs 下**所有** .so 打进 APK,留着旧名只会让包变大、
# 且给排查"到底加载了哪个库"添乱 —— Dart 侧只认 libinkwatch_core.so。
for abi in "${ABIS[@]}"; do
rm -f "$JNI_LIBS/$abi/libpmon_core.so"
done
echo "NDK : $ANDROID_NDK_HOME"
echo "目标 : ${ABIS[*]}(平台 API 24)"
echo "输出 : $JNI_LIBS"
@@ -58,12 +75,25 @@ cargo ndk -P 24 "${TARGETS[@]}" -o "$JNI_LIBS" build --release --lib
echo
echo "产物:"
# 跨平台取文件大小 / 导出符号数(2026-11-10 两次实测):
# · stat:原先硬写 `stat -f`(BSD 参数),Linux CI 上直接报错并中断脚本 → 改成探测式回落。
# · nm:原先按**宿主 OS** 分支,Darwin 用 `nm -gU`(Mach-O 参数)读 Android 的 ELF,
# 实测返回 "no symbols" —— 明明有 39 个 inkwatch_* 导出符号却报 0(假阴性)。
# 产物格式由目标平台决定,与宿主无关 → 统一用 `nm -D --defined-only` 读动态符号表,
# macOS 与 GNU/Linux 的 nm 都支持(macOS 端实测数出 39)。
fsize_bytes() {
if stat -f %z "$1" >/dev/null 2>&1; then stat -f %z "$1"; else stat -c %s "$1"; fi
}
count_exported() {
nm -D --defined-only "$1" 2>/dev/null | grep -c 'inkwatch_' || true
}
for abi in "${ABIS[@]}"; do
so="$JNI_LIBS/$abi/libpmon_core.so"
so="$JNI_LIBS/$abi/libinkwatch_core.so"
if [ -f "$so" ]; then
printf " %-14s %6s KB %s\n" "$abi" "$(( $(stat -f %z "$so") / 1024 ))" \
printf " %-14s %6s KB %s\n" "$abi" "$(( $(fsize_bytes "$so") / 1024 ))" \
"$(file -b "$so" | cut -d, -f1-2)"
printf " %-14s 导出符号 %s 个\n" "" "$(nm -D --defined-only "$so" | grep -c ' pmon_')"
printf " %-14s 导出符号 %s 个\n" "" "$(count_exported "$so")"
else
printf " %-14s ✗ 未生成\n" "$abi"
fi
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# 本机出 Android APK(arm64)—— CI 那条线在共享 runner 上跑不动之后的正式落点
#
# cd client && bash scripts/build_apk.sh # release(需 key.properties)
# cd client && bash scripts/build_apk.sh debug # debug(无需签名,验构建最快)
#
# 为什么要有它:
# 2026-11-10 把 APK 构建接进 Gitea Actions,两次把同机的 Gitea 压到无响应
# (宿主只有 3654MB / 4 核,而 Flutter 归档 1.58GB + NDK + Gradle 编译的峰值
# 就在 4G 上下),需从控制台重启才恢复。结论:**出包回本机做**,
# `.gitea/workflows/android-build.yml` 保留为换独立 runner 后的配方,触发已收紧为仅手动。
#
# 本机(M3 Max)实测:assembleDebug 1m39s;全量 Dart 测试 7s。
#
# 它做的事:
# 1. 前置检查:flutter / NDK / key.properties(release 必需)
# 2. 交叉编译 Rust 核心 → jniLibs(arm64-v8a),顺手清掉改名前的旧库
# 3. flutter build apk --target-platform android-arm64(不带会出三 ABI 胖包)
# 4. 校验 APK:必须有 libinkwatch_core.so、不得有 libpmon_core.so
# 5. 打印体积与 sha256(字节数相同不代表没重编,比 sha256 才作数)
set -euo pipefail
cd "$(dirname "$0")/.." # → client/
MODE="${1:-release}"
case "$MODE" in
release|debug) ;;
*) echo "用法:bash scripts/build_apk.sh [release|debug]" >&2; exit 2 ;;
esac
FLUTTER="${FLUTTER_BIN:-$HOME/development/flutter/bin/flutter}"
if [ ! -x "$FLUTTER" ]; then
echo "未找到 flutter:$FLUTTER" >&2
echo "可用 FLUTTER_BIN=/path/to/flutter 指定" >&2
exit 1
fi
if [ "$MODE" = "release" ] && [ ! -f android/key.properties ]; then
echo "release 构建需要 android/key.properties(签名配置),当前不存在。" >&2
echo "先验构建可跑:bash scripts/build_apk.sh debug" >&2
exit 1
fi
echo "──── 1/4 交叉编译 Rust 核心 → jniLibs ────"
bash scripts/build_android_core.sh arm64-v8a
echo
echo "──── 2/4 flutter build apk($MODE, arm64)────"
"$FLUTTER" build apk "--$MODE" --target-platform android-arm64
echo
echo "──── 3/4 校验 APK 内容 ────"
APK="build/app/outputs/flutter-apk/app-$MODE.apk"
test -f "$APK" || { echo "APK 未生成:$APK" >&2; exit 1; }
LIST="$(unzip -l "$APK")"
echo "$LIST" | grep -E 'lib/arm64-v8a/.*\.so' || true
if ! echo "$LIST" | grep -q 'lib/arm64-v8a/libinkwatch_core.so'; then
echo "✗ APK 里没有 libinkwatch_core.so —— Dart 侧会在加载阶段失败" >&2
exit 1
fi
if echo "$LIST" | grep -q 'libpmon_core.so'; then
echo "✗ APK 里出现了改名前的 libpmon_core.so(旧产物没清干净)" >&2
exit 1
fi
echo "✓ 核心库已正确打包"
echo
echo "──── 4/4 产物 ────"
ls -lh "$APK"
shasum -a 256 "$APK"
echo
echo "装到真机:adb install -r \"$APK\""
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# 构建墨观客户端 CLI 可执行文件 —— **"怎么构建 CLI"的唯一入口**
#
# 为什么要有这个脚本(2026-10-27):
# Dart 3.13 起 `dart compile exe` 遇到带 build hooks 的包会直接失败:
# 'dart compile' does not support build hooks, use 'dart build' instead.
# Packages with build hooks: objective_c.
# `objective_c` 是 `file_picker` 的传递依赖,**不是我们代码的问题**,
# 但 README 与历史脚本里那些 `dart compile exe ...` 命令因此全部失效 ——
# 用户照抄必失败,属于"文档在说谎"。
#
# 所以构建命令只写在这里,README 指过来;**不要**在 README / 其他脚本里再抄一份
# (本仓库"同一份知识写两处"已翻车多次:指标字段、配对码契约、显示目录、安装路径)。
#
# ── 产物布局(`dart build cli` 的约定)────────────────────────────
# build/cli/<name>/bundle/bin/<name> ← 可执行文件
# build/cli/<name>/bundle/lib/<libs> ← 该可执行文件的运行期动态库
# **bundle 是权威产物**:exe 与它的库是一个整体,别只拿走 bin/ 里的文件。
#
# 同时产出扁平副本(兼容既有路径,见下):
# build/inkwatch-agent build/inkwatch-install
# 并把 bundle/lib/ 里的动态库一并拷到 build/ 下,保证扁平副本也是自包含的。
#
# 为什么保留 `build/inkwatch-install`:
# GUI 的常驻采集卡按候选链找 CLI,**第一候选就是 `build/inkwatch-install`**
# (`lib/host/resident_service.dart`)。没有它就只剩"dart run"这一条 ——
# 而最终用户机器上**没有 Dart SDK**,那条候选在真实安装里等同不可用。
# 故本脚本继续产出该路径,让候选链有意义。
#
# 用法:
# client/scripts/build_cli.sh # 默认构建 inkwatch_agent + inkwatch_install
# client/scripts/build_cli.sh inkwatch_viewer # 追加构建其它入口(可多个)
# INKWATCH_DART=/path/to/dart client/scripts/build_cli.sh
set -euo pipefail
cd "$(dirname "$0")/.." # → client/
DART_BIN="${INKWATCH_DART:-}"
if [ -z "$DART_BIN" ]; then
if [ -x "$HOME/development/flutter/bin/dart" ]; then
DART_BIN="$HOME/development/flutter/bin/dart"
elif command -v dart >/dev/null 2>&1; then
DART_BIN="$(command -v dart)"
else
echo "✗ 找不到 dart(可用 INKWATCH_DART=/path/to/dart 指定)" >&2
exit 1
fi
fi
# 默认构建两个"会被安装/被常驻卡拉起"的入口;其它按参数追加。
TARGETS=("$@")
if [ ${#TARGETS[@]} -eq 0 ]; then
TARGETS=(inkwatch_agent inkwatch_install)
fi
# Dart 入口名 → 扁平产物名(历史路径用连字符,保持与 README/候选链一致)
flat_name() {
case "$1" in
inkwatch_agent) echo "inkwatch-agent" ;;
inkwatch_install) echo "inkwatch-install" ;;
*) echo "${1//_/-}" ;;
esac
}
echo "== 构建 CLI(dart build cli)=="
echo " dart: $DART_BIN"
echo " 版本: $("$DART_BIN" --version 2>&1 | head -1)"
for t in "${TARGETS[@]}"; do
src="bin/${t}.dart"
if [ ! -f "$src" ]; then
echo "✗ 找不到入口: $src" >&2
exit 1
fi
out="build/cli/${t}"
echo ""
echo "-- ${t} --"
rm -rf "$out"
"$DART_BIN" build cli -t "$src" -o "$out"
bundle="${out}/bundle"
exe="${bundle}/bin/${t}"
if [ ! -x "$exe" ]; then
echo "✗ 构建未产出可执行文件: $exe" >&2
exit 1
fi
# 扁平副本:兼容既有路径(GUI 候选链 / README 历史写法)
flat="build/$(flat_name "$t")"
cp "$exe" "$flat"
chmod +x "$flat"
echo " ✓ bundle: ${bundle}"
# 扁平副本的运行期库:拷到扁平 exe 同目录(bundle 布局里 lib/ 与 bin/ 同级,
# 扁平化后只能平铺)。当前这两个 CLI 实测不 dlopen 这些库,属自包含保险。
if [ -d "${bundle}/lib" ]; then
for lib in "${bundle}/lib"/*; do
[ -f "$lib" ] || continue
case "$lib" in
*.dylib|*.so|*.dll) cp "$lib" "build/" ;;
*) echo " · 跳过非动态库: $(basename "$lib")" ;;
esac
done
fi
echo " ✓ 扁平副本: ${flat}"
done
echo ""
echo "== 完成 =="
echo " 权威产物: build/cli/<name>/bundle/(exe 与 lib/ 一起用)"
echo " 扁平副本: build/inkwatch-agent、build/inkwatch-install(供 GUI 候选链与既有文档)"
+70 -6
View File
@@ -4,12 +4,12 @@
# 用法:
# bash scripts/bundle_rust_core.sh [app路径] [dylib路径]
# 默认:
# app = build/macos/Build/Products/Release/pmon_client.app
# dylib = ../rust-core/target/release/libpmon_core.dylib
# app = build/macos/Build/Products/Release/inkwatch_client.app
# dylib = ../rust-core/target/release/libinkwatch_core.dylib
#
# 为什么要这一步:
# lib/host/core_lib.dart 的查找顺序是
# 1) 环境变量 PMON_CORE_LIB(开发联调用)
# 1) 环境变量 INKWATCH_CORE_LIB(开发联调用)
# 2) .app/Contents/Frameworks/(正式安装形态 ← 本脚本负责)
# 3) 仓库内 rust-core/target/release/(开发期直接 flutter run)
# 缺了这一步,把 .app 拷到别的机器就找不到核心库。
@@ -17,8 +17,8 @@ set -euo pipefail
cd "$(dirname "$0")/.."
APP="${1:-build/macos/Build/Products/Release/pmon_client.app}"
DYLIB="${2:-../rust-core/target/release/libpmon_core.dylib}"
APP="${1:-build/macos/Build/Products/Release/inkwatch_client.app}"
DYLIB="${2:-../rust-core/target/release/libinkwatch_core.dylib}"
if [ ! -d "$APP" ]; then
echo "找不到 app: $APP" >&2
@@ -42,6 +42,70 @@ chmod +x "$DEST/$(basename "$DYLIB")"
NEW_ID="@rpath/$(basename "$DYLIB")"
install_name_tool -id "$NEW_ID" "$DEST/$(basename "$DYLIB")" 2>/dev/null || true
echo "✓ 已注入: $DEST/$(basename "$DYLIB")"
# ── 注入后必须重新签名(2026-09-17 macOS 实测,别删这段)──────────────
#
# 为什么必需:`.app` 的签名把 `Contents/Frameworks/` 下的嵌套代码**逐个封印**
# (sealed nested code)。直接 `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/Flutter 一行代码**:
#
# .ips → exception EXC_BREAKPOINT / SIGTRAP
# _libsecinit_appsandbox → _libsecinit_initializer → dyld4::runAllInitializers
#
# 这个现象极具误导性:看起来像"应用自己崩了",而且 stdout/stderr **一个字都没有**
# (连 Flutter 的启动日志都来不及打),只能去 `~/Library/Logs/DiagnosticReports/` 翻。
# 而本脚本此前还会打印「✓ 已注入」—— **假成功比失败更难查**。故在此自动重签并验签。
#
# 注意 `--force` 是必须的:不加重签会被 MACOS 拒绝(已有签名不能直接覆盖)。
# 签名身份用 `-`(adhoc):与 `flutter build` 的默认产物一致,不引入开发者证书依赖。
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
case "$APP" in
*/Debug/*) ENT_FILE="$ROOT_DIR/macos/Runner/DebugProfile.entitlements" ;;
*) ENT_FILE="$ROOT_DIR/macos/Runner/Release.entitlements" ;;
esac
if [ ! -f "$ENT_FILE" ]; then
echo "找不到 entitlements: $ENT_FILE" >&2
echo "签名的 app 缺了 network.client 就**连不上网关**(errno 1 / EPERM),不能马虎" >&2
exit 1
fi
# 先签嵌套库,再签外层 bundle —— 顺序反了外层封印的仍是旧哈希
codesign --force --sign - "$DEST/$(basename "$DYLIB")"
codesign --force --sign - --entitlements "$ENT_FILE" "$APP"
# ── 验签:不通过就**非零退出**,绝不打印成功 ──────────────────────
#
# ⚠️ 必须带 `--deep --strict`。2026-09-17 实测:对同一份**已失效**的产物
# (嵌套库被 install_name_tool 改过、自身签名 invalid),
#
# codesign -v --verbose=2 inkwatch_client.app
# → "valid on disk / satisfies its Designated Requirement" ← 漏报!
# codesign --verify --deep --strict inkwatch_client.app
# → "invalid signature (code or signature have been modified)"
# "In subcomponent: .../libinkwatch_core.dylib" ← 抓到了
#
# 原因是 `-v` 默认不递归进嵌套代码,只验顶层。用宽松验签去守"嵌套代码有没有被改坏",
# 等于没守 —— 本仓库对"假绿"的容忍度是零,故这里一律用最严的判据。
if ! codesign --verify --deep --strict "$APP" 2>/dev/null; then
echo "✗ 重签失败:$APP 签名校验不通过(--deep --strict)" >&2
codesign --verify --deep --strict --verbose=2 "$APP" 2>&1 | sed 's/^/ /' >&2
exit 1
fi
# 出站网络权限是本项目的命门(缺了的表现是 errno 1 而非 connection refused,
# 极易误判成服务端问题)—— 重签会整份替换 entitlements,故必须复核
if ! codesign -d --entitlements - "$APP" 2>/dev/null | grep -q "network.client"; then
echo "✗ 重签后 entitlements 丢失了 network.client —— 应用将无法连接网关" >&2
exit 1
fi
echo "✓ 已注入并重签: $DEST/$(basename "$DYLIB")"
echo " install name: $NEW_ID"
echo " entitlements: $(basename "$ENT_FILE")(含 app-sandbox + network.client)"
ls -la "$DEST" | sed 's/^/ /'
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# 水墨屏设备探针 —— Neo2 接上后一条命令出全部关键数据
#
# 为什么单独写:水墨屏看板的版面(列数、字号、格高)全部取决于设备的
# **逻辑尺寸(dp)**,而 6″/300PPI 且系统 density 由厂商设定,不查就不知道。
# 1072×1448 在 density 2.0 下是 536×724dp,在 320 下就是 536×724——
# 但若厂商设成 240,就是 715×965dp,版面会完全不是一回事。
#
# 用法:bash client/scripts/eink-device-probe.sh [APK路径]
# 不带 APK 参数则只探测不安装。
set -u
ADB="${ADB:-adb}"
# 选设备:优先名字里带 Neo/iReader 的;否则用第一台已授权的
pick() {
"$ADB" devices | awk 'NR>1 && $2=="device" {print $1}' | while read -r s; do
m=$("$ADB" -s "$s" shell getprop ro.product.model 2>/dev/null | tr -d '\r')
case "$m" in *Neo*|*iReader*|*ireader*) echo "$s"; return;; esac
done
}
DEV="$(pick)"
if [ -z "$DEV" ]; then
echo "adb 里没有水墨屏设备(名字含 Neo/iReader)。当前已授权设备:"
"$ADB" devices -l | sed 's/^/ /'
echo
# ★ 关键分流:设备**在 USB 总线上**却进不了 adb,与"根本没插上"是两码事 ——
# 前者要去查"谁占着它",后者才去查线/开关。
# 这一条是 2026-09-17 绕了四轮弯路才补上的:当时只报"没找到设备",
# 于是臣一直在设备侧(开关、拔插、描述符)打转,而真凶在**主机侧** ——
# Neo2 被 Parallels 直通进了虚拟机、之后又被 Microsoft Edge(WebUSB)认领。
# 从 "IOUSBHostDevice" 那一行里切出设备名:
# 行形如 `+-o Neo 2@00100000 <class IOUSBHostDevice, ...>`
# (先前用 `grep -oE "…Neo ?2…@"` 会把前缀 `-o ` 也吃进名字里,名字错了后面全查不到)
NAME=$(ioreg -p IOUSB -w0 2>/dev/null \
| grep "IOUSBHostDevice" \
| sed -n 's/.*+-o \(.*\)@[0-9a-f][0-9a-f]* .*/\1/p' \
| grep -iE "neo ?2|ireader|掌阅" | head -1)
if [ -n "${NAME}" ]; then
echo "⚠️ 但 USB 总线上**有**这个设备(「${NAME}」)—— 问题在主机侧或设备固件,不在线。"
echo
echo "── 它暴露了什么接口 ──"
ioreg -w0 -l -r -n "${NAME}" 2>/dev/null \
| grep -E "USB Interface Name|bInterfaceClass|bInterfaceSubClass|bInterfaceProtocol" \
| sed 's/^ *//' | sed 's/^/ /' | head -8
echo " (ADB 接口 = 255 / 66 / 1;只看到 255/255/0 之类即"它没给 ADB 接口")"
echo
echo "── 谁占着它(这一步最容易漏,却常常就是答案)──"
ioreg -w0 -l -r -n "${NAME}" 2>/dev/null \
| grep -E "AppleUSBHost(Device|Interface)UserClient" \
| sed 's/.*+-o //; s/ <class.*//' | sed 's/^/ /' | sort -u
echo " (正常应看到 adb。看到 prl_vm_app=Parallels 直通、"
echo " Microsoft Edge/Chrome=WebUSB 认领 —— 都是占用者,先释放再试)"
echo
else
echo "USB 总线上也没有它 —— 这才是"线或开关"的问题。"
echo
fi
echo "若确认设备侧已开 USB 调试却仍无 ADB 接口,按顺序检查:"
echo " 1) 主机侧释放占用:退出 Parallels / 关掉浏览器(见上)"
echo " 2) 下拉通知栏,把 USB 模式从「仅充电」改成「文件传输 / MTP」"
echo " 3) 拔掉数据线等 3 秒再插(Android 只在连接建立那一刻决定暴露哪些接口)"
echo " 4) 开发者选项里开「USB 调试」;有「USB 安装」就一起开"
echo " 5) 设备上弹「允许 USB 调试」→ 勾「一律允许」"
echo
echo "⚠️ 已知:iReader Neo 2(SmartOS / Android 10)实测**不产出 ADB 接口**,"
echo " 无论开关如何 —— 该机型的调试通道不可用,请改用设备内「自检」页取数。"
exit 1
fi
g() { "$ADB" -s "$DEV" shell "$1" 2>/dev/null | tr -d '\r'; }
echo "设备序列号: $DEV"
echo " 机型 : $(g 'getprop ro.product.model') ($(g 'getprop ro.product.brand'))"
echo " Android : $(g 'getprop ro.build.version.release') SDK $(g 'getprop ro.build.version.sdk')"
echo " 内存 : $(g 'free -m' | awk '/Mem:/{print $2" MB"}')"
echo " ABI : $(g 'getprop ro.product.cpu.abi')"
echo
echo "── 屏幕(决定看板版面)──"
echo " $(g 'wm size' | tr '\n' ' ')"
echo " $(g 'wm density' | tr '\n' ' ')"
LOGICAL=$("$ADB" -s "$DEV" shell 'wm size; wm density' 2>/dev/null | tr -d '\r' | python3 -c "
import sys,re
t=sys.stdin.read()
def pick(pat):
m=re.findall(pat,t)
return int(m[-1]) if m else None
w,h=pick(r'(\d+)x(\d+)')[0:2] if re.search(r'(\d+)x(\d+)',t) else (None,None)
d=pick(r'density[: ]+(\d+)')
print(f'{w} {h} {d}')
")
read -r W H D <<< "$LOGICAL" 2>/dev/null
if [ -n "${W:-}" ] && [ -n "${D:-}" ]; then
python3 -c "
w,h,d=$W,$H,$D; dp=lambda px: round(px*160/d)
print(f' 逻辑尺寸: {dp(w)} × {dp(h)} dp (物理 {w}×{h} @ density {d})')
print(f' 看板预期: {\"横屏 3 列 × 2 行\" if dp(w)>dp(h) else \"竖屏 2 列 × 3 行\"}')
"
fi
if [ $# -ge 1 ] && [ -f "$1" ]; then
echo
echo "── 安装 $1 ──"
"$ADB" -s "$DEV" install -r "$1" 2>&1 | tail -2
fi
+74
View File
@@ -14,6 +14,14 @@
# bash scripts/test.sh test/viewer_page_test.dart
#
# 可用 FLUTTER_BIN 指定 flutter:FLUTTER_BIN=/path/to/flutter bash scripts/test.sh
#
# ── 已登记测试清单(Phase 3b 补)────────────────────────────────────
# `flutter test` 无参时会自动发现 test/*_test.dart;下面这份清单不是为了
# 替代自动发现,而是为了守住「新增测试文件必须登记」这条纪律 ——
# theme_guard_test.dart 的 _uiFiles 已两次因漏登记让几百行界面代码
# 不受护栏保护。默认全量跑之前做一次反向检查:出现清单外的 *_test.dart
# 就报错,逼迫显式登记后再跑。
# 本轮新增登记:token_parity_test.dart(CSS ↔ Dart 令牌双向护栏,05 §3.5 ④)。
set -euo pipefail
cd "$(dirname "$0")/.."
@@ -25,6 +33,72 @@ if [ ! -x "$FLUTTER" ]; then
exit 1
fi
REGISTERED_TESTS=(
account_security_test.dart
account_test.dart
adaptive_unit_test.dart
agent_lock_test.dart
agent_probe_test.dart
app_paths_test.dart
autostart_runner_test.dart
boot_report_test.dart
clock_test.dart
config_change_test.dart
config_migration_test.dart
dark_theme_test.dart
dark_ui_test.dart
device_merge_notice_test.dart
device_model_test.dart
eink_board_test.dart
event_reporter_test.dart
handover_test.dart
history_test.dart
host_ffi_test.dart
idle_sampling_test.dart
install_paths_test.dart
installer_launch_note_test.dart
installer_status_json_test.dart
lan_service_test.dart
link_hub_test.dart
link_mode_test.dart
metric_format_test.dart
p2p_driver_test.dart
p2p_session_test.dart
pairing_card_test.dart
plan_text_guard_test.dart
relay_driver_test.dart
resident_card_test.dart
resident_service_test.dart
scaled_unit_test.dart
skeleton_test.dart
startup_log_test.dart
startup_path_test.dart
stun_test.dart
symbol_guard_test.dart
theme_guard_test.dart
token_parity_test.dart
transfer_card_test.dart
viewer_page_test.dart
viewer_test.dart
)
# 只在「默认全量跑」时校验:显式传文件参数时保持原样透传
if [ "$#" -eq 0 ]; then
shopt -s nullglob
for f in test/*_test.dart; do
base="$(basename "$f")"
found=0
for r in "${REGISTERED_TESTS[@]}"; do
if [ "$r" = "$base" ]; then found=1; break; fi
done
if [ "$found" -eq 0 ]; then
echo "未登记的测试文件:$f" >&2
echo "请把 $base 加进 scripts/test.sh 的 REGISTERED_TESTS 清单后再跑" >&2
exit 1
fi
done
fi
exec env \
-u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \
-u http_proxy -u https_proxy -u all_proxy \
+225
View File
@@ -0,0 +1,225 @@
/// 用户机制客户端测试(2026-10-30):角色目录 / 会话与安全接口 / 成员卡
///
/// 覆盖三类容易写错的点:
/// · **角色判定与服务端同源**:按钮的显隐依据必须来自 `role_catalog`,
/// 而不是界面里另写一套 if(否则会出现"点得到但必然 403")。
/// · **失败要说人话**:接口返回错误码,界面必须翻译,不能把码摆给用户。
/// · **不可用不得静默**:读不到会话/成员时显式说"读不到",不是假装为空。
///
/// 运行:cd client && bash scripts/test.sh test/account_security_test.dart
library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:inkwatch_client/host/account_api.dart';
import 'package:inkwatch_client/host/role_catalog.dart';
import 'package:inkwatch_client/ui/account_card.dart';
import 'package:inkwatch_client/ui/device_members_card.dart';
String _json(Map<String, dynamic> m) {
final parts = m.entries.map((e) {
final v = e.value;
if (v == null) return '"${e.key}":null';
if (v is num || v is bool) return '"${e.key}":$v';
if (v is Map) return '"${e.key}":${_json(v.cast<String, dynamic>())}';
if (v is List) {
return '"${e.key}":[${v.map((x) => x is Map ? _json(x.cast<String, dynamic>()) : '"$x"').join(',')}]';
}
return '"${e.key}":"$v"';
});
return '{${parts.join(',')}}';
}
void main() {
group('① 角色目录:与服务端同语义', () {
test('三级角色含义与序', () {
expect(roleRank['viewer'], lessThan(roleRank['manager']!));
expect(roleRank['manager'], lessThan(roleRank['owner']!));
expect(roleLabel('viewer'), '仅查看');
expect(roleLabel('nope'), '未知');
});
test('最低角色判定', () {
expect(canByRole('viewer', 'VIEW_SNAPSHOT'), isTrue);
expect(canByRole('viewer', 'INVITE_MEMBER'), isFalse);
expect(canByRole('manager', 'INVITE_MEMBER'), isTrue);
expect(canByRole('manager', 'MANAGE_MEMBERS'), isFalse);
expect(canByRole('owner', 'MANAGE_MEMBERS'), isTrue);
});
test('ownerOnly:被授予 owner 的成员仍不能转移/删除', () {
expect(canByRole('owner', 'TRANSFER_OWNER', isOwner: false), isFalse);
expect(canByRole('owner', 'TRANSFER_OWNER', isOwner: true), isTrue);
expect(canByRole('owner', 'DELETE_DEVICE', isOwner: false), isFalse);
});
test('未知能力名不放行(防拼写错误变成放行)', () {
expect(canByRole('owner', 'VIEW_EVERYTHING'), isFalse);
expect(canByRole(null, 'VIEW_SNAPSHOT'), isFalse);
});
test('13 个能力与 ownerOnly 集合非空', () {
expect(capabilityMinRole.length, 13);
expect(ownerOnlyCapabilities.length, 2);
for (final r in capabilityMinRole.values) {
expect(roleRank.containsKey(r), isTrue, reason: '未知最低角色 $r');
}
});
});
group('② AccountApi:会话 / 验证 / 注销', () {
test('会话列表解析(含 current 与设备标签)', () async {
final api = AccountApi(
businessUrl: 'http://h:4000',
authedGet: (u, t) async => HttpReply(
200,
_json({
'sessions': [
{
'id': 'abcdef012345',
'created_at': 1,
'last_used_at': 2,
'device_label': 'Neo 2',
'current': true,
},
{
'id': '001122334455',
'created_at': 3,
'last_used_at': 4,
'current': false,
},
],
}),
),
);
final list = await api.sessions('pm_sess_x');
expect(list.length, 2);
expect(list.first.displayLabel, 'Neo 2');
expect(list.first.current, isTrue);
expect(list.last.displayLabel, '未标注设备');
});
test('读不到会话时返回空表(界面据此说"读不到")', () async {
final api = AccountApi(
businessUrl: 'http://h:4000',
authedGet: (u, t) async => HttpReply(500, '{}'),
);
expect(await api.sessions('pm_sess_x'), isEmpty);
});
test('发信未配置时如实回报 delivered=false', () async {
final api = AccountApi(
businessUrl: 'http://h:4000',
authedSend: (m, u, t, b) async => HttpReply(
200,
_json({'ok': true, 'delivery': 'console', 'delivered': false}),
),
);
final r = await api.requestVerify('pm_sess_x');
expect(r.ok, isTrue);
expect(r.delivered, isFalse);
});
test('注销返回无人可看的设备清单', () async {
final api = AccountApi(
businessUrl: 'http://h:4000',
authedSend: (m, u, t, b) async => HttpReply(
200,
_json({
'ok': true,
'grace_days': 30,
'unowned_devices': ['dev_a', 'dev_b'],
}),
),
);
final r = await api.deleteAccount('pm_sess_x', 'secret123');
expect(r.ok, isTrue);
expect(r.unownedDevices, ['dev_a', 'dev_b']);
});
test('错误码翻译成人话', () async {
final api = AccountApi(
businessUrl: 'http://h:4000',
authedSend: (m, u, t, b) async => HttpReply(
403,
_json({'error': 'E_EMAIL_UNVERIFIED', 'message': '请先验证邮箱'}),
),
);
final r = await api.inviteMember(
'pm_sess_x',
'dev_a',
'b@c.com',
'viewer',
);
expect(r.ok, isFalse);
expect(r.message, contains('验证邮箱'));
});
});
group('③ 账号卡:未登录时有找回入口', () {
testWidgets('登录视图显示「忘记口令?」', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: AccountCard(api: AccountApi(businessUrl: 'http://h:4000')),
),
),
);
expect(find.text('忘记口令?'), findsOneWidget);
});
});
group('④ 成员卡:无权限时整卡隐藏', () {
testWidgets('viewer 看不到成员卡(不给必然失败的按钮)', (tester) async {
final api = AccountApi(businessUrl: 'http://h:4000');
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DeviceMembersCard(
api: api,
deviceId: 'dev_a',
token: 'pm_sess_x',
myRole: 'viewer',
),
),
),
);
await tester.pump();
expect(find.text('成员'), findsNothing);
});
testWidgets('manager 看到邀请入口', (tester) async {
final api = AccountApi(
businessUrl: 'http://h:4000',
authedGet: (u, t) async => HttpReply(
200,
_json({
'ok': true,
'my_role': 'manager',
'owner': {'account_id': '1', 'email': 'owner@x.com'},
'members': [
{'account_id': '2', 'role': 'viewer', 'email': 'v@x.com'},
],
}),
),
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DeviceMembersCard(
api: api,
deviceId: 'dev_a',
token: 'pm_sess_x',
myRole: 'manager',
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('成员'), findsOneWidget);
expect(find.text('邀请'), findsOneWidget);
// manager 不能改别人角色(MANAGE_MEMBERS 需 owner)
expect(find.byIcon(Icons.tune), findsNothing);
});
});
}
+126 -8
View File
@@ -10,8 +10,8 @@ library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pmon_client/host/account_api.dart';
import 'package:pmon_client/ui/account_card.dart';
import 'package:inkwatch_client/host/account_api.dart';
import 'package:inkwatch_client/ui/account_card.dart';
String _json(Map<String, dynamic> m) {
final parts = m.entries.map((e) {
@@ -29,7 +29,7 @@ Map<String, dynamic> _accountBody({String id = '7', String email = 'a@b.com'}) =
'token': 'pm_sess_TESTTOKEN',
'expires_in_ms': 604800000,
'account': {
'user_id': id, 'email': email, 'display_name': '<user>', 'plan': 'free',
'user_id': id, 'email': email, 'display_name': '产品负责人', 'plan': 'free',
},
};
@@ -48,7 +48,7 @@ void main() {
expect(r.ok, isTrue);
expect(r.token, 'pm_sess_TESTTOKEN');
expect(r.account!.userId, '7');
expect(r.account!.label, '<user>');
expect(r.account!.label, '产品负责人');
});
test('邮箱已注册 → 失败并给出人话', () async {
@@ -168,7 +168,7 @@ void main() {
final api = AccountApi(
businessUrl: 'http://h:4000',
authedGet: (u, t) async => HttpReply(200, _json({
'account': {'user_id': '7', 'email': 'a@b.com', 'display_name': '<user>', 'plan': 'free'},
'account': {'user_id': '7', 'email': 'a@b.com', 'display_name': '产品负责人', 'plan': 'free'},
'owned_device_count': 2, 'device_quota': 3,
})),
);
@@ -282,7 +282,7 @@ void main() {
businessUrl: 'http://h:4000',
restPost: (u, b) async => HttpReply(200, _json(_accountBody(email: 'a@b.com'))),
authedGet: (u, t) async => HttpReply(200, _json({
'account': {'user_id': '7', 'email': 'a@b.com', 'display_name': '<user>', 'plan': 'free'},
'account': {'user_id': '7', 'email': 'a@b.com', 'display_name': '产品负责人', 'plan': 'free'},
'owned_device_count': 2, 'device_quota': 3,
})),
),
@@ -325,7 +325,7 @@ void main() {
AccountApi(
businessUrl: 'http://h:4000',
authedGet: (u, t) async => HttpReply(200, _json({
'account': {'user_id': '7', 'email': 'me@x.com', 'display_name': '<user>', 'plan': 'pro'},
'account': {'user_id': '7', 'email': 'me@x.com', 'display_name': '产品负责人', 'plan': 'pro'},
'owned_device_count': 1, 'device_quota': 10,
})),
),
@@ -333,10 +333,49 @@ void main() {
token: 'pm_sess_EXISTING',
);
expect(find.text('退出登录'), findsOneWidget);
expect(find.textContaining('档位 pro'), findsOneWidget);
// 服务端没给 plan_name ⇒ 只能说「未知档位」,**不许**回落机器键 `pro`
expect(find.textContaining('档位 pro'), findsNothing);
expect(find.text('未知档位 · 查看三档权益'), findsOneWidget);
expect(find.textContaining('我的设备 1/10 台'), findsOneWidget);
});
testWidgets('服务端给了 plan_name → 档位行照它显示,配额行照数字显示', (tester) async {
await mount(
tester,
AccountApi(
businessUrl: 'http://h:4000',
authedGet: (u, t) async => HttpReply(200, _json({
'account': {'user_id': '7', 'email': 'me@x.com', 'plan': 'pro'},
'plan_name': '专业版',
'owned_device_count': 3, 'device_quota': 3,
})),
),
email: 'me@x.com',
token: 'pm_sess_EXISTING',
);
expect(find.text('专业版 · 查看三档权益'), findsOneWidget);
expect(find.text('我的设备 3/3 台 · 已达上限'), findsOneWidget);
});
testWidgets('配额任一数字缺失 → 整行不渲染(不写 0/0,也不写占位符)', (tester) async {
await mount(
tester,
AccountApi(
businessUrl: 'http://h:4000',
authedGet: (u, t) async => HttpReply(200, _json({
'account': {'user_id': '7', 'email': 'me@x.com', 'plan': 'pro'},
'plan_name': '专业版',
// 只给一半:`?? 0` 那种"看起来正常"的数字一律不许编
'owned_device_count': 2,
})),
),
email: 'me@x.com',
token: 'pm_sess_EXISTING',
);
expect(find.textContaining('我的设备'), findsNothing);
expect(find.text('专业版 · 查看三档权益'), findsOneWidget);
});
testWidgets('退出登录 → 回调收到空令牌(页面据此清会话)', (tester) async {
String? gotToken = 'unset';
await mount(
@@ -361,4 +400,83 @@ void main() {
expect(find.text('登录'), findsOneWidget);
});
});
// ══════════════════════════════════════════════════════════════
// ⑥ 外部登录态同步 —— 真机反馈"查看端和采集端各要登录一次"
//
// 实测原因**不是登录两次**,而是登录态没同步:
// 主壳是 IndexedStack,两页的 AccountCard 都长期存活;
// 在采集页登录后配置广播能让**页面**更新 _token,但**卡片的 State 不重跑
// initState**(同位置、无 key → State 被复用),于是卡片内部仍渲染登录表单。
//
// 下面用"重挂载同一棵树"来复现真机:Flutter 会复用 element,
// 从而触发 AccountCard.didUpdateWidget —— 这正是生产里发生的路径。
group('⑥ ★ 外部登录态同步', () {
Future<void> mountWith(WidgetTester tester,
{required String token, String email = ''}) async {
tester.view.physicalSize = const Size(1000, 2000);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: AccountCard(
api: AccountApi(businessUrl: 'http://h:4000'),
initialEmail: email,
initialToken: token,
),
),
),
));
await tester.pump();
await tester.pump(const Duration(milliseconds: 40));
}
testWidgets('★ 别处登录后,本卡片必须跟着变成已登录', (tester) async {
await mountWith(tester, token: '');
expect(find.text('登录'), findsOneWidget);
expect(find.text('退出登录'), findsNothing);
// 模拟"在采集页登录了" → 父级把新的 initialToken 传下来
await mountWith(tester, token: 'pm_sess_X', email: 'a@b.com');
expect(find.text('退出登录'), findsOneWidget,
reason: '外部已登录,卡片却还在显示登录表单 —— '
'用户看到的就是"这边还得再登一次"(其实令牌早已共享)');
expect(find.text('登录'), findsNothing);
});
testWidgets('★ 别处登出后,本卡片要跟着回到未登录', (tester) async {
await mountWith(tester, token: 'pm_sess_X', email: 'a@b.com');
expect(find.text('退出登录'), findsOneWidget);
await mountWith(tester, token: '');
expect(find.text('登录'), findsOneWidget);
expect(find.text('退出登录'), findsNothing);
});
testWidgets('★ 邮箱只补空、不覆盖用户正在输入的内容', (tester) async {
await mountWith(tester, token: '');
await tester.enterText(find.widgetWithText(TextField, '邮箱'), 'typed@x.com');
await tester.pump();
// 外部把一个别的邮箱传进来(例如另一页改了账号)
await mountWith(tester, token: '', email: 'other@y.com');
// 用与输入时同一个 finder(按标签定位邮箱框)——
// 卡上有邮箱与口令两个 TextField,byType 会命中多个。
final ctrl = tester
.widget<TextField>(find.widgetWithText(TextField, '邮箱'))
.controller!;
expect(ctrl.text, 'typed@x.com',
reason: '用户正在输入时,外部变化不该把他的内容冲掉');
});
testWidgets('token 没变时不做无谓重建(避免自激)', (tester) async {
await mountWith(tester, token: 'pm_sess_X', email: 'a@b.com');
await mountWith(tester, token: 'pm_sess_X', email: 'a@b.com');
// 只要不崩、状态稳定即可(两次挂载后仍是已登录)
expect(find.text('退出登录'), findsOneWidget);
});
});
}
+1 -1
View File
@@ -12,7 +12,7 @@
library;
import 'package:flutter_test/flutter_test.dart';
import 'package:pmon_client/host/adaptive_unit.dart';
import 'package:inkwatch_client/host/adaptive_unit.dart';
void main() {
group('基本换算与渲染', () {
+95
View File
@@ -0,0 +1,95 @@
/// 采集端单实例锁 —— 守住「GUI 与常驻 agent 不同时采集」
///
/// ── 覆盖不到的部分(先说明,免得误以为全绿就万事大吉)──
/// POSIX 记录锁是**按进程**的:同一进程内对同一文件重复加锁会合并成一把,
/// 不会冲突。所以"第二个进程抢不到锁"这条路径**无法用单测覆盖** ——
/// 它需要两个真实进程。本文件只覆盖:获取/释放/幂等/路径口径。
/// 跨进程冲突靠 GUI 与 agent 实际联调验证,见 `host/agent_lock.dart` 文件头。
library;
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:inkwatch_client/host/agent_lock.dart';
import 'package:inkwatch_client/host/app_config.dart';
void main() {
late Directory tmp;
setUp(() {
tmp = Directory.systemTemp.createTempSync('inkwatch_agentlock_test');
});
tearDown(() {
try {
tmp.deleteSync(recursive: true);
} catch (_) {
// 清理失败不影响断言结果
}
});
group('① 获取与释放', () {
test('首次获取成功,释放后可再次获取(GUI 里 Stop → Start 的路径)', () async {
final path = p.join(tmp.path, 'agent.lock');
final a = await AgentLock.tryAcquire(path: path);
expect(a, isNotNull, reason: '空闲的锁必须能拿到');
expect(File(path).existsSync(), isTrue, reason: '拿锁后锁文件应已创建');
await a!.release();
final b = await AgentLock.tryAcquire(path: path);
expect(b, isNotNull, reason: '释放后必须能重新获取,否则重启采集会失败');
await b!.release();
});
test('目录不存在时自动创建(首次安装路径)', () async {
final path = p.join(tmp.path, 'deep', 'nested', 'agent.lock');
final a = await AgentLock.tryAcquire(path: path);
expect(a, isNotNull);
expect(File(path).existsSync(), isTrue);
await a!.release();
});
});
group('② 检测他人在采集', () {
test('锁文件不存在 → 没有人在采集', () async {
final path = p.join(tmp.path, 'absent.lock');
expect(await AgentLock.isHeldByOther(path: path), isFalse);
});
test('文件存在但无人持锁 → false(残留的空文件不算占用)', () async {
// 进程被 kill -9 后锁文件会留下(内核释放锁、文件不删)。
// 若把"文件存在"当成"有人在采",一次强杀就会永久挡住采集。
final path = p.join(tmp.path, 'stale.lock');
File(path).writeAsStringSync('');
expect(await AgentLock.isHeldByOther(path: path), isFalse);
});
test('检测是只读的:查一次之后别人仍能正常加锁', () async {
// 若 isHeldByOther 忘了 unlock,检查一次就会把采集永久锁死 ——
// 这是最隐蔽的一种自锁。
final path = p.join(tmp.path, 'probe.lock');
File(path).writeAsStringSync('');
expect(await AgentLock.isHeldByOther(path: path), isFalse);
final a = await AgentLock.tryAcquire(path: path);
expect(a, isNotNull, reason: '探测后必须已释放,否则真实采集起不来');
await a!.release();
});
});
group('③ 路径口径', () {
test('默认锁文件落在配置目录下(不另算一份路径)', () {
expect(agentLockPath, endsWith('agent.lock'));
expect(
p.dirname(agentLockPath),
ConfigStore.dir,
reason: '锁文件必须与 config.json 同目录 —— '
'本项目"同一份路径写两处"已翻车多次,安装路径只能有一处派生',
);
});
});
}
+264
View File
@@ -0,0 +1,264 @@
/// 「常驻采集是否在跑」第二层判据(进程探测)的测试
///
/// ── 锁为什么不够(本文件存在的唯一理由)────────────────────────────
/// `agentLockPath` = `ConfigStore.dir/agent.lock`,而 `ConfigStore.dir` 由
/// `HOME` 决定。macOS **沙箱** GUI 的 `HOME` 是应用容器,agent 由 launchd
/// 拉起、`HOME` 是真实家目录:
///
/// GUI → ~/Library/Containers/com.inkwatch.eink/Data/…/agent.lock
/// agent → ~/Library/Application Support/inkwatch_client/agent.lock
///
/// **两个进程锁的是两个不同文件,谁也不挡谁** → 沙箱 GUI 里
/// `isHeldByOther()` 恒为 false → "agent 在跑、用户又点开始采集"必然双写:
/// 两条 seq 流混进同一个 device_id,服务端按会话去重分不清,历史曲线出现
/// 无法解释的毛刺,**且不报错**。故 macOS 上补一道"查进程"。
///
/// 这里不真跑 pgrep:`probeInstalledAgent` 的执行器可注入,测的是
/// "怎么解释结果",不是"pgrep 在不在"(后者在真机上验过)。
///
/// 运行:cd client && flutter test test/agent_probe_test.dart
library;
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:inkwatch_client/host/agent_lock.dart';
import 'package:inkwatch_client/host/app_paths.dart';
import 'package:inkwatch_client/host/startup_log.dart';
/// 记录调用并返回预设退出码的执行器
class _FakeProbe {
_FakeProbe({this.exitCode = 1, this.stdout = '', this.throwIt = false});
final int exitCode;
final String stdout;
final bool throwIt;
final List<({String exe, List<String> args})> calls = [];
Future<ProcessResult> call(String exe, List<String> args) async {
calls.add((exe: exe, args: args));
if (throwIt) {
throw ProcessException(exe, args, 'sandbox denied', 1);
}
return ProcessResult(9999, exitCode, stdout, '');
}
}
const String _agent =
"/Users/me/Library/Application Support/inkwatch_client/bin/inkwatch-agent";
void main() {
group('① macOS:pgrep 的三种结果有三种含义', () {
test('★ 有匹配(exit 0)→ running', () async {
final probe = _FakeProbe(exitCode: 0, stdout: '1737\n');
final r = await probeInstalledAgent(
platform: InkwatchPlatform.macOS,
agentPathFromCli: _agent,
run: probe.call,
);
expect(r, AgentPresence.running);
expect(probe.calls.single.exe, '/usr/bin/pgrep',
reason: '必须用绝对路径:沙箱里 PATH 可能被裁剪,靠 PATH 找会变成'
'"有时查得到有时查不到"的不确定性来源');
expect(probe.calls.single.args.first, '-f');
});
test('★ 无匹配(exit 1)→ notRunning(这是唯一可信的"没在跑")', () async {
final probe = _FakeProbe(exitCode: 1);
final r = await probeInstalledAgent(
platform: InkwatchPlatform.macOS,
agentPathFromCli: _agent,
run: probe.call,
);
expect(r, AgentPresence.notRunning);
});
test('★ 其它非零(exit 2/3,pgrep 自身出错)→ undetermined,不是 notRunning',
() async {
for (final code in [2, 3]) {
final r = await probeInstalledAgent(
platform: InkwatchPlatform.macOS,
agentPathFromCli: _agent,
run: _FakeProbe(exitCode: code).call,
);
expect(r, AgentPresence.undetermined,
reason: 'exit $code 是"查不了",当成"没在跑"就是拿错事实下判断');
}
});
test('pgrep 起不来(ProcessException)→ undetermined', () async {
final r = await probeInstalledAgent(
platform: InkwatchPlatform.macOS,
agentPathFromCli: _agent,
run: _FakeProbe(throwIt: true).call,
);
expect(r, AgentPresence.undetermined);
});
test('agent 路径为空(没有可信来源)→ undetermined,不猜一个路径去查', () async {
final probe = _FakeProbe(exitCode: 1);
final r = await probeInstalledAgent(
platform: InkwatchPlatform.macOS,
agentPathFromCli: ' ',
run: probe.call,
);
expect(r, AgentPresence.undetermined);
expect(probe.calls, isEmpty, reason: '没路径就不该发起探测');
});
});
group('② 匹配口径:与 installer 同一套(转义 + --collector)', () {
test('★ 路径里的正则元字符必须被转义(家目录里的 `.` 会当通配符)', () async {
final probe = _FakeProbe(exitCode: 1);
// ⚠️ 家目录一律用 `/Users/me` 这种**占位名**:`tests/privacy-scan.js` 把
// `/Users/<真用户名>` 判成高置信个人数据(`/Users/me` 在它的白名单里)。
// 元字符放在目录名上,同样能测到转义。
const weird = '/Users/me/Library/inkwatch(v2.3)/inkwatch-agent';
await probeInstalledAgent(
platform: InkwatchPlatform.macOS,
agentPathFromCli: weird,
run: probe.call,
);
final pattern = probe.calls.single.args.last;
expect(pattern, contains(r'\.'), reason: '`.` 不转义会匹配到别人家的进程');
expect(pattern, contains(r'\('), reason: '括号同理');
expect(pattern, isNot(contains(weird)),
reason: '原始未转义的路径不该原样出现在模式里');
});
test('★ 模式带 --collector(与 plist 的 ProgramArguments 对齐)', () async {
final probe = _FakeProbe(exitCode: 1);
await probeInstalledAgent(
platform: InkwatchPlatform.macOS,
agentPathFromCli: _agent,
run: probe.call,
);
expect(probe.calls.single.args.last, endsWith(' --collector'),
reason: 'installer 的 probeAgentProcess 用的是同一套口径,'
'一边匹配命令行、一边匹配可执行名迟早会漂移');
});
});
group('③ 非 macOS 不得调用探针(防"统一成全平台 pgrep")', () {
test('★ Windows / Linux / Android 一律 notApplicable,且一次都不执行', () async {
for (final plat in [
InkwatchPlatform.windows,
InkwatchPlatform.linux,
InkwatchPlatform.android,
]) {
final probe = _FakeProbe(exitCode: 0);
final r = await probeInstalledAgent(
platform: plat,
agentPathFromCli: _agent,
run: probe.call,
);
expect(r, AgentPresence.notApplicable,
reason: '$plat 上 GUI 与 agent 的 HOME 相同 → 锁已是同一文件,锁就够');
expect(probe.calls, isEmpty,
reason: '$plat 上不该执行 pgrep —— Windows 根本没有这个程序,'
'强行"统一"只会新增两处不必要的失败面');
}
});
});
group('④ 两层判据的合成(纯函数,一条一段)', () {
test('锁命中 → 阻止,且措辞不提"探测"(是锁判出来的)', () {
final r = guiCollectorBlockReason(
lockHeld: true, presence: AgentPresence.notApplicable);
expect(r, isNotNull);
expect(r, contains('仅查看'));
expect(r, isNot(contains('进程探测')));
});
test('★ 探针说 running → 阻止,并说明"进程探测确认"', () {
final r =
guiCollectorBlockReason(lockHeld: false, presence: AgentPresence.running);
expect(r, isNotNull);
expect(r, contains('进程探测确认'));
});
test('★ 探针说 undetermined → **不放行**,并如实说"无法判定"', () {
final r = guiCollectorBlockReason(
lockHeld: false, presence: AgentPresence.undetermined);
expect(r, isNotNull, reason: '不确定时不放行:双写是污染数据,漏采只是缺数据');
expect(r, contains('无法判定'));
expect(r, contains('inkwatch-install status'),
reason: '要给出能自己确认的办法,而不是一句"失败"');
});
test('探针说 notRunning / 不适用 → 放行', () {
expect(
guiCollectorBlockReason(
lockHeld: false, presence: AgentPresence.notRunning),
isNull);
expect(
guiCollectorBlockReason(
lockHeld: false, presence: AgentPresence.notApplicable),
isNull,
reason: '非 macOS 走这条:只有锁一层,和历史行为一致');
});
});
group('⑤ 源码守卫:main.dart 必须真的把两层接上', () {
test('★ 启动路径不得再只查锁', () {
final src = File('lib/main.dart').readAsStringSync();
expect(src, contains('_guiCollectorBlockReason'),
reason: '启动采集前的判断必须走两层合成的那个函数');
expect(src, contains('probeInstalledAgent'),
reason: 'macOS 必须补一道进程探测(沙箱下锁恒为 false)');
expect(src, isNot(contains('AgentLock.isHeldByOther()) {')),
reason: '又回到"只查锁"就退化成必然双写 —— 这正是本条守的东西');
expect(src, contains('currentPlatform == InkwatchPlatform.macOS'),
reason: '第二层只许在 macOS 上启用');
});
});
/// 「拿不到快照就放行」这个决定**必须留痕**。
///
/// 那是本模块唯一一处"不拦"的例外。将来有人问"为什么这次没拦住",
/// 没有日志就只能靠猜 —— 而猜出来的原因通常比没有原因更糟。
/// 故这里既验"码上有",也验"落得了盘"。
group('⑥ 放行的那条例外必须有痕迹', () {
late Directory tmp;
setUp(() {
tmp = Directory.systemTemp.createTempSync('inkwatch_startup_log_');
StartupLog.dir = tmp.path;
StartupLog.fileName = 'startup_log.jsonl';
});
tearDown(() {
StartupLog.dir = null;
StartupLog.fileName = kStartupLogFile;
if (tmp.existsSync()) tmp.deleteSync(recursive: true);
});
test('★ 事件能落到盘上,且字段原样保留(不是被脱敏吞掉的)', () {
StartupLog.event('resident_probe_skipped', {
'reason': 'status 快照不可用(agent_path 为空)',
'state': 'unknown',
'detail': '未找到 inkwatch-install',
});
final f = File('${tmp.path}/startup_log.jsonl');
expect(f.existsSync(), isTrue, reason: '日志是这条例外唯一的取证手段');
final lines = f
.readAsLinesSync()
.where((l) => l.trim().isNotEmpty)
.map((l) => jsonDecode(l) as Map<String, dynamic>)
.toList();
final ev = lines.lastWhere((e) => e['e'] == 'resident_probe_skipped');
final fields = (ev['f'] as Map).cast<String, Object?>();
expect(fields['state'], 'unknown');
expect(fields['reason'], contains('agent_path'));
expect(fields['detail'], '未找到 inkwatch-install',
reason: '判据原文要留着,否则下次排障还是不知道当时到底怎么了');
});
test('★ main.dart 里必须留着这条日志(防"嫌麻烦删掉")', () {
final src = File('lib/main.dart').readAsStringSync();
expect(src, contains("StartupLog.event('resident_probe_skipped'"),
reason: '若删掉它,"为什么这次没拦"就永久失去线索');
});
});
}
+224
View File
@@ -0,0 +1,224 @@
/// 安装/数据路径真相源的护栏
///
/// ── 这套测试在守什么 ──────────────────────────────────────────────
/// 守的不是"函数返回什么字符串",而是**这条口径只有一处**,以及
/// **"装进去的位置 == GUI 读的位置"** 这条等式在三种进程形态下都成立。
///
/// 为什么必须这么守(本项目血泪,且这是第 6 次同类复发):
/// · 指标字段、配对码契约、显示目录、单位换算、安装路径 —— 每次都是
/// "同一份清单写两处,改一处漏一处",而且**全都不报错**。
/// · 安装路径这次的具体后果是:Windows 上 agent 被装到
/// `%USERPROFILE%\Library\Application Support\inkwatch_client\bin`,
/// 配置却写进 `%USERPROFILE%\AppData\Roaming\inkwatch_client` ——
/// **装得进、读不到**,两边都静默。
///
/// 运行:cd client && flutter test test/app_paths_test.dart
library;
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:inkwatch_client/host/app_paths.dart';
import 'package:path/path.dart' as p;
void main() {
const home = '/Users/u';
const winHome = r'C:\Users\u';
final posix = pathContextFor(InkwatchPlatform.macOS);
final win = pathContextFor(InkwatchPlatform.windows);
group('平台口径(平台是参数,不是宿主)', () {
test('三个平台的数据目录各不相同,且非 macOS 不得落在 macOS 专用路径', () {
expect(dataDirFor(InkwatchPlatform.macOS, home, proc: InkwatchProc.gui),
posix.join(home, 'Library', 'Application Support', 'inkwatch_client'));
// Windows:路径分隔符必须是反斜杠 —— 用 p.windows 拼期望值,
// 这样"在 macOS 上测 Windows 口径"测的是真机形态,不是 posix 伪装
final w = dataDirFor(InkwatchPlatform.windows, winHome, proc: InkwatchProc.gui);
expect(w, win.join(winHome, 'AppData', 'Roaming', 'inkwatch_client'));
expect(w.contains(r'\'), isTrue, reason: 'Windows 口径必须用反斜杠');
expect(dataDirFor(InkwatchPlatform.linux, home, proc: InkwatchProc.gui),
posix.join(home, '.inkwatch_client'));
for (final v in [w, dataDirFor(InkwatchPlatform.linux, home)]) {
expect(v.contains('Library/Application Support'), isFalse,
reason: '非 macOS 上出现 macOS 专用路径 = E10-b 回归(装进去读不到)');
expect(v.contains('LaunchAgents'), isFalse);
}
});
test('Windows 上可执行文件必须带 .exe(合并前缺它,落位出来的文件不可执行)', () {
expect(agentExeNameFor(InkwatchPlatform.windows), 'inkwatch-agent.exe');
expect(agentExeNameFor(InkwatchPlatform.macOS), 'inkwatch-agent');
expect(agentExeNameFor(InkwatchPlatform.linux), 'inkwatch-agent');
expect(installedAgentPathFor(InkwatchPlatform.windows, winHome),
endsWith(r'inkwatch-agent.exe'));
});
test('家目录:Windows 优先 USERPROFILE(Git Bash 会把 HOME 指到 MSYS)', () {
expect(
homeFor({'HOME': '/c/msys64/home/u', 'USERPROFILE': winHome},
platform: InkwatchPlatform.windows),
winHome,
reason: 'Windows 上先取 HOME 会导向 MSYS 家目录',
);
// 非 Windows 仍以 HOME 为准
expect(homeFor({'HOME': home, 'USERPROFILE': winHome},
platform: InkwatchPlatform.macOS), home);
// 全缺时不编假路径,交给调用方兜底
expect(homeFor(const {}, platform: InkwatchPlatform.linux), '');
});
test('Android 未注入时返回空串,不静默落临时目录', () {
expect(dataDirFor(InkwatchPlatform.android, home, proc: InkwatchProc.injected), '');
expect(
dataDirFor(InkwatchPlatform.android, home,
proc: InkwatchProc.injected, injectedDir: '/data/user/0/com.inkwatch/files'),
'/data/user/0/com.inkwatch/files',
);
});
test('Linux 安装目录走 XDG,数据目录保持存量口径', () {
expect(
installRootFor(InkwatchPlatform.linux, home,
env: const {'XDG_DATA_HOME': '/xdg'}),
posix.join('/xdg', 'inkwatch_client'),
);
expect(
installRootFor(InkwatchPlatform.linux, home, env: const {}),
posix.join(home, '.local', 'share', 'inkwatch_client'),
);
// 数据目录不能跟着改:已装机器的配置在 ~/.inkwatch_client,改了就"配置凭空消失"
expect(dataDirFor(InkwatchPlatform.linux, home, proc: InkwatchProc.gui),
posix.join(home, '.inkwatch_client'));
});
});
group('★ 跨进程形态一致性("装进去的位置 == GUI 读的位置")', () {
test('macOS 非沙箱:安装目录与 GUI 数据目录同源', () {
final gui = dataDirFor(InkwatchPlatform.macOS, home, proc: InkwatchProc.gui);
expect(gui, installRootFor(InkwatchPlatform.macOS, home),
reason: 'macOS:安装器落位处与 GUI 读配置处必须同一口径(E10-b)');
// 顺带确认落位产物就在这个目录下(不是别处拼出来的)
expect(installedAgentPathFor(InkwatchPlatform.macOS, home),
posix.join(gui, 'bin', 'inkwatch-agent'));
});
test('macOS 沙箱:安装器能算出 GUI 的容器路径(等式成立)', () {
// 这一条是本任务的**核心断言**:
// 左侧 = 安装器从真实家目录推导出的「GUI 会读的位置」
// 右侧 = 沙箱 GUI 拿自己的 HOME(容器路径)算出来的位置
// 两边必须相等 —— 否则就是"装进去、GUI 读不到",而且没有任何报错。
final installerView = guiDataDirForInstallerView(InkwatchPlatform.macOS, home);
final containerHome =
posix.join(home, 'Library', 'Containers', kMacOsBundleId, 'Data');
final guiOwnView =
dataDirFor(InkwatchPlatform.macOS, containerHome, proc: InkwatchProc.gui);
expect(installerView, guiOwnView);
// 容器路径的形状也要钉住(拼错了上面那条也会"自洽地错")
expect(guiSandboxHomeFor(InkwatchPlatform.macOS, home), containerHome);
expect(installerView,
posix.join(containerHome, 'Library', 'Application Support', 'inkwatch_client'));
// 沙箱与非沙箱**本来就不同** —— 这不是缺陷,是操作系统事实;
// 缺陷只在于"两处各写一份"。所以这里断言"不同",同时断言
// 两者都出自同一函数(差别只在容器前缀)。
expect(installerView, isNot(installRootFor(InkwatchPlatform.macOS, home)));
expect(installerView.replaceFirst(containerHome, home),
installRootFor(InkwatchPlatform.macOS, home));
});
test('Windows:安装目录与 GUI 数据目录同源(不得一个 AppData 一个 Library)', () {
expect(dataDirFor(InkwatchPlatform.windows, winHome, proc: InkwatchProc.gui),
installRootFor(InkwatchPlatform.windows, winHome));
// 沙箱维度在非 macOS 上退化,不得凭空插容器前缀
expect(guiSandboxHomeFor(InkwatchPlatform.windows, winHome), winHome);
expect(guiDataDirForInstallerView(InkwatchPlatform.windows, winHome),
installRootFor(InkwatchPlatform.windows, winHome));
});
test('Linux:安装器与 GUI 的**配置目录**同源(安装目录另算,见上)', () {
expect(dataDirFor(InkwatchPlatform.linux, home, proc: InkwatchProc.installer),
dataDirFor(InkwatchPlatform.linux, home, proc: InkwatchProc.gui));
});
});
group('单一真相源的对外证据', () {
test('bundle id 与 macos/Runner/Configs/AppInfo.xcconfig 逐字一致', () {
final xc = File('macos/Runner/Configs/AppInfo.xcconfig').readAsStringSync();
final m = RegExp(r'PRODUCT_BUNDLE_IDENTIFIER\s*=\s*(\S+)').firstMatch(xc);
expect(m, isNotNull, reason: 'xcconfig 里找不到 PRODUCT_BUNDLE_IDENTIFIER');
expect(kMacOsBundleId, m!.group(1),
reason: '沙箱容器路径靠 bundle id 拼,对不上就装进去读不到');
});
test('LaunchAgent plist 形状与 Rust 声明一致(防两边漂移)', () {
final rust = File('../rust-core/src/keepalive.rs').readAsStringSync();
expect(rust.contains('Library/LaunchAgents/{}.plist'), isTrue,
reason: 'Rust 侧 plist 路径形状变了,Dart 的 launchAgentPlistPath 必须同步');
final got = launchAgentPlistPathFor(InkwatchPlatform.macOS, home, 'com.inkwatch.collector');
expect(got, posix.join(home, 'Library', 'LaunchAgents', 'com.inkwatch.collector.plist'));
// 非 macOS 没有 launchd 这回事,必须返回 null 而不是编一条路径
expect(launchAgentPlistPathFor(InkwatchPlatform.windows, winHome, 'x'), isNull);
});
test('动态库名只有一个出处:core_lib.dart 不得再自己判平台', () {
for (final plat in InkwatchPlatform.values) {
expect(coreLibNameFor(plat), isNotEmpty);
}
expect(coreLibNameFor(InkwatchPlatform.macOS), 'libinkwatch_core.dylib');
expect(coreLibNameFor(InkwatchPlatform.windows), 'inkwatch_core.dll');
expect(coreLibNameFor(InkwatchPlatform.linux), 'libinkwatch_core.so');
final coreLib = File('lib/host/core_lib.dart').readAsStringSync();
expect(coreLib.contains("Platform.isMacOS ? 'libinkwatch_core.dylib'"), isFalse,
reason: 'core_lib.dart 又长出动态库名分支了,应调用 coreLibNameFor');
for (final n in ['libinkwatch_core.dylib', 'inkwatch_core.dll', 'libinkwatch_core.so']) {
expect(coreLib.contains("'$n'"), isFalse,
reason: 'core_lib.dart 里又出现字面量 $n,真相源是 app_paths.dart');
}
expect(coreLib.contains('coreLibNameFor'), isTrue);
});
});
group('真相源护栏(防第 7 次分裂)', () {
test('install_paths.dart 不得出现任何路径字面量', () {
final src = File('lib/host/install_paths.dart').readAsStringSync();
for (final bad in ["'Library'", "'Application Support'", "'AppData'", "'inkwatch_client'"]) {
expect(src.contains(bad), isFalse,
reason: 'install_paths.dart 又出现硬编码片段 $bad,应改为调用 app_paths.dart');
}
});
test('app_config.dart 不得再自带平台分支', () {
final src = File('lib/host/app_config.dart').readAsStringSync();
for (final bad in ["'Library'", "'Application Support'", "'AppData'", "'.inkwatch_client'"]) {
expect(src.contains(bad), isFalse,
reason: 'app_config.dart 的 _dir 又长出平台分支 $bad(这正是合并前的缺陷)');
}
expect(src.contains('dataDirFor'), isTrue, reason: '_dir 必须委托给 dataDirFor');
});
test('inkwatch_install.dart 不得再写动态库名分支或 plist 路径', () {
final src = File('bin/inkwatch_install.dart').readAsStringSync();
expect(src.contains("Platform.isMacOS ? 'libinkwatch_core.dylib' : 'libinkwatch_core.so'"),
isFalse, reason: '动态库名二分支应改为 coreLibName');
expect(src.contains("Platform.isWindows ? 'inkwatch_core.dll'"), isFalse,
reason: '动态库名三分支应改为 coreLibName');
expect(src.contains("'LaunchAgents'"), isFalse,
reason: 'plist 路径应改为 launchAgentPlistPath(合并前 :184 与 :271 各写一份)');
});
test('真相源自身确实含有这些片段(防护栏被"空文件糊弄")', () {
// 如果哪天有人把 app_paths.dart 也搬空/改名,上面的护栏会"通过"但什么也没守。
// 这条反向断言要求真相源里真的有这些知识。
final src = File('lib/host/app_paths.dart').readAsStringSync();
for (final must in ["'Library'", "'Application Support'", "'AppData'",
"'.inkwatch_client'", "'LaunchAgents'", kMacOsBundleId]) {
expect(src.contains(must), isTrue, reason: 'app_paths.dart 里找不到 $must');
}
});
});
}
+179
View File
@@ -0,0 +1,179 @@
/// 自启命令执行器 —— 守住"安装器不会再撒谎"
///
/// ── 这个测试要防的是什么 ──────────────────────────────────────────
/// 2026-10-25 体检发现 `bin/inkwatch_install.dart` 的成功判据是假的:
/// 遍历 `install_commands` 时非零**只打印一行就继续**,循环结束后
/// **无条件**打印「安装完成」并 `return 0`。于是
/// `launchctl bootstrap` / `systemctl --user enable` 全部失败,
/// 安装器照样宣布成功。
///
/// 危害不是"屏幕上的字不对",而是**任何自动化都无法判断安装成败**:
/// 远程部署脚本、将来的 CI 真机构建、用户自己写的 supervisor 包装,
/// 全部只能拿到退出码 0。再叠加 P0-1 的 GUI 一键安装按钮,就成了
/// "点一下就永远显示成功"—— 用户信了绿勾就不再自查,问题被永久掩盖。
/// 假成功比失败更糟。
///
/// 本测试锁的是判定逻辑本身:**有必成命令失败,就必须 hardFailures > 0**。
library;
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:inkwatch_client/host/autostart_runner.dart';
/// 造一个假的 ProcessResult,避免真的去跑 shell
ProcessResult _res(int code, {String out = '', String err = ''}) =>
ProcessResult(0, code, out, err);
/// 按命令文本决定退出码的假执行器
ShellRunner _runner(Map<String, int> codes) {
return (command) async => _res(codes[command] ?? 0);
}
void main() {
group('① 成败语义来自 Rust,不在这里猜', () {
test('新形态:cmd + allow_failure 被逐字解析', () {
final spec = AutostartCommandSpec.parse(
{'cmd': 'launchctl bootstrap gui/501/x', 'allow_failure': false},
);
expect(spec.text, 'launchctl bootstrap gui/501/x');
expect(spec.allowFailure, isFalse);
});
test('allow_failure=true 被正确识别', () {
final spec = AutostartCommandSpec.parse(
{'cmd': 'launchctl bootout gui/501/x', 'allow_failure': true},
);
expect(spec.allowFailure, isTrue);
});
test('旧形态(裸字符串)视为必成 —— 宁可误报失败,不可假装成功', () {
final spec = AutostartCommandSpec.parse('launchctl bootstrap gui/501/x');
expect(spec.text, 'launchctl bootstrap gui/501/x');
expect(
spec.allowFailure,
isFalse,
reason: '旧 libinkwatch_core.dylib(cargo test 不更新 cdylib)会返回裸字符串。'
'此时按必成处理,最多多报一次失败;按可失败处理就会回到"全失败却成功"的老路。',
);
});
test('allow_failure 缺失时默认必成', () {
final spec = AutostartCommandSpec.parse({'cmd': 'x'});
expect(spec.allowFailure, isFalse);
});
test('空文本条目被跳过(不让脏数据穿透成一次"成功的空操作")', () async {
final outcome = await runAutostartCommands(
[
{'cmd': '', 'allow_failure': false},
{'cmd': 'real', 'allow_failure': false},
],
runner: _runner({}),
);
expect(outcome.commands.length, 1);
expect(outcome.commands.single.spec.text, 'real');
});
});
group('② 必成失败必须计入(这是缺陷的正面形态)', () {
test('单条必成命令失败 → hardFailures=1 且 ok=false', () async {
final outcome = await runAutostartCommands(
[
{'cmd': 'launchctl bootstrap gui/501/x', 'allow_failure': false},
],
runner: _runner({'launchctl bootstrap gui/501/x': 5}),
);
expect(outcome.hardFailures, 1);
expect(outcome.ok, isFalse);
expect(outcome.hardFailed.single.exitCode, 5);
});
test('可失败命令非零 → 不计入失败(bootout 在未加载时必然非零)', () async {
final outcome = await runAutostartCommands(
[
{'cmd': 'launchctl bootout gui/501/x', 'allow_failure': true},
{'cmd': 'launchctl bootstrap gui/501/x', 'allow_failure': false},
],
runner: _runner({
'launchctl bootout gui/501/x': 3,
'launchctl bootstrap gui/501/x': 0,
}),
);
expect(outcome.hardFailures, 0);
expect(outcome.ok, isTrue);
expect(outcome.commands.first.isToleratedFailure, isTrue);
});
test('混合场景只算必成的那些', () async {
final outcome = await runAutostartCommands(
[
{'cmd': 'a', 'allow_failure': true}, // 失败,容忍
{'cmd': 'b', 'allow_failure': false}, // 失败,计入
{'cmd': 'c', 'allow_failure': true}, // 失败,容忍
{'cmd': 'd', 'allow_failure': false}, // 成功
],
runner: _runner({'a': 1, 'b': 1, 'c': 1, 'd': 0}),
);
expect(outcome.hardFailures, 1);
expect(outcome.hardFailed.single.spec.text, 'b');
});
test('旧形态字符串失败同样计入(不能因为格式旧就放行)', () async {
final outcome = await runAutostartCommands(
['launchctl bootstrap gui/501/x'],
runner: _runner({'launchctl bootstrap gui/501/x': 1}),
);
expect(outcome.hardFailures, 1);
});
});
group('③ 回归守卫:安装器不得再"撒谎"', () {
test('全部命令都失败时 ok 必须为 false(2026-10-25 缺陷的完整形态)',
() async {
// 复刻当时真实发生的情形:macOS 上 bootout 与 bootstrap 双双非零
final outcome = await runAutostartCommands(
[
{'cmd': 'launchctl bootout gui/501/x', 'allow_failure': true},
{'cmd': 'launchctl bootstrap gui/501/x', 'allow_failure': false},
],
runner: _runner({
'launchctl bootout gui/501/x': 3,
'launchctl bootstrap gui/501/x': 5,
}),
);
expect(
outcome.ok,
isFalse,
reason: '修复前这里会返回 0 —— 安装器宣布"安装完成",而自启根本没注册上',
);
});
test('Linux 上 systemctl enable 失败必须被识别', () async {
final outcome = await runAutostartCommands(
[
{'cmd': 'systemctl --user daemon-reload', 'allow_failure': false},
{'cmd': 'systemctl --user enable --now x.service', 'allow_failure': false},
{'cmd': 'loginctl enable-linger u', 'allow_failure': true},
],
runner: _runner({
'systemctl --user daemon-reload': 0,
'systemctl --user enable --now x.service': 1,
'loginctl enable-linger u': 1,
}),
);
expect(outcome.hardFailures, 1);
expect(outcome.hardFailed.single.spec.text, contains('enable --now'));
});
test('命令执行输出被保留,便于失败时给出原因', () async {
final outcome = await runAutostartCommands(
[
{'cmd': 'systemctl --user enable --now x.service', 'allow_failure': false},
],
runner: (cmd) async => _res(1, err: 'Failed to connect to bus'),
);
expect(outcome.hardFailed.single.stderrText, contains('Failed to connect'));
});
});
}
+9 -9
View File
@@ -15,8 +15,8 @@ library;
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:pmon_client/host/app_config.dart';
import 'package:pmon_client/host/boot_report.dart';
import 'package:inkwatch_client/host/app_config.dart';
import 'package:inkwatch_client/host/boot_report.dart';
/// 假凭据:形状与真的一致(服务端签发的 opaque 令牌是 64 位 hex)
const _acctToken = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef';
@@ -123,13 +123,13 @@ void main() {
group('③ 目录清单必须能区分"全新安装"与"覆盖安装"', () {
test('目录不存在 → 明确说"等价于全新安装"(不是沉默)', () async {
final lines = await dirInventoryLines('/nonexistent-pmon-dir-xyz');
final lines = await dirInventoryLines('/nonexistent-inkwatch-dir-xyz');
expect(lines.any((l) => l.contains('等价于全新安装')), isTrue,
reason: '这一句就是本功能的结论本身:没有残留数据');
});
test('真实目录 → 列出每个文件的名字与字节数', () async {
final tmp = Directory.systemTemp.createTempSync('pmon-boot-');
final tmp = Directory.systemTemp.createTempSync('inkwatch-boot-');
addTearDown(() => tmp.deleteSync(recursive: true));
File('${tmp.path}/config.json').writeAsStringSync('{"a":1}' * 100);
File('${tmp.path}/viewer_cache.json').writeAsStringSync('x' * 4096);
@@ -143,7 +143,7 @@ void main() {
});
test('空目录 → 也说"等价于全新安装"', () async {
final tmp = Directory.systemTemp.createTempSync('pmon-boot-empty-');
final tmp = Directory.systemTemp.createTempSync('inkwatch-boot-empty-');
addTearDown(() => tmp.deleteSync(recursive: true));
final lines = await dirInventoryLines(tmp.path);
expect(lines.any((l) => l.contains('等价于全新安装')), isTrue);
@@ -152,12 +152,12 @@ void main() {
group('④ 缓存文件只报规模与陈旧度,不报内容', () {
test('不存在 → 明说', () async {
final lines = await viewerCacheLines('/nonexistent-pmon-dir-xyz');
final lines = await viewerCacheLines('/nonexistent-inkwatch-dir-xyz');
expect(lines.first.contains('不存在'), isTrue);
});
test('存在 → 报字节数/帧数/写入时间,且不打印原始内容', () async {
final tmp = Directory.systemTemp.createTempSync('pmon-boot-cache-');
final tmp = Directory.systemTemp.createTempSync('inkwatch-boot-cache-');
addTearDown(() => tmp.deleteSync(recursive: true));
const payload = '{"device_id":"dev_secret_abc","frames":[{"x":1}]}';
File('${tmp.path}/viewer_cache.json').writeAsStringSync(payload);
@@ -171,9 +171,9 @@ void main() {
group('⑤ 默认关闭:生产包一行都不输出', () {
test('`kLogBoot` 默认是 false(不带 define 构建时为假)', () {
// 生产包构建命令里没有 PMON_LOG_BOOT → 这里读到 false
// 生产包构建命令里没有 INKWATCH_LOG_BOOT → 这里读到 false
expect(kLogBoot, isFalse,
reason: '若为真,说明构建时误带了 --dart-define=PMON_LOG_BOOT=true');
reason: '若为真,说明构建时误带了 --dart-define=INKWATCH_LOG_BOOT=true');
});
});
}
+262
View File
@@ -0,0 +1,262 @@
/// 时钟源收敛(E4)—— 状态机必须吃**单调时钟**,线格式/落盘必须吃**墙钟**
///
/// ── 这个测试要防的是什么 ──────────────────────────────────────────
/// 设备时钟被 NTP 调整(尤其**回拨**)时,喂给 Rust 状态机的 now 会倒退,
/// 而 `transport.rs` 的 `next_action(now)` 内部是
/// `if self.reconnect_at_ms != 0 && now >= self.reconnect_at_ms`
/// 这类**绝对值比较** —— now 一旦退回过去,条件长期不成立 → **重连挂死**。
/// 用户只看到灰灯:不报错、不留痕,是最难自查的一类故障。
///
/// 反面同样要防:把信封 `ts`、`lastFrameLocalMs`、`saved_at` 也改成单调时钟。
/// 那三处是"给人看/落盘/跟界面墙钟相减"的值,换成单调基准会让时间显示跳变、
/// 缓存年龄算错。故本文件**两类用途都锁**:
/// ① 状态机路径的 now 必须来自单调时钟(且不是墙钟换皮);
/// ② 单调时钟必须是真单调(基准 + Stopwatch 增量),不是 `DateTime.now()` 包一层。
///
/// 运行:cd client && flutter test test/clock_test.dart
library;
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:inkwatch_client/host/clock.dart';
import 'package:inkwatch_client/host/inkwatch_core.dart';
import 'package:inkwatch_client/link/link_hub.dart';
import 'package:inkwatch_client/link/link_mode.dart';
import 'viewer_fakes.dart';
/// 脚本时钟(测试用"可控假时钟"):值由测试随时改
class _ScriptedClock {
_ScriptedClock(this.value);
int value;
int nowMs() => value;
/// 推进
void advance(int ms) => value += ms;
}
void main() {
group('① MonotonicClock:真单调,不是 DateTime.now() 换皮', () {
test('连续调用严格不减(10 万次)', () {
final c = MonotonicClock();
var prev = c.nowMs();
for (var i = 0; i < 100000; i++) {
final now = c.nowMs();
expect(now, greaterThanOrEqualTo(prev), reason: '第 $i 次调用出现倒退');
prev = now;
}
});
test('★ 基准由构造参数决定 —— 能抓住"只是包一层 DateTime.now()"的实现', () {
// 关键:反例实现 `nowMs() => DateTime.now().millisecondsSinceEpoch`
// 会返回 ~1.7e12;正确实现以 origin 为基准,只加 Stopwatch 增量。
final skewed = MonotonicClock(originMs: 0);
final v = skewed.nowMs();
expect(v, greaterThanOrEqualTo(0));
expect(v, lessThan(10000),
reason: 'origin=0 时读数必须是个位数毫秒级;'
'若这里是 ~1.7e12,说明实现退回了 DateTime.now()(回拨问题原样存在)');
expect(skewed.originMs, 0);
});
test('基准贴近真实时间(日志/排查时看得懂)', () {
final c = MonotonicClock();
final wall = wallMs();
// 构造与取值的间隔远小于 60 秒
expect((c.originMs - wall).abs(), lessThan(60000));
expect((c.nowMs() - wall).abs(), lessThan(60000));
});
test('两个实例互相独立(各自单调)', () {
final a = MonotonicClock(originMs: 1000);
final b = MonotonicClock(originMs: 2000);
expect(a.nowMs(), lessThan(b.nowMs()));
expect(a.nowMs(), greaterThanOrEqualTo(1000));
expect(a.nowMs(), lessThan(2000));
});
test('进程默认时钟 processNowMs 单调且贴近墙钟', () {
final wall = wallMs();
var prev = processNowMs();
for (var i = 0; i < 1000; i++) {
final now = processNowMs();
expect(now, greaterThanOrEqualTo(prev));
prev = now;
}
expect((prev - wall).abs(), lessThan(60000));
});
test('可注入:debugOverrideProcessClock 换掉默认源后可恢复', () {
final scripted = _ScriptedClock(777);
debugOverrideProcessClock(scripted.nowMs);
addTearDown(() => debugOverrideProcessClock(null));
expect(processNowMs(), 777);
scripted.value = 888;
expect(processNowMs(), 888);
debugOverrideProcessClock(null);
expect((processNowMs() - wallMs()).abs(), lessThan(60000));
});
});
group('② 真 Rust 状态机:墙钟回拨会挂死,单调时钟不会', () {
final core = InkwatchCore.instance;
test('正常推进:到点才重连,差 1ms 都不触发', () {
final t = core.openTransport('clock-test-normal', resendCapacity: 8);
addTearDown(t.dispose);
const base = 7200000; // 单调读数(小数值),与墙钟无关
t.onConnected(base);
final delay = t.onDisconnected(base);
expect(delay, greaterThan(0), reason: '首次退避延迟必须为正');
expect(t.nextAction(base + delay - 1), isA<Idle>(),
reason: '未到点不得重连');
expect(t.nextAction(base + delay), isA<ReconnectNow>(),
reason: '到点必须触发重连');
});
test('★ 回拨 1 小时(旧实现喂墙钟)→ 连续三拍 Idle = 重连挂死', () {
final t = core.openTransport('clock-test-rollback', resendCapacity: 8);
addTearDown(t.dispose);
final base = wallMs();
t.onConnected(base);
final delay = t.onDisconnected(base);
final rolledBack = base - 3600 * 1000; // 墙钟被回拨 1 小时
for (var i = 0; i < 3; i++) {
expect(t.nextAction(rolledBack), isA<Idle>(),
reason: '这就是缺陷本体:reconnect_at 落在"未来",条件永远不成立');
}
});
test('★ 同一个实例改成喂单调时钟 → 立刻恢复正常重连', () {
final t = core.openTransport('clock-test-monotonic', resendCapacity: 8);
addTearDown(t.dispose);
final base = wallMs();
t.onConnected(base);
final delay = t.onDisconnected(base);
// 单调时钟不会回拨:它只会继续往前走
expect(t.nextAction(base + delay), isA<ReconnectNow>());
});
test('★ 单调时钟前跳(休眠唤醒):只触发一次重连,不连发', () {
final t = core.openTransport('clock-test-jump', resendCapacity: 8);
addTearDown(t.dispose);
final base = wallMs();
t.onConnected(base);
t.onDisconnected(base);
final jumped = base + 3600 * 1000; // 唤醒后前跳 1 小时
final actions = <LinkAction>[
for (var i = 0; i < 3; i++) t.nextAction(jumped),
];
expect(actions.whereType<ReconnectNow>().length, 1,
reason: '前跳只能触发一次重连;连发会把网关打出一波连接风暴');
expect(actions.whereType<ReconnectNow>(), hasLength(1));
});
});
group('③ 查看端会话:状态机吃注入时钟,不吃墙钟', () {
test('★ 注入脚本时钟 → policy 收到的 now 就是脚本值(不是墙钟)', () async {
final scripted = _ScriptedClock(1000);
final env = makeViewerEnv(nowMs: scripted.nowMs);
await env.session.start();
addTearDown(env.session.stop);
env.socket.fireConnect(); // onConnect → policy.onConnected(_now())
expect(env.policy.nows, isNotEmpty);
expect(env.policy.nows.last, 1000,
reason: '连接事件必须把注入时钟的值交给状态机');
final wall = wallMs();
expect(env.policy.nows.every((n) => (wall - n).abs() > 100000000000), isTrue,
reason: '这些 now 绝不能是墙钟(1.7e12 量级)——'
'墙钟回拨正是重连挂死的根因');
});
test('★ 事件循环也走同一个时钟,且单调不减(墙钟回拨不影响)', () async {
final scripted = _ScriptedClock(5000);
final env = makeViewerEnv(nowMs: scripted.nowMs);
await env.session.start();
addTearDown(env.session.stop);
env.socket.fireConnect();
// 模拟"墙钟回拨 1 小时":墙钟在别处回拨,单调时钟照常往前走。
// 这里能观测到的就是 policy 收到的 now —— 它必须继续增加、绝不倒退。
scripted.advance(500);
await Future<void>.delayed(const Duration(milliseconds: 700)); // 让 500ms 的循环跑一拍
expect(env.policy.nextActionNows, isNotEmpty,
reason: '事件循环必须把时钟值交给 nextAction');
var prev = env.policy.nextActionNows.first;
for (final n in env.policy.nextActionNows) {
expect(n, greaterThanOrEqualTo(prev), reason: '喂给状态机的 now 出现倒退');
prev = n;
}
expect(env.policy.nows.every((n) => n < 1000000), isTrue,
reason: '事件循环的 now 也必须来自注入时钟(小数值),不是墙钟');
});
});
group('④ 默认值就是进程单调时钟(三处调用点的默认路径)', () {
test('LinkHub 不传 nowMs 时用 processNowMs', () {
final scripted = _ScriptedClock(5000);
debugOverrideProcessClock(scripted.nowMs);
addTearDown(() => debugOverrideProcessClock(null));
final hub = LinkHub(peerDeviceId: 'dev-clock');
addTearDown(hub.dispose);
hub.observeCloudConnected();
expect(hub.scoreOfMode(LinkMode.cloud)!.observedAtMs, 5000);
scripted.value = 6000;
hub.observeCloudDisconnected('超时');
expect(hub.scoreOfMode(LinkMode.cloud)!.observedAtMs, 6000);
});
test('ViewerSession 不传 nowMs 时用 processNowMs', () async {
final scripted = _ScriptedClock(4242);
debugOverrideProcessClock(scripted.nowMs);
addTearDown(() => debugOverrideProcessClock(null));
final env = makeViewerEnv(); // 刻意不传 nowMs
await env.session.start();
addTearDown(env.session.stop);
env.socket.fireConnect();
expect(env.policy.nows.last, 4242);
});
});
group('⑤ 源码结构守卫:三处不得退回 DateTime.now()', () {
/// 每处期望出现的"接上单调时钟"的写法
const expectations = {
'lib/host/link.dart': 'nowMs ?? processNowMs',
'lib/host/viewer.dart': 'nowMs ?? processNowMs',
'lib/link/link_hub.dart': 'nowMs ?? _defaultNowMs',
};
test('状态机时钟的默认值必须来自 host/clock.dart', () {
expectations.forEach((path, needle) {
final src = File(path).readAsStringSync();
expect(src.length, greaterThan(200), reason: '$path 读不到内容?');
expect(src, contains(needle), reason: '$path 必须 $needle');
// 反例:把 now 改回墙钟的写法(历史缺陷形态)
expect(RegExp(r'_now\(\)\s*=>\s*DateTime\.now\(\)').hasMatch(src), isFalse,
reason: '$path 的状态机时钟退回了 DateTime.now()');
// 假单调的旧名字(曾让注释与实现打架)—— 注释里可以留这段历史,
// 但**函数定义**不许回来
expect(RegExp(r'int\s+_monotonicishMs\s*\(').hasMatch(src), isFalse,
reason: '$path 仍留着"假单调"函数(名字骗人,实现是墙钟)');
});
});
test('墙钟与单调时钟的用途边界:host/clock.dart 是唯一定义处', () {
final clockSrc = File('lib/host/clock.dart').readAsStringSync();
expect(clockSrc, contains('int wallMs()'));
expect(clockSrc, contains('class MonotonicClock'));
expect(clockSrc, contains('Stopwatch'));
// 单调时钟必须真的用 Stopwatch 算增量,而不是读墙钟
expect(clockSrc, contains('_sw.elapsedMilliseconds'));
});
});
}
+2 -2
View File
@@ -20,7 +20,7 @@ library;
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:pmon_client/host/app_config.dart';
import 'package:inkwatch_client/host/app_config.dart';
void main() {
group('① 哪些字段变化需要重建会话', () {
@@ -84,7 +84,7 @@ void main() {
group('② ConfigStore 的变更广播', () {
setUp(() async {
// 指到临时目录,别碰真实用户配置
final tmp = await Directory.systemTemp.createTemp('pmon-cfg-change');
final tmp = await Directory.systemTemp.createTemp('inkwatch-cfg-change');
ConfigStore.setPlatformDir(tmp.path);
addTearDown(() {
try {
+64 -4
View File
@@ -1,6 +1,6 @@
/// 配置规范化护栏 —— "覆盖安装残留不得变成病态行为"
///
/// ── 这个测试要防的是什么(2026-09-15,<user>反馈"覆盖安装卡、清数据不卡")──
/// ── 这个测试要防的是什么(2026-09-15,产品负责人反馈"覆盖安装卡、清数据不卡")──
/// `config.json` 是**上一个版本写下的外部输入**,而加载路径此前**无条件信任**它。
/// 于是一条病态的残留值可以直达业务:
/// `sampling_interval_ms` → `Timer.periodic(Duration(milliseconds: ms))`
@@ -28,9 +28,9 @@ library;
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:pmon_client/host/app_config.dart';
import 'package:pmon_client/host/config_migration.dart';
import 'package:pmon_client/host/device_id.dart';
import 'package:inkwatch_client/host/app_config.dart';
import 'package:inkwatch_client/host/config_migration.dart';
import 'package:inkwatch_client/host/device_id.dart';
AppConfig _raw({
String serverUrl = 'http://203.0.113.10',
@@ -43,6 +43,9 @@ AppConfig _raw({
String lastViewed = '',
int samplingMs = 1000,
bool manual = true,
bool eink = false,
int einkSec = 10,
bool einkLandscape = true,
}) =>
AppConfig(
serverUrl: serverUrl,
@@ -55,6 +58,9 @@ AppConfig _raw({
lastViewedDeviceId: lastViewed,
samplingIntervalMs: samplingMs,
samplingIntervalManual: manual,
einkMode: eink,
einkRefreshSec: einkSec,
einkLandscape: einkLandscape,
);
void main() {
@@ -89,6 +95,60 @@ void main() {
});
});
group('①-b 水墨屏刷新间隔同样要夹(同为"直达 Timer"的字段)', () {
test('过小/过大/负数一律回落默认 10 秒', () {
for (final bad in [-1, -100, 301, 99999]) {
final r = normalizeConfig(_raw(einkSec: bad), fromVersion: kConfigVersion);
expect(r.config.einkRefreshSec, kDefaultEinkRefreshSec, reason: '值 $bad 没被拦下');
}
});
test('0 是**合法**的(表示只手动刷新)—— 别把用户的选择当错误', () {
final r = normalizeConfig(_raw(einkSec: 0), fromVersion: kConfigVersion);
expect(r.config.einkRefreshSec, 0);
expect(r.changed, isFalse);
});
test('合法区间内的值原样保留', () {
for (final ok in [1, 5, 10, 30, 60, 300]) {
expect(normalizeConfig(_raw(einkSec: ok), fromVersion: kConfigVersion)
.config.einkRefreshSec, ok);
}
});
test('einkMode 与刷新间隔都能往返(toJson/fromJson 不丢)', () {
final cfg = _raw(eink: true, einkSec: 30);
final back = AppConfig.fromJson(cfg.toJson());
expect(back.einkMode, isTrue);
expect(back.einkRefreshSec, 30);
});
test('★ 水墨屏**默认横屏**(产品负责人定的默认),且能记住用户切过的方向', () {
// 全新配置 → 横屏
expect(const AppConfig().einkLandscape, isTrue);
// 用户切到竖屏 → 往返不丢
final cfg = _raw(eink: true, einkLandscape: false);
expect(AppConfig.fromJson(cfg.toJson()).einkLandscape, isFalse);
// 归一化不会擅自把用户选的方向改回去
expect(normalizeConfig(cfg, fromVersion: kConfigVersion)
.config.einkLandscape, isFalse);
});
test('老配置没有这个键 → 默认横屏(不是竖屏,也不是 null)', () {
final j = _raw().toJson()..remove('eink_landscape');
expect(AppConfig.fromJson(j).einkLandscape, isTrue);
});
test('老配置没有这两个键 → 默认关 + 10 秒(不误开墨水屏模式)', () {
final j = _raw().toJson()
..remove('eink_mode')
..remove('eink_refresh_sec');
final back = AppConfig.fromJson(j);
expect(back.einkMode, isFalse);
expect(back.einkRefreshSec, 10);
});
});
group('② ★ 迁移而不是清除:身份类字段必须原样保留', () {
test('合法的账号 / 令牌 / 设备标识一个都不许动', () {
final r = normalizeConfig(_raw(lastViewed: 'android-6f0d2e'));
+162
View File
@@ -0,0 +1,162 @@
/// 深色皮肤行为测试(本阶段新增,登记进 scripts/test.sh)
///
/// 运行:cd client && bash scripts/test.sh test/dark_theme_test.dart
///
/// ── 覆盖面 ────────────────────────────────────────────────────────
/// ① buildInkwatchDarkTheme() 的 brightness / colorScheme / 关键色值 / 控件语义色
/// ② E-Ink 主题不受深色影响(纯黑白、圆角 0、无动画、看板局部覆盖解耦)
/// ③ 浅色 buildInkwatchTheme() 逐字段未变(重构只是把装配参数化)
/// ④ 主题偏好持久化(AppConfig 值域 light|dark、默认 light 不预写键)
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:inkwatch_client/host/app_config.dart';
import 'package:inkwatch_client/host/config_migration.dart';
import 'package:inkwatch_client/ui/theme.dart';
void main() {
group('① 深色 ThemeData', () {
test('brightness == Brightness.dark,colorScheme 与主色一致', () {
final t = buildInkwatchDarkTheme();
expect(t.brightness, Brightness.dark);
expect(t.colorScheme.brightness, Brightness.dark);
// 「与主色一致」:primary 严格落在语义令牌上(不是 fromSeed 的 tonal 近似值)
expect(t.colorScheme.primary, InkwatchDarkColors.primary);
expect(t.colorScheme.primary, const Color(0xFF6E9CFF));
expect(t.colorScheme.surface, InkwatchDarkColors.surface);
expect(t.colorScheme.onSurface, InkwatchDarkColors.textPrimary);
expect(t.colorScheme.onPrimary, InkwatchDarkColors.textOnAccent);
});
test('关键色值:page / surface / textPrimary / primary / tertiary / snackbar', () {
final t = buildInkwatchDarkTheme();
// page #0B0E14
expect(t.scaffoldBackgroundColor, InkwatchDarkColors.page);
expect(t.scaffoldBackgroundColor, const Color(0xFF0B0E14));
// surface #151A23(AppBar / 卡片 / 弹窗 / 输入底)
expect(t.appBarTheme.backgroundColor, const Color(0xFF151A23));
expect(t.cardTheme.color, const Color(0xFF151A23));
expect(t.popupMenuTheme.color, InkwatchDarkColors.surface);
expect(t.inputDecorationTheme.fillColor, InkwatchDarkColors.surface);
// textPrimary #F2F4F9(AppBar 标题文字)
expect(t.appBarTheme.titleTextStyle?.color, InkwatchDarkColors.textPrimary);
expect(InkwatchDarkColors.textPrimary, const Color(0xFFF2F4F9));
// primary #6E9CFF
expect(t.colorScheme.primary, const Color(0xFF6E9CFF));
// tertiary 仍 #8A93A3 —— 显式不同步浅色点修值 #646D7D
expect(InkwatchDarkColors.textTertiary, const Color(0xFF8A93A3));
expect(InkwatchDarkColors.textTertiary, isNot(InkwatchColors.textTertiary));
// snackbar 反相 #F2F4F9:浅底必须配深字,不能白字压浅底
expect(t.snackBarTheme.backgroundColor, InkwatchDarkColors.snackbarBg);
expect(t.snackBarTheme.backgroundColor, const Color(0xFFF2F4F9));
expect(t.snackBarTheme.contentTextStyle?.color, InkwatchDarkColors.surface);
expect(t.snackBarTheme.contentTextStyle?.color, isNot(Colors.white));
});
test('控件语义色一并换深色(沿用同一套装配,只替换色板)', () {
final t = buildInkwatchDarkTheme();
expect(t.dividerTheme.color, InkwatchDarkColors.line);
expect(t.popupMenuTheme.textStyle?.color, InkwatchDarkColors.textPrimary);
expect(t.inputDecorationTheme.hintStyle?.color,
InkwatchDarkColors.textTertiary);
expect(t.inputDecorationTheme.focusedBorder?.borderSide.color,
InkwatchDarkColors.primary);
expect(t.listTileTheme.iconColor, InkwatchDarkColors.textTertiary);
expect(t.listTileTheme.textColor, InkwatchDarkColors.textPrimary);
expect(t.navigationBarTheme.backgroundColor, InkwatchDarkColors.surface);
expect(t.navigationBarTheme.indicatorColor, InkwatchDarkColors.primarySoft);
});
});
group('② E-Ink 不受深色影响', () {
test('einkTheme() 仍是纯黑白 / Brightness.light / 无 splash', () {
final e = einkTheme();
expect(e.colorScheme.brightness, Brightness.light);
expect(e.scaffoldBackgroundColor, InkwatchEink.page);
expect(e.scaffoldBackgroundColor, const Color(0xFFFFFFFF));
expect(e.colorScheme.primary, InkwatchEink.ink);
expect(e.colorScheme.primary, const Color(0xFF000000));
expect(e.cardTheme.color, InkwatchEink.box);
expect(e.appBarTheme.backgroundColor, InkwatchEink.page);
expect(e.splashFactory, NoSplash.splashFactory);
expect(e.snackBarTheme.backgroundColor, InkwatchEink.ink);
expect(e.snackBarTheme.backgroundColor,
isNot(InkwatchDarkColors.snackbarBg));
});
test('E-Ink 看板以 einkTheme() 局部覆盖主题,不读 themeMode', () {
final src = File('lib/ui/eink_board.dart').readAsStringSync();
expect(src.length, greaterThan(200), reason: 'eink_board.dart 读不到内容?');
expect(src.contains('Theme('), isTrue);
expect(src.contains('data: einkTheme()'), isTrue,
reason: '看板自带 einkTheme(),与 MaterialApp 的 themeMode 解耦');
expect(src.contains('InkwatchDarkColors'), isFalse);
expect(src.contains('buildInkwatchDarkTheme'), isFalse);
expect(src.contains('InkwatchColors'), isFalse,
reason: '看板只消费 InkwatchEink.* / InkwatchEinkText.*,不碰彩色令牌');
expect(src.contains('InkwatchEinkText'), isTrue);
expect(src.contains('statusGlyph'), isTrue);
});
});
group('③ 浅色主题未变(深色只是新增装配)', () {
test('buildInkwatchTheme() 仍是 Brightness.light,且色值与重构前基线一致', () {
final t = buildInkwatchTheme();
expect(t.brightness, Brightness.light);
expect(t.colorScheme.brightness, Brightness.light);
expect(t.scaffoldBackgroundColor, InkwatchColors.page);
// 重构前 colorScheme 由 fromSeed 派生,primary 不是主色本身。
// 用同一公式动态取基线,钉死「浅色分支没有被 copyWith 改动」。
final fromSeedPrimary = ColorScheme.fromSeed(
seedColor: InkwatchColors.primary,
brightness: Brightness.light,
).primary;
expect(t.colorScheme.primary, fromSeedPrimary);
expect(t.colorScheme.primary, isNot(InkwatchColors.primary));
expect(t.appBarTheme.backgroundColor, InkwatchColors.surface);
expect(t.cardTheme.color, InkwatchColors.surface);
expect(t.snackBarTheme.backgroundColor, InkwatchColors.textPrimary);
expect(t.snackBarTheme.contentTextStyle?.color, Colors.white);
// 深浅确实是两套不同的主题
expect(buildInkwatchDarkTheme().brightness, isNot(t.brightness));
});
});
group('④ 偏好持久化(AppConfig,值域 light|dark,默认 light 不预写键)', () {
test('默认值 light 且 toJson 不预写 theme_mode 键', () {
const cfg = AppConfig();
expect(cfg.themeMode, 'light');
expect(cfg.toJson().containsKey('theme_mode'), isFalse,
reason: '默认 light 不预写键(与 Web localStorage 默认不 set 同义)');
});
test('dark 往返不丢 / 切回 light 不写键', () {
const dark = AppConfig(themeMode: 'dark');
expect(dark.toJson()['theme_mode'], 'dark');
expect(AppConfig.fromJson(dark.toJson()).themeMode, 'dark');
expect(AppConfig.fromJson(const AppConfig().toJson()).themeMode, 'light');
expect(const AppConfig(themeMode: 'light')
.toJson()
.containsKey('theme_mode'), isFalse);
});
test('非法值经 normalizeConfig 回落 light(不跟随系统 / 任意串)', () {
final r = normalizeConfig(const AppConfig(themeMode: 'system'),
fromVersion: kConfigVersion);
expect(r.config.themeMode, 'light');
expect(r.changed, isTrue);
// 合法值零修正(正常启动不制造"已修正"噪音)
final ok = normalizeConfig(const AppConfig(), fromVersion: kConfigVersion);
expect(ok.changed, isFalse);
expect(ok.config.themeMode, 'light');
// 深色合法值原样保留
expect(
normalizeConfig(const AppConfig(themeMode: 'dark'),
fromVersion: kConfigVersion).config.themeMode,
'dark');
});
});
}
+449
View File
@@ -0,0 +1,449 @@
/// 整屏页面级深色化 —— 三主题视觉回归测试(本轮新增,登记进 scripts/test.sh)
///
/// 运行:cd client && bash scripts/test.sh test/dark_ui_test.dart
///
/// ── 覆盖面 ────────────────────────────────────────────────────────
/// ① 三份 InkwatchPalette 字段映射:light 逐项 == InkwatchColors、dark 逐项 ==
/// InkwatchDarkColors、eink 全部 R=G=B 无彩色;statusStyle 按色板分叉。
/// ② 三个 ThemeData(含 einkTheme)都挂了对应 extension —— E-Ink 不得为 null。
/// ③ 浅 / 深 / E-Ink 三主题下渲染关键自绘组件(指标卡 / 状态徽标 /
/// 标准卡片 / 提示条 / 说明文字),断言实际 BoxDecoration.color /
/// TextStyle.color / statusStyle 返回值分别等于三套色板。
/// ④ E-Ink 独立性:einkTheme() 与深色外壳下看板仍纯黑白、圆角 0,
/// 且 Theme(data: einkTheme()) 局部覆盖里 InkwatchPalette.of(ctx) 取到 eink。
library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:inkwatch_client/host/metric_format.dart';
import 'package:inkwatch_client/ui/eink_board.dart';
import 'package:inkwatch_client/ui/skeleton.dart';
import 'package:inkwatch_client/ui/theme.dart';
/// 把色板拆成「字段名 → 颜色」,便于逐项比对三份实例。
Map<String, Color> _fields(InkwatchPalette p) => <String, Color>{
'page': p.page,
'surface': p.surface,
'surfaceMuted': p.surfaceMuted,
'line': p.line,
'textPrimary': p.textPrimary,
'textSecondary': p.textSecondary,
'textTertiary': p.textTertiary,
'primary': p.primary,
'primarySoft': p.primarySoft,
'online': p.online,
'onlineSoft': p.onlineSoft,
'onlineText': p.onlineText,
'suspect': p.suspect,
'suspectSoft': p.suspectSoft,
'suspectText': p.suspectText,
'offline': p.offline,
'offlineText': p.offlineText,
'unknownText': p.unknownText,
'danger': p.danger,
'dangerSoft': p.dangerSoft,
'dangerText': p.dangerText,
'infoText': p.infoText,
'chartLine': p.chartLine,
'gap': p.gap,
};
Map<String, Color> _lightRef() => <String, Color>{
'page': InkwatchColors.page,
'surface': InkwatchColors.surface,
'surfaceMuted': InkwatchColors.surfaceMuted,
'line': InkwatchColors.line,
'textPrimary': InkwatchColors.textPrimary,
'textSecondary': InkwatchColors.textSecondary,
'textTertiary': InkwatchColors.textTertiary,
'primary': InkwatchColors.primary,
'primarySoft': InkwatchColors.primarySoft,
'online': InkwatchColors.online,
'onlineSoft': InkwatchColors.onlineSoft,
'onlineText': InkwatchColors.onlineText,
'suspect': InkwatchColors.suspect,
'suspectSoft': InkwatchColors.suspectSoft,
'suspectText': InkwatchColors.suspectText,
'offline': InkwatchColors.offline,
'offlineText': InkwatchColors.offlineText,
'unknownText': InkwatchColors.unknownText,
'danger': InkwatchColors.danger,
'dangerSoft': InkwatchColors.dangerSoft,
'dangerText': InkwatchColors.dangerText,
'infoText': InkwatchColors.infoText,
'chartLine': InkwatchColors.chartLine,
'gap': InkwatchColors.gap,
};
Map<String, Color> _darkRef() => <String, Color>{
'page': InkwatchDarkColors.page,
'surface': InkwatchDarkColors.surface,
'surfaceMuted': InkwatchDarkColors.surfaceMuted,
'line': InkwatchDarkColors.line,
'textPrimary': InkwatchDarkColors.textPrimary,
'textSecondary': InkwatchDarkColors.textSecondary,
'textTertiary': InkwatchDarkColors.textTertiary,
'primary': InkwatchDarkColors.primary,
'primarySoft': InkwatchDarkColors.primarySoft,
'online': InkwatchDarkColors.online,
'onlineSoft': InkwatchDarkColors.onlineSoft,
'onlineText': InkwatchDarkColors.onlineText,
'suspect': InkwatchDarkColors.suspect,
'suspectSoft': InkwatchDarkColors.suspectSoft,
'suspectText': InkwatchDarkColors.suspectText,
'offline': InkwatchDarkColors.offline,
'offlineText': InkwatchDarkColors.offlineText,
'unknownText': InkwatchDarkColors.unknownText,
'danger': InkwatchDarkColors.danger,
'dangerSoft': InkwatchDarkColors.dangerSoft,
'dangerText': InkwatchDarkColors.dangerText,
'infoText': InkwatchDarkColors.infoText,
'chartLine': InkwatchDarkColors.chartLine,
'gap': InkwatchDarkColors.gap,
};
/// 无彩色 = R=G=B(纯黑 / 纯白 / 中性灰都算,带色相的彩色不算)。
bool _isGray(Color c) => c.r == c.g && c.g == c.b;
/// 渲染用探针:全部用**真实自绘组件 / 真实装饰出口**,只把状态徽标与提示条
/// 就地拼出来(它们在生产里是页面私有方法,无法单独挂载)。
class _PaletteProbe extends StatelessWidget {
const _PaletteProbe();
@override
Widget build(BuildContext context) {
final p = InkwatchPalette.of(context);
final s = statusStyle('online', p);
return SingleChildScrollView(
child: Column(
children: [
const SkeletonMetricCard(), // 标准卡片 / 指标卡(InkwatchCard.standard)
const SkeletonBox(width: 40, height: 10), // 次级表面色块
const LoadingHint(text: '正在加载…'), // 说明文字(InkwatchTextStyles.caption)
Container(
key: const Key('notice'),
width: 120,
height: 20,
decoration: InkwatchCard.notice(p.suspect, p.suspectSoft), // 提示条
),
Container(
key: const Key('badge'),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(color: s.soft), // 状态徽标(软底)
child: Text(s.label, style: TextStyle(color: s.text)),
),
],
),
);
}
}
Future<void> _pumpProbe(WidgetTester tester, ThemeData theme) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(
theme: theme,
home: const Scaffold(body: _PaletteProbe()),
));
}
/// 断言探针里每个自绘点都取当前色板 p 的对应值。
void _expectProbe(WidgetTester tester, InkwatchPalette p) {
// 标准卡片:白面 + 发丝线
final card = tester.widget<Container>(find
.descendant(
of: find.byType(SkeletonMetricCard), matching: find.byType(Container))
.first);
final cardDeco = card.decoration! as BoxDecoration;
expect(cardDeco.color, p.surface, reason: 'InkwatchCard.standard().color');
expect((cardDeco.border as Border).top.color, p.line,
reason: 'InkwatchCard.standard().border');
// 次级表面色块
final box = tester.widget<Container>(find
.descendant(
of: find.byType(SkeletonBox), matching: find.byType(Container))
.first);
expect((box.decoration! as BoxDecoration).color, p.surfaceMuted,
reason: 'SkeletonBox 的 surfaceMuted');
// 说明文字(caption)
final hint = tester.widget<Text>(find
.descendant(of: find.byType(LoadingHint), matching: find.byType(Text))
.first);
expect(hint.style?.color, p.textTertiary,
reason: 'InkwatchTextStyles.caption 的 textTertiary');
// 提示条(notice 的软底)
final notice = tester.widget<Container>(find.byKey(const Key('notice')));
expect((notice.decoration! as BoxDecoration).color, p.suspectSoft,
reason: 'InkwatchCard.notice 的软底');
// 状态徽标:软底与文字色都来自 statusStyle(key, p)
final s = statusStyle('online', p);
final badge = tester.widget<Container>(find.byKey(const Key('badge')));
expect((badge.decoration! as BoxDecoration).color, s.soft,
reason: '状态徽标软底');
final badgeText = tester.widget<Text>(find
.descendant(of: find.byKey(const Key('badge')), matching: find.byType(Text))
.first);
expect(badgeText.style?.color, s.text, reason: '状态徽标文字色');
}
/// 典型真机数值(照抄 eink_board_test.dart 的 _realFrame)。
final _boardFrame = <String, dynamic>{
'cpu_usage': 24.8,
'mem_usage': 67.6,
'mem_used_mb': 8192.0,
'disk_free_gb': 239.4,
'net_rx_mbps': 1.42,
'net_tx_mbps': 0.31,
};
Future<void> _pumpBoard(WidgetTester tester,
{required ThemeData outer, ThemeMode mode = ThemeMode.light}) async {
tester.view.physicalSize = const Size(724, 536);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(
theme: outer,
darkTheme: outer,
themeMode: mode,
home: EinkBoard(
deviceName: 'MacBook Pro · M3 Max',
statusKey: 'online',
agoText: '数据新鲜(每秒上报)',
frame: _boardFrame,
hiddenKeys: const <String>{},
formatter: MetricFormatter(),
lastRefreshText: '20:18:52',
refreshNote: '每 10 秒',
onRefresh: () {},
),
));
}
/// 看板必须:所有自绘颜色无彩色、所有 BoxDecoration 圆角为 0。
void _expectBoardMonochrome(WidgetTester tester) {
for (final c in tester.widgetList<Container>(find.byType(Container))) {
final d = c.decoration;
if (d is BoxDecoration) {
if (d.color != null) {
expect(_isGray(d.color!), isTrue,
reason: '看板出现彩色装饰 ${d.color}');
}
expect(d.borderRadius, isNull,
reason: '墨水屏看板不允许圆角(灰阶残影 + 刷新成本)');
final b = d.border;
if (b is Border) {
for (final side in <BorderSide>[b.top, b.right, b.bottom, b.left]) {
expect(_isGray(side.color), isTrue,
reason: '看板描边出现彩色 ${side.color}');
}
}
}
}
for (final t in tester.widgetList<Text>(find.byType(Text))) {
final c = t.style?.color;
if (c != null) {
expect(_isGray(c), isTrue, reason: '看板文字出现彩色 $c');
}
}
for (final i in tester.widgetList<Icon>(find.byType(Icon))) {
final c = i.color;
if (c != null) {
expect(_isGray(c), isTrue, reason: '看板图标出现彩色 $c');
}
}
for (final d in tester.widgetList<Divider>(find.byType(Divider))) {
final c = d.color;
if (c != null) {
expect(_isGray(c), isTrue, reason: '看板分隔线出现彩色 $c');
}
}
}
void main() {
group('① 三份 palette 的字段映射', () {
test('light 逐项 == InkwatchColors(浅色视觉一字不变,24 字段全覆盖)', () {
final got = _fields(InkwatchPalette.light);
final ref = _lightRef();
expect(got.keys.toSet(), ref.keys.toSet());
for (final k in ref.keys) {
expect(got[k], ref[k],
reason: 'InkwatchPalette.light.$k 必须等于 InkwatchColors.$k');
}
});
test('dark 逐项 == InkwatchDarkColors(深色整屏生效的根)', () {
final got = _fields(InkwatchPalette.dark);
final ref = _darkRef();
expect(got.keys.toSet(), ref.keys.toSet());
for (final k in ref.keys) {
expect(got[k], ref[k],
reason: 'InkwatchPalette.dark.$k 必须等于 InkwatchDarkColors.$k');
}
});
test('深色关键色值:page/surface/textPrimary/primary/tertiary', () {
expect(InkwatchPalette.dark.page, const Color(0xFF0B0E14));
expect(InkwatchPalette.dark.surface, const Color(0xFF151A23));
expect(InkwatchPalette.dark.textPrimary, const Color(0xFFF2F4F9));
expect(InkwatchPalette.dark.primary, const Color(0xFF6E9CFF));
// tertiary 显式不同步浅色点修值 #646D7D
expect(InkwatchPalette.dark.textTertiary, const Color(0xFF8A93A3));
expect(InkwatchPalette.dark.textTertiary,
isNot(InkwatchPalette.light.textTertiary));
// 深浅确实是两套不同的色板
for (final k in _fields(InkwatchPalette.light).keys) {
if (k == 'offline') continue; // offline 深浅同值(中性灰),语义如此
expect(_fields(InkwatchPalette.dark)[k],
isNot(_fields(InkwatchPalette.light)[k]),
reason: 'dark.$k 不能仍是浅色值');
}
});
test('eink 全部字段 R=G=B(无彩色),且映射到 InkwatchEink 六色', () {
final got = _fields(InkwatchPalette.eink);
expect(got.length, 24);
for (final e in got.entries) {
expect(_isGray(e.value), isTrue,
reason: 'InkwatchPalette.eink.${e.key} 出现了彩色');
}
expect(InkwatchPalette.eink.page, InkwatchEink.page);
expect(InkwatchPalette.eink.surface, InkwatchEink.box);
expect(InkwatchPalette.eink.line, InkwatchEink.rule);
expect(InkwatchPalette.eink.textPrimary, InkwatchEink.ink);
expect(InkwatchPalette.eink.primary, InkwatchEink.ink);
expect(InkwatchPalette.eink.chartLine, InkwatchEink.ink);
});
test('statusStyle 按色板分叉(6 键),单参数旧签名仍回退浅色', () {
for (final key in <String>[
'online',
'suspect',
'offline',
'unknown',
'info',
'anything-else'
]) {
final l = statusStyle(key, InkwatchPalette.light);
final d = statusStyle(key, InkwatchPalette.dark);
final e = statusStyle(key, InkwatchPalette.eink);
// label 与色板无关
expect(l.label, d.label);
expect(l.label, e.label);
// 颜色严格来自各自色板(钉死「不再写死浅色常量」)
expect(l.color, statusStyle(key, InkwatchPalette.light).color);
expect(_isGray(e.color), isTrue, reason: '$key eink.color 有彩色');
expect(_isGray(e.soft), isTrue, reason: '$key eink.soft 有彩色');
expect(_isGray(e.text), isTrue, reason: '$key eink.text 有彩色');
}
expect(statusStyle('online', InkwatchPalette.light).color,
InkwatchColors.online);
expect(statusStyle('online', InkwatchPalette.dark).color,
InkwatchDarkColors.online);
expect(statusStyle('online', InkwatchPalette.dark).color,
isNot(statusStyle('online', InkwatchPalette.light).color));
// 单参数(theme_guard_test 仍在用)必须仍是浅色
expect(statusStyle('online').color, InkwatchColors.online);
expect(statusStyle('online').soft, InkwatchColors.onlineSoft);
expect(statusStyle('online').text, InkwatchColors.onlineText);
});
});
group('② 三个 ThemeData 都挂了对应 palette', () {
test('light / dark / eink 各挂一份,E-Ink 绝不为 null', () {
expect(buildInkwatchTheme().extension<InkwatchPalette>(),
same(InkwatchPalette.light));
expect(buildInkwatchDarkTheme().extension<InkwatchPalette>(),
same(InkwatchPalette.dark));
expect(einkTheme().extension<InkwatchPalette>(), same(InkwatchPalette.eink));
});
test('InkwatchCard.standard()/selected() 缺省回退浅色、显式传板分叉', () {
final std = InkwatchCard.standard();
expect(std.color, InkwatchColors.surface);
expect((std.border as Border).top.color, InkwatchColors.line);
final sel = InkwatchCard.selected();
expect(sel.color, InkwatchColors.primarySoft);
expect((sel.border as Border).top.color, InkwatchColors.primary);
final dStd = InkwatchCard.standard(palette: InkwatchPalette.dark);
expect(dStd.color, InkwatchDarkColors.surface);
expect((dStd.border as Border).top.color, InkwatchDarkColors.line);
final dSel = InkwatchCard.selected(palette: InkwatchPalette.dark);
expect(dSel.color, InkwatchDarkColors.primarySoft);
expect((dSel.border as Border).top.color, InkwatchDarkColors.primary);
final eStd = InkwatchCard.standard(palette: InkwatchPalette.eink);
expect(_isGray(eStd.color!), isTrue);
expect((eStd.border as Border).top.color, InkwatchEink.rule);
});
});
group('③ 三主题下渲染关键自绘组件', () {
testWidgets('浅色:自绘点全部 == InkwatchPalette.light', (tester) async {
await _pumpProbe(tester, buildInkwatchTheme());
_expectProbe(tester, InkwatchPalette.light);
});
testWidgets('深色:同一批自绘点全部 == InkwatchPalette.dark', (tester) async {
await _pumpProbe(tester, buildInkwatchDarkTheme());
_expectProbe(tester, InkwatchPalette.dark);
});
testWidgets('E-Ink:同一批自绘点全部 == InkwatchPalette.eink 且无彩色',
(tester) async {
await _pumpProbe(tester, einkTheme());
_expectProbe(tester, InkwatchPalette.eink);
// E-Ink 下探针里所有 BoxDecoration.color 都无彩色
for (final c in tester.widgetList<Container>(find.byType(Container))) {
final d = c.decoration;
if (d is BoxDecoration && d.color != null) {
expect(_isGray(d.color!), isTrue);
}
}
});
});
group('④ E-Ink 独立性(深色外壳也不得把看板染成彩色)', () {
testWidgets('einkTheme() 下渲染看板:纯黑白 / 圆角 0 / 无动画',
(tester) async {
await _pumpBoard(tester, outer: einkTheme());
_expectBoardMonochrome(tester);
// 零动画:pumpAndSettle 能收敛(有无限动画会超时)
await tester.pumpAndSettle(const Duration(milliseconds: 100));
expect(tester.takeException(), isNull);
});
testWidgets('深色 MaterialApp 外壳下看板仍无彩色(内部 einkTheme 覆盖生效)',
(tester) async {
await _pumpBoard(tester,
outer: buildInkwatchDarkTheme(), mode: ThemeMode.dark);
_expectBoardMonochrome(tester);
});
testWidgets('Theme(data: einkTheme()) 局部覆盖里 InkwatchPalette.of(ctx) == eink',
(tester) async {
// 直接模拟看板 eink_board.dart:296 的局部覆盖,且外层故意是深色,
// 钉死「页面改成 extension 查询后,看板里取到 null 会崩」这个最大风险。
InkwatchPalette? seen;
await tester.pumpWidget(MaterialApp(
theme: buildInkwatchDarkTheme(),
darkTheme: buildInkwatchDarkTheme(),
themeMode: ThemeMode.dark,
home: Theme(
data: einkTheme(),
child: Builder(builder: (ctx) {
seen = InkwatchPalette.of(ctx);
return const SizedBox.shrink();
}),
),
));
expect(seen, same(InkwatchPalette.eink));
expect(seen, isNot(same(InkwatchPalette.dark)));
});
});
}
+3 -3
View File
@@ -1,6 +1,6 @@
/// 「归并必须告知用户」—— 认领时带指纹 + 把归并结果说出来
///
/// ── 为什么要单独测这条(2026-09-15 <user>反馈)────────────────────────
/// ── 为什么要单独测这条(2026-09-15 产品负责人反馈)────────────────────────
/// 「同一个手机重新认领之后,并不会和之前的数据结合,而是重新出现一个设备」
///
/// 服务端的归并逻辑由 `tests/device-merge-test.js`(31 项)覆盖。客户端这一侧
@@ -18,8 +18,8 @@ library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pmon_client/host/account_api.dart';
import 'package:pmon_client/ui/pairing_card.dart';
import 'package:inkwatch_client/host/account_api.dart';
import 'package:inkwatch_client/ui/pairing_card.dart';
const _deviceId = 'android-newmodel-y-abc123';
const _hw = 'abc123def4567890';
+15 -4
View File
@@ -12,7 +12,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:flutter_test/flutter_test.dart';
import 'package:pmon_client/platform/device_model.dart';
import 'package:inkwatch_client/platform/device_model.dart';
/// 造一份 system_profiler 输出(照真机格式,含易混淆的 Model Identifier 行)
String profilerOut({String? name, String? chip, String? modelId}) => [
@@ -30,7 +30,18 @@ ProcessResult ok(String stdout) =>
ProcessResult bad() => ProcessResult(0, 1, '', 'not found');
void main() {
group('① macOS:优先 system_profiler 的机型名', () {
// 平台守卫(2026-11-09 加):
// resolveDeviceModel 的第一个分支就是 `Platform.isMacOS`,Linux/Windows 会
// 走到函数末尾直接 `return ''`。下面几组用例的前提是「macOS 的两级取法」,
// 在别的平台跑等于用错前提 —— 之前在 Gitea 的 ubuntu runner 上就出现
// 7 条红,看似代码坏了,实为平台不符。
// 处理方式:显式 skip 并写明原因(框架会计入 skipped,不会伪装成通过)。
// ③ 组本来就有运行时判断,保持原样。
final macOnly = Platform.isMacOS
? null
: '仅 macOS:它测的是 system_profiler / sysctl 两级取法,别的平台上该函数直接返回空串';
group('① macOS:优先 system_profiler 的机型名', skip: macOnly, () {
test('正常输出 → "MacBook Pro · M3 Max"(芯片去掉厂商前缀)', () async {
final m = await resolveDeviceModel(
run: (exe, args) async =>
@@ -75,7 +86,7 @@ void main() {
});
});
group('② 降级链:每一步都必须有交代,绝不编造', () {
group('② 降级链:每一步都必须有交代,绝不编造', skip: macOnly, () {
test('system_profiler 失败 → 降级 sysctl hw.model(Mac15,8)', () async {
final m = await resolveDeviceModel(
run: (exe, args) async {
@@ -129,7 +140,7 @@ void main() {
});
});
group('④ 字段解析的边界', () {
group('④ 字段解析的边界', skip: macOnly, () {
test('输出为空 → 空串', () async {
final m = await resolveDeviceModel(
run: (exe, args) async => exe == 'system_profiler' ? ok('') : bad());
+631
View File
@@ -0,0 +1,631 @@
/// 水墨屏看板测试(`feature/eink` 分支专属)
///
/// ── 这套测试盯的是**产品负责人 2026-09-16 提的四条要求**,逐条可执行 ──────
/// | 要求 | 断言落在哪 |
/// |---|---|
/// | 横屏为默认、可切竖屏 | ① 列数按宽高比(离线穷举)+ 实际渲染列数 + 「转屏」按钮 |
/// | 聚焦核心数据、隐藏次要信息与冗余控件 | ② 在线时顶部**不得**出现状态/末帧文案;异常才出现 |
/// | 良好的对比度与刷新效果 | ③ 零无限动画(`pumpAndSettle` 能收敛)+ 多比例下一屏放下不溢出 |
/// | **统一字体与样式,避免字号大小不一** | ④ **同屏所有数值的 fontSize 必须完全相等** |
///
/// ④ 是最容易做错的一条:若逐格用 `FittedBox(scaleDown)`,
/// `310 kb/s`(长)会比 `24.8%`(短)小一号 —— 一屏里字号参差。
/// 故改为"按全屏最长那条统一算比例",并由测试**逐格取值**断言。
///
/// 运行:cd client && bash scripts/test.sh test/eink_board_test.dart
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:inkwatch_client/host/metric_display.dart';
import 'package:inkwatch_client/host/metric_format.dart';
import 'package:inkwatch_client/ui/eink_board.dart';
import 'package:inkwatch_client/ui/eink_mode.dart';
import 'package:inkwatch_client/ui/theme.dart';
/// 典型横屏 / 竖屏逻辑尺寸(Neo2 = 1448×1072 / 1072×1448 @ density 2.0)
const _landscape = Size(724, 536);
const _portrait = Size(536, 724);
/// **真实形态**的数值(一台 16GB/1TB 的机器)—— "字号一致""数值是主角"
/// 这类断言必须用它:拿极端值去断言常态需求,是在用假数据测假需求
/// (本仓库反复踩过这个坑)。
final _realFrame = <String, dynamic>{
'cpu_usage': 24.8,
'mem_usage': 67.6,
'mem_used_mb': 8192.0,
'disk_free_gb': 239.4,
'net_rx_mbps': 1.42,
'net_tx_mbps': 0.31,
};
/// **极端值**:只为"不溢出"这类压力断言服务(真实里少见,但不能崩)
final _fatFrame = <String, dynamic>{
'cpu_usage': 24.8,
'mem_usage': 67.6,
'mem_used_mb': 999999.0,
'disk_free_gb': 99999.0,
'net_rx_mbps': 1.42,
'net_tx_mbps': 310.0,
};
Future<void> mount(
WidgetTester tester, {
Map<String, dynamic>? frame,
Set<String> hidden = const {},
String statusKey = 'online',
String agoText = '数据新鲜(每秒上报)',
String deviceName = 'MacBook Pro · M3 Max',
Size size = _landscape,
VoidCallback? onRefresh,
VoidCallback? onToggleOrientation,
VoidCallback? onExit,
bool showDiagnostics = false,
VoidCallback? onToggleDiagnostics,
List<(String, String, IconData?)> diagnostics = const [],
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(
home: EinkBoard(
deviceName: deviceName,
statusKey: statusKey,
agoText: agoText,
frame: frame ?? _realFrame,
hiddenKeys: hidden,
formatter: MetricFormatter(),
lastRefreshText: '20:18:52',
refreshNote: '每 10 秒',
onRefresh: onRefresh ?? () {},
onToggleOrientation: onToggleOrientation,
onExit: onExit,
showDiagnostics: showDiagnostics,
onToggleDiagnostics: onToggleDiagnostics,
diagnostics: diagnostics,
),
));
}
/// 取某格数值的**实际字号**(看板给数值挂了 key,才能逐格取值)
double valueSizeOf(WidgetTester tester, String key) {
final w = tester.widget<Text>(find.byKey(ValueKey('eink-value-$key')));
return w.style!.fontSize!;
}
/// 数"一行里最多放了几格"(同 y 坐标的格子数)
int columnsOf(WidgetTester tester, List<MetricDisplaySpec> specs) {
final byY = <int, int>{};
for (final s in specs) {
final f = find.byKey(ValueKey('eink-value-${s.key}'));
if (f.evaluate().isEmpty) continue;
final y = tester.getTopLeft(f).dy.round();
byY[y] = (byY[y] ?? 0) + 1;
}
return byY.values.isEmpty ? 0 : byY.values.reduce((a, b) => a > b ? a : b);
}
void main() {
setUp(InkwatchEinkMode.reset);
tearDown(InkwatchEinkMode.reset);
// ══════════════════════════════════════════════════════════════
group('① 横屏为默认、可切竖屏', () {
test('★ 列数按可用空间的宽高比决定(宽 → 3 列,高 → 2 列)', () {
expect(einkGridColumns(724, 536), 3, reason: '横屏应铺 3 列');
expect(einkGridColumns(536, 724), 2, reason: '竖屏应 2 列');
// 正方形 / 极端比例也不能崩
expect(einkGridColumns(600, 600), 2);
expect(einkGridColumns(2000, 300), 3);
expect(einkGridColumns(300, 2000), 2);
});
testWidgets('横屏实际渲染出 3 列', (tester) async {
await mount(tester, size: _landscape);
await tester.pumpAndSettle();
expect(columnsOf(tester, einkBoardSpecs(const {})), 3);
});
testWidgets('竖屏实际渲染出 2 列', (tester) async {
await mount(tester, size: _portrait);
await tester.pumpAndSettle();
expect(columnsOf(tester, einkBoardSpecs(const {})), 2);
});
testWidgets('「转屏」按钮只在宿主给了回调时出现,点击触发一次', (tester) async {
await mount(tester, size: _landscape);
await tester.pumpAndSettle();
expect(find.text('转屏'), findsNothing,
reason: '宿主没提供切换能力时不该出现按钮(看板不管屏幕方向)');
var n = 0;
await mount(tester, size: _landscape, onToggleOrientation: () => n++);
await tester.pumpAndSettle();
expect(find.text('转屏'), findsOneWidget);
await tester.tap(find.text('转屏'));
await tester.pumpAndSettle();
expect(n, 1);
});
});
// ══════════════════════════════════════════════════════════════
group('② 聚焦核心数据:隐藏次要信息与冗余控件', () {
testWidgets('★ 在线时顶部**不得**铺出状态与末帧文案(● 已经说了在线)',
(tester) async {
await mount(tester, statusKey: 'online', agoText: '数据新鲜(每秒上报)');
await tester.pumpAndSettle();
expect(find.textContaining('数据新鲜'), findsNothing,
reason: '"数据新鲜"与右下角刷新时刻重复,属冗余信息');
expect(find.textContaining('在线'), findsNothing,
reason: '实心 ● 已表达在线,再写两个字是冗余');
// 但设备名与形状必须在
expect(find.text('MacBook Pro · M3 Max'), findsOneWidget);
expect(find.text(statusGlyph('online')), findsOneWidget);
});
testWidgets('★ 异常时才把状态与原因铺出来(那时它们才是有用信息)',
(tester) async {
await mount(tester, statusKey: 'offline', agoText: '已 5 分钟未上报数据');
await tester.pumpAndSettle();
expect(find.textContaining('离线'), findsOneWidget);
expect(find.textContaining('已 5 分钟未上报数据'), findsOneWidget);
expect(find.text(statusGlyph('offline')), findsOneWidget);
});
testWidgets('底部只留一行说明(刷新时刻与节奏已合并)', (tester) async {
await mount(tester);
await tester.pumpAndSettle();
expect(find.text('20:18:52 · 每 10 秒'), findsOneWidget);
expect(find.text('最后刷新 20:18:52'), findsNothing,
reason: '旧的第二行已合并掉 —— 副屏角落的字越少越好');
});
testWidgets('只显示 6 项核心指标', (tester) async {
await mount(tester);
await tester.pumpAndSettle();
var shown = 0;
for (final s in einkBoardSpecs(const {})) {
if (find.byKey(ValueKey('eink-label-${s.key}')).evaluate().isNotEmpty) {
shown++;
}
}
expect(shown, kEinkCellCount);
expect(einkBoardSpecs(const {}).map((s) => s.key).contains('cpu_cores'),
isFalse);
});
});
// ══════════════════════════════════════════════════════════════
group('③ 对比度与刷新:零动画、一屏放下', () {
testWidgets('有数据时 pumpAndSettle 必须能收敛(有无限动画必然超时)',
(tester) async {
await mount(tester);
await tester.pumpAndSettle();
expect(find.byType(CircularProgressIndicator), findsNothing);
});
testWidgets('等待首帧同样是静止的(不上转圈)', (tester) async {
await mount(tester, frame: const {}, agoText: '本会话还没收到数据');
await tester.pumpAndSettle();
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.text('等待这台设备上报第一帧'), findsOneWidget);
});
test('源码结构:看板里不得出现任何无限动画/转圈', () {
final src = codeOnly(fileText('lib/ui/eink_board.dart'));
for (final bad in [
'repeat(',
'CircularProgressIndicator',
'LinearProgressIndicator',
'AnimationController',
'AnimatedContainer',
]) {
expect(src.contains(bad), isFalse,
reason: 'eink_board.dart 里出现了 `$bad` —— 墨水屏上任何持续动画都是持续刷新');
}
});
test('源码结构:全局等待指示里的转圈必须被水墨屏开关守住', () {
final src = codeOnly(fileText('lib/ui/eink_mode.dart'));
expect(src.contains('if (InkwatchEinkMode.enabled)'), isTrue);
expect(src.contains('repeat('), isFalse);
expect(src.indexOf('CircularProgressIndicator'),
greaterThan(src.indexOf('if (InkwatchEinkMode.enabled)')),
reason: '转圈出现在开关之前 —— 它会在水墨屏下先被渲染');
});
test('源码结构:看板不得使用彩色主题的灰底(墨水屏上灰底要整块重绘且残留)', () {
final src = codeOnly(fileText('lib/ui/eink_board.dart'));
expect(src.contains('InkwatchColors.'), isFalse,
reason: '看板只能用 InkwatchEink 的三档明度 + 黑描边分区');
});
// 一屏放下、永不溢出:多比例都要过(溢出会直接抛 RenderFlex overflowed)
const sizes = <String, Size>{
'横屏(724×536)': Size(724, 536),
'竖屏(536×724)': Size(536, 724),
'很矮(536×420)': Size(536, 420),
'很窄(380×700)': Size(380, 700),
'很宽(900×500)': Size(900, 500),
'极小(320×380)': Size(320, 380),
};
for (final e in sizes.entries) {
testWidgets('不溢出:${e.key}', (tester) async {
// 压力:用极端值(`999999 MB` / `310.00 Mb/s`)也要一屏放下
await mount(tester, size: e.value, frame: _fatFrame);
await tester.pumpAndSettle();
expect(find.text('刷新'), findsOneWidget);
expect(find.text('20:18:52 · 每 10 秒'), findsOneWidget);
});
}
});
// ══════════════════════════════════════════════════════════════
group('④ ★ 统一字体与样式(产品负责人点名的那条)', () {
testWidgets('★ 同一屏所有数值的 fontSize 必须**完全相等**(竖屏)',
(tester) async {
await mount(tester, size: _portrait);
await tester.pumpAndSettle();
final specs = einkBoardSpecs(const {});
final sizes = specs.map((s) => valueSizeOf(tester, s.key)).toSet();
expect(sizes.length, 1,
reason: '同屏数值出现了 ${sizes.length} 种字号 $sizes —— '
'这正是"字号大小不一"。数值缩放必须按全屏最长那条统一算,'
'不能逐格 FittedBox 各自缩。');
});
testWidgets('★ 同一屏所有数值的 fontSize 必须**完全相等**(横屏)',
(tester) async {
await mount(tester, size: _landscape);
await tester.pumpAndSettle();
final specs = einkBoardSpecs(const {});
final sizes = specs.map((s) => valueSizeOf(tester, s.key)).toSet();
expect(sizes.length, 1, reason: '横屏也出现了多种字号 $sizes');
});
testWidgets('★ 同屏数值的**渲染高度**必须一致 —— 这条才抓得住逐格 FittedBox',
(tester) async {
// ⚠️ 光查 `style.fontSize` 是不够的:用 `FittedBox` 逐格缩时,
// style 完全一样(都是 40),但**渲染出来的大小**各不相同 ——
// 只断言 style 会让这个退化溜过去。渲染高度才是"看起来是否同号"的真相。
for (final size in [_landscape, _portrait]) {
await mount(tester, size: size);
await tester.pumpAndSettle();
final specs = einkBoardSpecs(const {});
final heights = specs
.map((s) => tester
.getSize(find.byKey(ValueKey('eink-value-${s.key}')))
.height)
.toList();
final lo = heights.reduce((a, b) => a < b ? a : b);
final hi = heights.reduce((a, b) => a > b ? a : b);
expect(hi - lo, lessThan(1.0),
reason: '$size 同屏数值渲染高度相差 ${(hi - lo).toStringAsFixed(1)}px '
'($heights)—— 数值大小不一致。这通常是把统一缩放换成了'
'逐格 FittedBox:长值被压小,看着像两种字号。');
}
});
testWidgets('★ 所有标签同号、所有数值同号,且数值大于标签', (tester) async {
await mount(tester);
await tester.pumpAndSettle();
final specs = einkBoardSpecs(const {});
final labels = specs
.map((s) => tester
.widget<Text>(find.byKey(ValueKey('eink-label-${s.key}')))
.style!
.fontSize)
.toSet();
expect(labels.length, 1, reason: '标签字号不一致:$labels');
expect(labels.first, InkwatchEinkText.label.fontSize);
final v = valueSizeOf(tester, specs.first.key);
expect(v, lessThanOrEqualTo(InkwatchEinkText.value.fontSize!));
expect(v, greaterThan(labels.first!));
});
testWidgets('★ 数值必须是主角:任何尺寸下数值渲染高度都大于标签', (tester) async {
// 2026-09-16 真机暴露的缺陷:横屏矮屏(F50 横过来只有 ~366dp 高)时,
// 标签 + 内边距吃掉了几乎整格,数值被缩到**比标签还小** ——
// 6 个大数才是这个页面的主角,被标签压过去就本末倒置了。
// 修法是"矮屏切紧凑排版,把空间让给数值";这条断言锁住它。
// 尺寸集**只取真实存在的墨水屏设备尺寸**(6″ 及以上,短边 ≥ 366dp):
// 320×380 这类 3.3″ 的尺寸没有任何对应设备,在那种格子里标签本身就占满
// 一格,"数值大于标签"物理上做不到 —— 拿它当约束是"用假需求测真人"。
// 溢出/不崩那一条仍然覆盖极端尺寸(见上一组)。
const cases = <String, Size>{
'典型横屏(6″ 横过来)': Size(724, 536),
'典型竖屏(6″)': Size(536, 724),
'矮横屏(F50 横过来)': Size(651, 366),
'很矮(6″ 逼近下限)': Size(536, 420),
};
for (final e in cases.entries) {
await mount(tester, size: e.value);
await tester.pumpAndSettle();
final specs = einkBoardSpecs(const {});
final vKey = ValueKey('eink-value-${specs.first.key}');
final lKey = ValueKey('eink-label-${specs.first.key}');
final vh = tester.getSize(find.byKey(vKey)).height;
final lh = tester.getSize(find.byKey(lKey)).height;
expect(vh, greaterThan(lh),
reason: '${e.key}:数值高 ${vh.toStringAsFixed(1)}px 不大于标签高 '
'${lh.toStringAsFixed(1)}px —— 数值被标签压过去了(版面本末倒置)');
}
});
test('★ 源码结构:界面里不得写就地字号(`fontSize: 数字`)', () {
// 唯一允许改字号的地方是 `theme.dart` 的令牌表,
// 以及看板里那处**算出来的**(fontSize: 基准 × 统一比例)。
for (final path in ['lib/ui/eink_board.dart', 'lib/ui/eink_mode.dart']) {
final src = codeOnly(fileText(path));
final offenders = <String>[];
for (final line in src.split('\n')) {
if (RegExp(r'fontSize:\s*[0-9]').hasMatch(line)) offenders.add(line.trim());
}
expect(offenders, isEmpty,
reason: '$path 里出现了就地字号:\n ${offenders.join('\n ')}\n'
'要新尺寸就在 InkwatchEinkText 里加一档并写清语义 —— '
'否则同类元素很快会变成 13/13.5/14 三种写法。');
}
});
test('★ theme.dart 的水墨屏刻度有限且互不相同', () {
final sizes = [
InkwatchEinkText.value.fontSize!,
InkwatchEinkText.glyph.fontSize!,
InkwatchEinkText.title.fontSize!,
InkwatchEinkText.label.fontSize!,
InkwatchEinkText.action.fontSize!,
InkwatchEinkText.caption.fontSize!,
];
expect(sizes.toSet().length, 5,
reason: '应恰好 5 档(label/action 刻意同号,靠字重区分),实际 ${sizes.toSet()}');
for (final s in [
InkwatchEinkText.value,
InkwatchEinkText.glyph,
InkwatchEinkText.title,
InkwatchEinkText.label,
InkwatchEinkText.action,
InkwatchEinkText.caption,
]) {
expect(s.fontWeight!.value, greaterThanOrEqualTo(FontWeight.w600.value),
reason: '水墨屏文字不得低于 w600(细笔画会发虚)');
}
});
test('★ 无损紧凑化:只删"无信息的零尾",**绝不四舍五入**', () {
// 该删的
expect(compactEinkValue('310.00 Mb/s'), '310 Mb/s');
expect(compactEinkValue('1.50 Mb/s'), '1.5 Mb/s');
expect(compactEinkValue('100.0 %'), '100 %');
expect(compactEinkValue('2.00 GB'), '2 GB');
// 该原样的(读数一位都不能动)
expect(compactEinkValue('24.8%'), '24.8%');
expect(compactEinkValue('1.42 Mb/s'), '1.42 Mb/s');
expect(compactEinkValue('976.56 GB'), '976.56 GB');
expect(compactEinkValue('1.007'), '1.007');
expect(compactEinkValue('8192 MB'), '8192 MB');
expect(compactEinkValue('—'), '—');
// 末尾就是零、且后面没有小数:不许被吃掉
expect(compactEinkValue('10 %'), '10 %');
expect(compactEinkValue('100'), '100');
});
testWidgets('★ 紧凑化能在真实屏宽上把统一字号抬回去', (tester) async {
// 这条是量出问题后的回归:`310.00 Mb/s` 宽 440px 而格子只有 ~182px,
// 会把统一比例拖到 0.41,一屏数值全被它压小。
const base = TextStyle(fontSize: 40);
final long = measureEinkText('310.00 Mb/s', base).width;
final short = measureEinkText(compactEinkValue('310.00 Mb/s'), base).width;
expect(short, lessThan(long * 0.9),
reason: '紧凑化应显著缩短宽度($long → $short)');
});
testWidgets('缩放算法:比例由**全屏最长**那条决定,且只缩不放', (tester) async {
const base = TextStyle(fontSize: 40);
expect(
einkUniformValueScale(
texts: const [], maxWidth: 100, maxHeight: 100, base: base),
1.0);
expect(
einkUniformValueScale(
texts: const ['1.0'], maxWidth: 1000, maxHeight: 1000, base: base),
1.0,
reason: '空间充裕也不放大 —— 否则短值与长值的相对关系失衡');
final s = einkUniformValueScale(
texts: const ['24.8%', '310 kb/s', '1.42 Mb/s'],
maxWidth: 60,
maxHeight: 100,
base: base,
);
expect(s, lessThan(1.0));
final longest = measureEinkText('310 kb/s', base).width;
expect(longest * s, lessThanOrEqualTo(60.5),
reason: '按最长那条算的比例必须让最长值放得下');
expect(
einkUniformValueScale(
texts: const ['x'], maxWidth: 0, maxHeight: 10, base: base),
1.0);
expect(
einkUniformValueScale(
texts: const ['x'], maxWidth: 10, maxHeight: -5, base: base),
1.0);
});
});
// ══════════════════════════════════════════════════════════════
group('⑥ 设备自检面板(adb 不通时的取数通道)', () {
testWidgets('★ 读出逻辑尺寸 / 像素比 / 由尺寸推出的列数', (tester) async {
// 这条是给 Neo2 用的:USB 调试开不了 → 拿不到 wm size/density,
// 于是把 MediaQuery 的真实值直接画在屏上,一张截图就能取数。
await mount(tester, size: _portrait, showDiagnostics: true);
await tester.pumpAndSettle();
expect(find.text('536 × 724 dp'), findsOneWidget); // 窗口(物理)
expect(find.text('1.000'), findsOneWidget); // devicePixelRatio
expect(find.text('536 × 724'), findsWidgets); // 物理像素(dpr=1)
expect(find.text('2 列'), findsOneWidget);
// 未旋转时"窗口"与"布局"必须一致 —— 一旦不一致就说明在用应用内旋转
expect(find.text('窗口(物理)'), findsOneWidget);
expect(find.text('布局(看板用)'), findsOneWidget);
});
testWidgets('横屏时自检页报 3 列', (tester) async {
await mount(tester, size: _landscape, showDiagnostics: true);
await tester.pumpAndSettle();
expect(find.text('3 列'), findsOneWidget);
});
testWidgets('★ 应用内旋转:竖屏窗口里也排出 3 列(RotatedBox 真的交换了约束)',
(tester) async {
// 这是"系统不理会转屏请求"时的兜底机制,必须证明它真有效 ——
// 把看板放进 RotatedBox(quarterTurns: 1),在 536×724 的**竖屏**窗口里,
// 看板应拿到 724×536 的约束 → 按宽高比走 3 列。
//
// 为什么这条断言足够:`einkGridColumns` 只看宽高比,
// 所以"能排出 3 列"就等价于"看板确实拿到了横屏约束"。
tester.view.physicalSize = _portrait;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(
home: RotatedBox(
quarterTurns: 1,
child: EinkBoard(
deviceName: 'MacBook Pro · M3 Max', statusKey: 'online', agoText: 'x',
frame: _realFrame, hiddenKeys: const {}, formatter: MetricFormatter(),
lastRefreshText: '20:18:52', refreshNote: '每 10 秒', onRefresh: () {},
showDiagnostics: true, // 靠自检页读「约束」——见下面的说明
),
),
));
await tester.pumpAndSettle();
// ⚠️ 这里**不能用 `columnsOf()`**(它按屏幕 y 坐标分行):
// RotatedBox 之后 `getTopLeft` 返回的是**旋转后的屏幕坐标**,
// 原本同一行的格子变成了同一列 —— 度量方法本身失效,不是功能失效。
// 改用它真正依赖的东西:**看板拿到的约束**。
// 「布局(看板用)」行与「看板列数」行都由 LayoutBuilder 的约束算出,
// 所以它们能直接证明"约束确实被交换了"。
// 两个事实合起来才构成证明(自检页正好把它们并排放着):
// · 窗口(物理)仍是 536 × 724 —— **竖的**,说明系统确实没转
// · 看板列数是 3 —— 只有拿到"宽 > 高"的约束才可能排 3 列
// 一竖一横并存 ⇒ RotatedBox 真的把约束交换了,应用内旋转这条路成立。
expect(find.text('536 × 724 dp'), findsOneWidget,
reason: '窗口应当是竖的(这台设备系统不转屏),否则本用例测不到点子上');
expect(find.text('3 列'), findsOneWidget,
reason: '窗口是竖的却排出了 3 列 ⇒ 约束确实被交换 —— '
'否则应用内旋转就是假的,系统不支持转屏时用户就真的没救了');
});
testWidgets('宿主提供的诊断行照原样列出(图标走独立槽位,不拼进文本)', (tester) async {
await mount(tester,
showDiagnostics: true,
diagnostics: const [
('刷新间隔', '10 秒', null),
('常驻内存', '42 MB', null),
('对端内网', '走内网 · 192.168.1.5:8770', Icons.check_circle),
]);
await tester.pumpAndSettle();
expect(find.text('刷新间隔'), findsOneWidget);
expect(find.text('10 秒'), findsOneWidget);
expect(find.text('常驻内存'), findsOneWidget);
expect(find.text('42 MB'), findsOneWidget);
// 图标必须是**真正的 Icon**:拼进文本就又回到"用字符当符号"的老路,
// 而字符符号在水墨屏上渲染成方框
expect(find.byIcon(Icons.check_circle), findsOneWidget);
expect(find.textContaining('✓'), findsNothing);
});
testWidgets('自检面板打开时**不显示数值网格**(一屏只放一件事)',
(tester) async {
await mount(tester, showDiagnostics: true);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('eink-value-cpu_usage')), findsNothing);
});
testWidgets('按钮在「自检 / 返回」之间切换,且只在给了回调时出现',
(tester) async {
await mount(tester);
await tester.pumpAndSettle();
expect(find.text('自检'), findsNothing, reason: '宿主没提供入口时不该出现');
var n = 0;
await mount(tester, onToggleDiagnostics: () => n++);
await tester.pumpAndSettle();
expect(find.text('自检'), findsOneWidget);
await tester.tap(find.text('自检'));
await tester.pumpAndSettle();
expect(n, 1);
await mount(tester, showDiagnostics: true, onToggleDiagnostics: () {});
await tester.pumpAndSettle();
expect(find.text('返回'), findsOneWidget);
});
testWidgets('自检页同样是静止的(无转圈、pumpAndSettle 能收敛)',
(tester) async {
await mount(tester, showDiagnostics: true);
await tester.pumpAndSettle();
expect(find.byType(CircularProgressIndicator), findsNothing);
});
testWidgets('自检页在多尺寸下不溢出且内容在位', (tester) async {
// 溢出会直接抛 RenderFlex overflowed,故"pumpAndSettle 过得去"就是断言。
// 内容用「逻辑尺寸」这个标签判在场 —— 不依赖底部按钮的文案(那个会随开合变)。
const cases = <String, Size>{
'横屏(6″)': Size(724, 536),
'竖屏(6″)': Size(536, 724),
'矮横屏(F50)': Size(651, 366),
'很窄': Size(380, 700),
};
for (final e in cases.entries) {
await mount(tester, size: e.value, showDiagnostics: true);
await tester.pumpAndSettle();
expect(find.text('布局(看板用)'), findsOneWidget,
reason: '${e.key} 面板内容缺失');
}
});
});
// ══════════════════════════════════════════════════════════════
group('⑤ 诚实边界:读不到就 —,不显示 0', () {
testWidgets('null 值 → —,且标签仍在(不整格消失)', (tester) async {
await mount(tester, frame: {'cpu_usage': 23.5, 'mem_usage': null});
await tester.pumpAndSettle();
expect(find.text('—'), findsWidgets);
expect(find.byKey(const ValueKey('eink-label-cpu_usage')), findsOneWidget);
expect(find.byKey(const ValueKey('eink-label-mem_usage')), findsOneWidget);
});
testWidgets('点「刷新」触发回调', (tester) async {
var n = 0;
await mount(tester, onRefresh: () => n++);
await tester.pumpAndSettle();
await tester.tap(find.text('刷新'));
await tester.pumpAndSettle();
expect(n, 1);
});
testWidgets('「退出」只在给了回调时出现', (tester) async {
await mount(tester);
await tester.pumpAndSettle();
expect(find.text('退出'), findsNothing);
await mount(tester, onExit: () {});
await tester.pumpAndSettle();
expect(find.text('退出'), findsOneWidget);
});
});
}
/// 读源码(相对 client 目录,测试运行时 cwd 就是 client)
String fileText(String p) => File(p).readAsStringSync();
/// 只取**代码**,剔掉 `//` 与 `///` 注释行。
///
/// ⚠️ 为什么必须这么做(第二次踩):注释里会**合法地**提到
/// `CircularProgressIndicator` / `fontSize:` 之类 —— 比如模块开头那句
/// "转圈与呼吸会持续触发局部刷新"。直接 `contains` 会被自己的说明文字绊倒。
String codeOnly(String src) => src
.split('\n')
.where((l) => !l.trimLeft().startsWith('//'))
.join('\n');
+108
View File
@@ -0,0 +1,108 @@
/// 埋点上报出口的离线断言(M5)。
///
/// 这些断言守的不是"能发出去",而是**三条不许退化的性质**:
/// ① 失败不抛(AC-22 的客户端侧)—— 埋点绝不能让用户看到错;
/// ② 失败不重试 —— 补报的时序已经错了,重试只会把一次抖动放大成持续流量;
/// ③ 队列有界 —— 离线很久的进程不能攒出一个巨大队列再一次性打出去。
///
/// 另有一条**容易写错**的:冲刷要**先抽干再发**。否则发送期间 `track` 进来的
/// 新事件会被这一批的失败连坐丢掉 —— 那是"失败一次,丢两批"。
library;
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:inkwatch_client/host/event_reporter.dart';
void main() {
test('① 上报形状:name 在白名单内才入队,未知名字直接丢', () {
final sent = <Map<String, Object?>>[];
final r = EventReporter(post: (path, body) async {
sent.add({'path': path, 'body': body});
});
r.track('app_open', props: {'platform': 'android'});
r.track('evil_event'); // 白名单外
r.track('first_frame_shown');
expect(r.pendingCount, 2, reason: '未知名字不该进队列');
});
test('② 冲刷一次发一批,路径与结构正确,发完队列清空', () async {
final calls = <(String, Map<String, Object?>)>[];
final r = EventReporter(post: (path, body) async => calls.add((path, body)));
r.track('app_open', props: {'platform': 'android'});
r.track('upgrade_click', props: {'from': 'quota_banner'});
await r.flush();
expect(calls.length, 1);
expect(calls.first.$1, '/api/events');
final events = calls.first.$2['events'] as List<Object?>;
expect(events.length, 2);
final e0 = events[0]! as Map<String, Object?>;
expect(e0['name'], 'app_open');
expect((e0['props']! as Map)['platform'], 'android');
expect(e0.containsKey('ts'), isTrue, reason: 'ts 由客户端给提示,服务端仍以 now() 为准');
expect(r.pendingCount, 0);
});
test('★ ③ 发送失败不抛、不重试、不退回队列(吞掉即丢)', () async {
var attempts = 0;
final r = EventReporter(post: (path, body) async {
attempts += 1;
throw StateError('网络断了');
});
r.track('app_open');
// 不 try/catch:如果 flush 会把异常冒出去,这一行就会让测试失败
await r.flush();
expect(attempts, 1, reason: '只发一次 —— 重试会把抖动放大成持续流量');
expect(r.pendingCount, 0, reason: '失败即丢,不退回队列');
});
test('★ ④ 队列有界:超上限丢最旧的,保留最近的', () async {
final r = EventReporter(
post: (path, body) async {},
maxQueue: 3,
);
for (var i = 0; i < 6; i++) {
r.track('app_open', props: {'i': i});
}
expect(r.pendingCount, 3, reason: '上限 3,不能长成 6');
});
test('★ ⑤ 先抽干再发:发送期间新入队的事件属于下一批,不被这次失败连坐', () async {
final gate = Completer<void>();
final batches = <int>[];
final r = EventReporter(post: (path, body) async {
final n = (body['events']! as List<Object?>).length;
batches.add(n);
if (batches.length == 1) {
await gate.future; // 第一批发到一半
throw StateError('第一批失败');
}
});
r.track('app_open');
final flushing = r.flush(); // 第一批开始发(挂住)
r.track('upgrade_click'); // 发送期间进来 → 属于第二批
gate.complete();
await flushing;
expect(batches.first, 1, reason: '第一批只该有 1 条');
expect(r.pendingCount, 1, reason: '发送期间进的 1 条必须还在队列里');
});
test('⑥ 白名单与服务端同向:5 个名字逐字一致', () {
expect(kEventNames, [
'app_open',
'first_frame_shown',
'quota_blocked',
'upgrade_click',
'upgrade_requested',
]);
expect(kEventBatchMax, 20, reason: '与服务端 MAX_BATCH 同一个数');
});
}
+87
View File
@@ -0,0 +1,87 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:inkwatch_client/link/handover.dart';
import 'package:inkwatch_client/link/link_mode.dart';
/// 切换状态机契约。
///
/// 锁定的性质:
/// ① 唯一合法路径 idle→decided→proving→overlapping→retiring→done;
/// 跳跃/倒序一律忽略(宁可停在原相位,也不做半截切换);
/// ② 证通即进入重叠期(旧路先不退),承载后才退役——这是"不丢帧"的时序;
/// ③ abort 后旧路保持,原因可读;
/// ④ isStalled 只判需要外部推进的三个相位;
/// ⑤ 同一实例重复 start 不重启(防抖)。
void main() {
Handover mk({LinkMode? from = LinkMode.cloud}) =>
Handover(from: from, to: LinkMode.lan);
test('① 正常路径:六相位按序走完', () {
final h = mk();
expect(h.phase, HandoverPhase.idle);
h.start(0);
expect(h.phase, HandoverPhase.decided);
h.markProven(10);
expect(h.phase, HandoverPhase.overlapping,
reason: '证通即刻进重叠期(旧路先不退)');
h.markCarrying(20);
expect(h.phase, HandoverPhase.retiring);
h.markRetired(30);
expect(h.phase, HandoverPhase.done);
expect(h.active, isFalse);
expect(h.describe(), contains('完成'));
});
test('② 跳跃/倒序一律忽略', () {
final h = mk();
// 没 start 就 markProven → 不动
h.markProven(1);
expect(h.phase, HandoverPhase.idle);
h.start(1);
// decided 阶段直接 markRetired → 不动
h.markRetired(2);
expect(h.phase, HandoverPhase.decided);
h.markProven(3);
// overlapping 阶段重复 markProven → 不动
h.markProven(4);
expect(h.phase, HandoverPhase.overlapping);
});
test('③ abort:旧路保持,原因可读', () {
final h = mk();
h.start(0);
h.abort(5, '内网探测失败');
expect(h.phase, HandoverPhase.aborted);
expect(h.abortReason, '内网探测失败');
expect(h.describe(), contains('中止'));
expect(h.active, isFalse);
// 已中止后不再接受推进
h.markProven(10);
expect(h.phase, HandoverPhase.aborted);
});
test('④ isStalled 只判 proving/overlapping/retiring', () {
final h = Handover(from: null, to: LinkMode.lan, stallMs: 100);
h.start(0);
expect(h.isStalled(200), isFalse, reason: 'decided 不是卡住');
h.markProven(10); // → overlapping
expect(h.isStalled(50), isFalse, reason: '未超时');
expect(h.isStalled(200), isTrue, reason: '重叠期超时=卡住');
});
test('⑤ 重复 start 不重启(幂等,防抖)', () {
final h = mk();
h.start(0);
h.markProven(10);
h.start(20); // 已在进行中,不该回到 decided
expect(h.phase, HandoverPhase.overlapping);
});
test('⑥ describe 覆盖首切(from=null)与各相位', () {
final h = Handover(from: null, to: LinkMode.p2p);
expect(h.describe(), contains('无→P2P'));
h.start(0);
expect(h.describe(), contains('已决定'));
h.markProven(1);
expect(h.describe(), contains('双路重叠'));
});
}
+1 -1
View File
@@ -9,7 +9,7 @@
library;
import 'package:flutter_test/flutter_test.dart';
import 'package:pmon_client/host/history_api.dart';
import 'package:inkwatch_client/host/history_api.dart';
String _json(Map<String, dynamic> m) {
final parts = m.entries.map((e) {
+23 -6
View File
@@ -1,4 +1,4 @@
/// Dart 侧 FFI 冒烟测试 —— 真实加载 libpmon_core 并调用 C ABI
/// Dart 侧 FFI 冒烟测试 —— 真实加载 libinkwatch_core 并调用 C ABI
///
/// 与 Python 的 `tests/ffi-smoke.py` 分工不同:
/// · ffi-smoke.py 验证 **Rust 导出的契约**(符号齐全、空指针安全、JSON 形状)
@@ -13,12 +13,12 @@ import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:pmon_client/host/pmon_core.dart';
import 'package:inkwatch_client/host/inkwatch_core.dart';
int nowMs() => DateTime.now().millisecondsSinceEpoch;
void main() {
final core = PmonCore.instance;
final core = InkwatchCore.instance;
group('核心库加载与自省', () {
test('平台标识合法', () {
@@ -150,8 +150,25 @@ void main() {
for (final d in delays) {
expect(d > 0 && d <= 60000, isTrue, reason: '退避必须封顶: $d');
}
// 抖动是**向下取**(delay = capped - offset,offset 最大 30% × cap),见
// rust-core/src/transport.rs 的 backoff_grows_and_is_capped_across_repeated_failures
// 与 capped_backoff_dips_within_jitter_band:封顶后 capped 恒为 60s,样本落在
// [cap-span, cap],**后一个样本可能小于前一个**(实测出现过 57414 → 57413)。
// 故此处不能断言全程单调不减:
// · 未进入抖动带之前,相邻档位翻倍、抖动最多削掉 30%,必然递增;
// · 一旦落入 [cap-span, cap],只保证不跌破抖动带下界。
const cap = 60000;
const span = 18000; // cap × jitter_ratio(0.3),与 Rust 侧 policy 同源
for (var i = 1; i < delays.length; i++) {
expect(delays[i] >= delays[i - 1], isTrue, reason: '退避单调不减: $delays');
if (delays[i - 1] < cap - span) {
// 未进入抖动带:档位翻倍、抖动最多削掉 30%,必然递增
expect(delays[i] >= delays[i - 1],
isTrue, reason: '未封顶时必须递增: $delays');
} else {
// 已封顶:capped 恒 60s,只保证不跌破抖动带下界(可回退)
expect(delays[i] >= cap - span,
isTrue, reason: '封顶后不得跌破抖动带下界: $delays');
}
}
// 连上即归零
t.onConnected(now);
@@ -226,7 +243,7 @@ void main() {
group('保活契约', () {
test('自启计划与本机平台匹配', () {
final plan = core.planAutostart(exePath: '/tmp/pmon-test', label: 'com.pmon.test');
final plan = core.planAutostart(exePath: '/tmp/inkwatch-test', label: 'com.inkwatch.test');
if (core.platformName == 'ios') {
expect(plan['error'], isNotNull);
} else {
@@ -239,7 +256,7 @@ void main() {
test('macOS 计划含 LaunchAgent 与"仅崩溃重启"语义', () {
if (!Platform.isMacOS) return;
final plan = core.planAutostart(exePath: '/tmp/pmon-test', label: 'com.pmon.test');
final plan = core.planAutostart(exePath: '/tmp/inkwatch-test', label: 'com.inkwatch.test');
final text = jsonEncode(plan);
expect(text, contains('LaunchAgents'));
expect(text, contains('SuccessfulExit'));
+1 -1
View File
@@ -13,7 +13,7 @@
library;
import 'package:flutter_test/flutter_test.dart';
import 'package:pmon_client/host/link.dart';
import 'package:inkwatch_client/host/link.dart';
void main() {
group('采样分派:必须走空闲队列', () {
Loaded 100 of 251 files, more files were not shown because too many files have changed in this diff. Show more