Compare commits
150
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8026e82a05 | ||
|
|
f6e896d27a | ||
|
|
90a16357d1 | ||
|
|
c1a4ccbfa3 | ||
|
|
1c41ec8574 | ||
|
|
afa20b431a | ||
|
|
25861a5c0c | ||
|
|
a9bdee363d | ||
|
|
e2848ca672 | ||
|
|
71efe04e32 | ||
|
|
f3ec3093c4 | ||
|
|
5ba371380f | ||
|
|
30280f3b75 | ||
|
|
21dae59276 | ||
|
|
49dacb57a6 | ||
|
|
48fef36b82 | ||
|
|
1d76c3b8ac | ||
|
|
89260024ee | ||
|
|
4194c6ee03 | ||
|
|
24746795bf | ||
|
|
38c3d8ffea | ||
|
|
3716747f01 | ||
|
|
0d332f6e9a | ||
|
|
61f044a752 | ||
|
|
088c9b34a0 | ||
|
|
7f8e02d4ad | ||
|
|
5ba022f598 | ||
|
|
7c9c2f0605 | ||
|
|
5f419bcb91 | ||
|
|
cc0aaffffd | ||
|
|
e7481a43d9 | ||
|
|
f4e24c2c95 | ||
|
|
b3c16fb1eb | ||
|
|
0bc21f29af | ||
|
|
29672c98b6 | ||
|
|
3cc67d3725 | ||
|
|
bd39799ab0 | ||
|
|
bc6e5f405f | ||
|
|
184f566fe3 | ||
|
|
6c402702df | ||
|
|
0f57186b46 | ||
|
|
4cca14d1b9 | ||
|
|
f03bf7e19a | ||
|
|
a825fc366e | ||
|
|
19f8ee181a | ||
|
|
269a1d32b1 | ||
|
|
1a10e4a04a | ||
|
|
c92d870c8e | ||
|
|
930c35957b | ||
|
|
c806c26537 | ||
|
|
26b939c239 | ||
|
|
4e3b4739d4 | ||
|
|
3af5c592ff | ||
|
|
d0d2d717a4 | ||
|
|
5a909f14fd | ||
|
|
e6d1438864 | ||
|
|
3b5007c1f6 | ||
|
|
b11def8167 | ||
|
|
9db09bb923 | ||
|
|
d7147e67a7 | ||
|
|
24e79b06a6 | ||
|
|
cb82678cdd | ||
|
|
2574011e5a | ||
|
|
d983cc5d7c | ||
|
|
166963f994 | ||
|
|
edaf52b06d | ||
|
|
1711425b08 | ||
|
|
d296701468 | ||
|
|
f543739b8d | ||
|
|
748785bb8e | ||
|
|
7c11ec490b | ||
|
|
e9c02c981e | ||
|
|
14395692e4 | ||
|
|
edc1053278 | ||
|
|
03b30c1534 | ||
|
|
cffdc50df2 | ||
|
|
ca5c8014f3 | ||
|
|
53279ea256 | ||
|
|
b380c88b06 | ||
|
|
a5e9f090b5 | ||
|
|
e591c6579d | ||
|
|
33193aa03a | ||
|
|
f8b5cb2684 | ||
|
|
12433ccedf | ||
|
|
ca47186615 | ||
|
|
4170838e45 | ||
|
|
de609af2a8 | ||
|
|
50f4abb1d4 | ||
|
|
e8e6fb3ec1 | ||
|
|
46b5b3b050 | ||
|
|
c060efa4fe | ||
|
|
1238e5d530 | ||
|
|
42ad9ffae5 | ||
|
|
29c4a9c846 | ||
|
|
21b1a561a3 | ||
|
|
35ee33ef13 | ||
|
|
886eb61af9 | ||
|
|
16879e0a56 | ||
|
|
28d53611e6 | ||
|
|
878aa50bb5 | ||
|
|
7bd123ac8e | ||
|
|
2d04728b91 | ||
|
|
0d72f13235 | ||
|
|
553ddef497 | ||
|
|
ddf890ed47 | ||
|
|
9f5abdc74d | ||
|
|
2f7f85624f | ||
|
|
247f5c6491 | ||
|
|
49a9537f2c | ||
|
|
ff070e8b7f | ||
|
|
5f382fbca8 | ||
|
|
e48f9336d9 | ||
|
|
419da432eb | ||
|
|
6ade1320db | ||
|
|
84ac9ddce3 | ||
|
|
fcd7d24803 | ||
|
|
977067c114 | ||
|
|
723852e1da | ||
|
|
10aa9f4119 | ||
|
|
3104aeda8e | ||
|
|
49b76035d8 | ||
|
|
a5b0d3f3f9 | ||
|
|
52cc4bf4d6 | ||
|
|
99deda3980 | ||
|
|
408940b282 | ||
|
|
81607cca5e | ||
|
|
ba41b5ec5c | ||
|
|
b19a8bf0a9 | ||
|
|
b5f33ef485 | ||
|
|
9e63636511 | ||
|
|
b19fbae68b | ||
|
|
e370292074 | ||
|
|
d48d56e4c1 | ||
|
|
417a3ab78b | ||
|
|
6ccd19d771 | ||
|
|
ca678a9bb0 | ||
|
|
b32b1784f6 | ||
|
|
70fb3e0dd1 | ||
|
|
6f314b69a0 | ||
|
|
273b398fa7 | ||
|
|
0777e55cf5 | ||
|
|
158ae7577e | ||
|
|
8520f13741 | ||
|
|
f289b11f48 | ||
|
|
c3eb36b8f3 | ||
|
|
55c027da58 | ||
|
|
e0bd64fe81 | ||
|
|
427994853c | ||
|
|
5a860cd555 | ||
|
|
b585d780e0 |
No files matched your search
+113
-4
@@ -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=
|
||||
@@ -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 "一次性密钥材料已删除"
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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!** |
|
||||
|
||||
@@ -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">
|
||||
|
||||
+5
-5
@@ -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}")
|
||||
+28
-7
@@ -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))
|
||||
+11
-11
@@ -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
|
||||
|
||||
+7
-7
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 包,减少客户端依赖面)
|
||||
@@ -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 = '''
|
||||
墨观 查看端验证工具
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
/// 配置 → 诊断文本(**纯函数**,便于离线断言"凭据不泄漏")。
|
||||
///
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 指向该文件。',
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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() {
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 帧窗口,图表将显示断点)');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 共用。
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/// 查看端:提交配对码,换取该设备的查看授权
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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] ?? '未知';
|
||||
@@ -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 (_) {
|
||||
// 静默
|
||||
}
|
||||
}
|
||||
}
|
||||
+667
-155
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('退出')),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
),
|
||||
);
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
+1047
-308
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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+72
@@ -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\""
|
||||
Executable
+111
@@ -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 候选链与既有文档)"
|
||||
@@ -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/^/ /'
|
||||
Executable
+105
@@ -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
|
||||
@@ -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 \
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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('基本换算与渲染', () {
|
||||
|
||||
@@ -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 同目录 —— '
|
||||
'本项目"同一份路径写两处"已翻车多次,安装路径只能有一处派生',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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: '若删掉它,"为什么这次没拦"就永久失去线索');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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)));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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');
|
||||
@@ -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 同一个数');
|
||||
});
|
||||
}
|
||||
@@ -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('双路重叠'));
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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
Reference in new issue
Block a user