PMon Dev
|
1d76c3b8ac
|
feat(design-system): 合入 V2 线并统一命名为 Inkwatch,补齐深色主题与状态符号图标化
Design System Guard / guard (push) Failing after 7m11s
本提交收口本轮四件事。三者改动在同一批文件里交织(改名触及 theme.dart、
深色化也在 theme.dart),按文件无法干净切分,故合为一个可审计的提交,
下面按事由分述。
一、命名统一:Pmon* → Inkwatch*(合入 V2 线后的收尾)
· 包名 pmon_client → inkwatch_client,Rust crate pmon-core → inkwatch-core。
改名后必须 flutter pub get 重建 package_config,否则 dart analyze 会报
1688 个"找不到 package:pmon_client"的假错误。
· 符号:PmonColors→InkwatchColors、PmonPalette→InkwatchPalette、
PmonTextStyles→InkwatchTextStyles、PmonRadius→InkwatchRadius、
PmonEinkText→InkwatchEinkText、PMON_LOG_*→INKWATCH_LOG_*。
· 覆盖 server/ client/ rust-core/ tests/ 全树;已用
git grep -In 'Pmon\|PMON\|pmon' 复核,零残留(tokens.css 里最后一处
§2.8 的类名注释也在本提交修掉)。
· rust-core/Cargo.lock 的变动只是包名变化导致 [[package]] 字典序重排。
二、深色主题:58 键同名覆写 + Flutter 整屏深色化
· tokens.css 增 dark 块(58 键),键名与浅色**逐一相同**——
这是 token_parity_test.dart 的深色 parity 组在守的约束。
· theme.dart:新增 InkwatchDarkColors / _assemble(palette, brightness),
把语义色从"常量"改为"随 brightness 装配"。
· main.dart:darkTheme + 显式 themeMode(ValueNotifier 持久化,不用
ThemeMode.system —— 水墨屏用户要的是自己定,不是跟系统)。
· theme_guard_test.dart C-15 → C-15′:从"禁止出现深色"改为"允许深色,
但必须 58 键同名",护栏跟着设计意图一起改,而不是删掉。
· 新增 dark_theme_test.dart / dark_ui_test.dart 覆盖装配与整屏渲染。
三、状态符号图标化:Unicode 字形 → Material Icons
· symbol_guard_test.dart 按**码位**扫 lib/(注释之外),命中 5 处:
transfer_card.dart 的 ⚠️/✓/✓/⚠,viewer_page.dart 的 ✓。
· 只删字形会留下不可见的 U+FE0F,源码看不出、界面渲染成豆腐块,
且按 emoji 区间匹配的旧护栏看不见它 —— 故护栏按码位判,且判据选
"扫注释之外全部字符"(错了只会多报,不会漏)。
· 改法:状态图标单独一槽,不拼进文案。
- transfer_card.dart:_offerHint 由 String? 改为
({IconData? icon, Color? tint, String text})?,渲染为 Row(Icon+Text);
「已收下」结果块同理(check_circle / error_outline)。
图标着色:warning_amber→_p.suspect,check_circle→_p.online。
- viewer_page.dart:_lanSummary() 返回 ({IconData? icon, String text}),
诊断行由 ('对端内网', _lanSummary(), null) 改为 (…, lan.text, lan.icon)。
调用点提到 return 之前只算一次。
· 理由不只是"好看":字符符号在水墨屏上会渲染成方框,且无法随主题着色。
验证
· client/scripts/test.sh:674 项全过(+674,0 失败,23s)
· tests/run-guards.sh(RUN_AXE=1):
web-token-guard.js PASS 21/0
phase3b-static-guard.js PASS 87/0
dockerfile-coverage-test.js PASS 53/0
schema-consistency-test.js PASS 116/0
client Dart 全量 PASS 674 项
admin-axe-audit.mjs PASS 42/42 格 viol=0(inc=12 有台账)
6 套件 PASS 6 / FAIL 0 / SKIPPED 0
· dart analyze:9 issues / 0 error(全部为既有 info/warning,非本次引入)
· 两处 tokens.css(仓库内 admin/assets 与设计系统交付目录)逐字节一致
sha256 088873cf6ece8da78135f96ca4633a537ac7802ea5f0fc8bd9c9294bb8a7bcaa
已知未做(写下来,不藏)
· flutter analyze 在本机因仓库路径含中文(水墨屏工具)触发 analysis_server
的 LSP 分帧 UTF-8 错误(FormatException: Unterminated string)。
绕法是用 dart analyze,或把工作树放到纯 ASCII 路径下。
· 窄屏降级态 + 失败态的组合真机复核:仍未做。
· VoiceOver / NVDA 实机验证:NOT RUN。
· 管理台登录框仍缺持久 <label>(现靠 aria-labelledby 指向标题)。
|
2026-11-09 17:24:56 +08:00 |
|
PMon Dev
|
89260024ee
|
merge(mtfyun/main): 合入 V2 线(Pmon→Inkwatch 改名与 V2 功能),冲突保留本地设计系统
# Conflicts:
# client/lib/main.dart
# client/lib/ui/account_card.dart
# client/lib/ui/device_members_card.dart
# client/lib/ui/eink_board.dart
# client/lib/ui/eink_mode.dart
# client/lib/ui/pairing_card.dart
# client/lib/ui/resident_card.dart
# client/lib/ui/skeleton.dart
# client/lib/ui/theme.dart
# client/lib/ui/transfer_card.dart
# client/lib/ui/viewer_chart.dart
# client/lib/ui/viewer_page.dart
# client/test/theme_guard_test.dart
|
2026-11-09 17:07:05 +08:00 |
|
PMon Dev
|
4194c6ee03
|
test(transport): 修正退避单调断言——jitter 向下取,封顶后允许回退
Design System Guard / guard (push) Failing after 21m51s
原断言要求退避序列全程单调不减,与实现冲突:rust-core/src/transport.rs 的抖动是向下取(delay = capped - offset,offset 最大 30%×cap),封顶后 capped 恒为 60s、样本落在 [cap-span, cap],后一个样本可能小于前一个(实测出现 57414 → 57413)。Rust 侧已有 capped_backoff_dips_within_jitter_band 断言同一语义。改为分档断言:未进入抖动带时要求递增(档位翻倍、抖动最多削 30%,必然成立);已进入 [42000, 60000] 时只要求不跌破下界。修正后 host_ffi_test 连续 20 次 20/20 通过(修正前 20 次全红,验证了断言的边界写法)。
|
2026-11-08 23:39:18 +08:00 |
|
PMon Dev
|
24746795bf
|
ci(a11y): 管理台 axe 42 格扫描入库并接入护栏(RUN_AXE 可选)
把交付目录的管理台扫描迁入源仓库 tests/admin-axe-audit.mjs(自包含:Node 内置 http 静态服务 + 浏览器自动探测 + CDP 注入 axe-core),矩阵与口径零放宽(3 视口 × 2 皮肤 × 6 页 + E-Ink 6 格 = 42 格,禁用规则 0 条,incomplete 不记 PASS)。run-guards.sh 增第 6 套件,默认不跑以保住 13s 快跑路径,RUN_AXE=1 才执行,未设时汇总表显式标 SKIPPED (RUN_AXE not set) 并打印原因与启用命令。workflow 支持 workflow_dispatch 输入与仓库变量 IW_RUN_AXE 两种启用方式。结果 JSON 与截图只写 /tmp,不入库。本地实跑:默认 PASS 5 / SKIPPED 1 / 13s / exit 0;RUN_AXE=1 后 PASS 6 / 0 / 60s / exit 0(管理台 42/42 格 violations=0、incomplete=12 均为 mobile-375 几何遮挡)。
|
2026-11-08 23:39:18 +08:00 |
|
PMon Dev
|
38c3d8ffea
|
feat(ui): Flutter 整屏页面级深色化(ThemeExtension<PmonPalette>)
theme.dart 新增 PmonPalette(24 语义色 + copyWith/lerp + light/dark/eink 三实例)与 PmonTextStyles 集中出口;statusStyle / PmonCard 改为接收色板(缺省回退 light 保住旧调用);三主题均挂 extensions。11 个消费文件改从 PmonPalette.of(context) 取色,不再直接引用静态 PmonColors/PmonText;含 const 去化以支持运行时色板(不改颜色/尺寸/布局)。E-Ink 挂 PmonPalette.eink(PmonEink 六色,R=G=B 无彩色、圆角 0)。新增 dark_ui_test.dart(13 项)并登记 test.sh。深色 tertiary 保持 #8A93A3;浅色 18 原色与 Core 45 项未动。验证:Dart 全量 +640、run-guards 5/5、dart analyze 9 issues 0 error。
|
2026-11-08 22:27:09 +08:00 |
|
PMon Dev
|
3716747f01
|
fix(a11y): 管理台登录遮罩补 dialog 语义,region 违规 42 格归零
6 个 admin 静态页的 #auth-overlay 补 role="dialog" aria-modal="true" aria-labelledby="auth-title",遮罩内 h1 补 id="auth-title"。依据 axe-core region 规则 regionMatcher 接受 [role=dialog];管理台 42 格 axe 复扫 violations 42→0(126 节点→0),PASS 格 0→42。未改 admin.css / admin.js / tokens.css。
|
2026-11-08 22:27:09 +08:00 |
|
PMon Dev
|
0d332f6e9a
|
feat(ui): Flutter 正式深色皮肤(ThemeData 层)与显式偏好装配
- theme.dart 新增 PmonDarkColors:转录 CSS dark 块 30 键到 26 个常量
(含两处反相 text-on-accent #0B0E14 / snackbar-bg #F2F4F9),
28 个 Web 专属键登记 kDarkExemptTokens 豁免;几何/排印/动效/端侧派生/
E-Ink 六色不进 Dart,沿用 PmonSpace/PmonRadius/PmonText,不新增刻度
- buildPmonTheme() 重构为 _assemble(palette, brightness),
buildPmonDarkTheme() 复用同一控件装配,只在 theme.dart 内 new ThemeData;
浅色分支 fromSeed 逐字段未变,深色分支 copyWith 使 colorScheme.primary
严格等于 #6E9CFF
- main.dart 装 darkTheme + themeMode(顶层 ValueNotifier,默认 light,
不用 ThemeMode.system),采集页加最小切换入口
- AppConfig.themeMode 持久化(零依赖 JSON,值域 light|dark,默认不预写键)
- 护栏:theme_guard C-15 改为 C-15'(允许深色但要求 58 键同名、
不污染 E-Ink、不新增刻度、浅色 18 原色未动);token_parity 新增深色
parity 组(覆盖 58 / 转录逐项相等 / 反向缺项 / 豁免缺席);
新增 client/test/dark_theme_test.dart 并登记进 scripts/test.sh
关键约束:深色 tertiary 保持 #8A93A3,显式不同步浅色点修值 #646D7D;
浅色 18 品牌原色与 Core 45 项一个不动;E-Ink 仍纯黑白 / 圆角 0 / 无动画,
看板以 Theme(data: einkTheme()) 局部覆盖,与 themeMode 解耦。
验证:Dart 全量 +627 All tests passed(基线 610,新增 17)、
定向 35/35、run-guards.sh 5 套件 PASS 5 / FAIL 0、dart analyze 9 issues
(2 warning + 7 info,0 error,与基线一致)。
已知边界:本轮仅 ThemeData 层深色皮肤。页面自绘卡片/文字仍直接引用静态
PmonColors.* / PmonText.* / PmonCard.*(11 / 9 / 4 个文件),不走 ThemeData,
深色下整屏仍呈浅色;整屏页面级深色化需改为 Theme.of(context) 查询,
属独立后续阶段。Web localStorage 与 Flutter AppConfig 为两端独立持久化。
|
2026-11-08 21:16:30 +08:00 |
|
PMon Dev
|
61f044a752
|
merge(main): 解 2026-09 历史双写冲突,8 文件统一保留 feature/eink 实现
# Conflicts:
# client/lib/host/lan_service.dart
# client/lib/host/link.dart
# client/lib/host/viewer.dart
# client/lib/main.dart
# client/lib/ui/transfer_card.dart
# client/lib/ui/viewer_page.dart
# client/test/lan_service_test.dart
# client/test/transfer_card_test.dart
|
2026-11-08 20:52:12 +08:00 |
|
PMon Dev
|
5ba022f598
|
ci(ui): 设计系统双向护栏接入 Gitea Actions
- 新增 .gitea/workflows/design-system-guard.yml:push / pull_request 触发,
checkout + setup-node(22) + flutter-action(stable),执行 run-guards.sh;
经 FLUTTER_BIN 把 flutter 绝对路径传给 client/scripts/test.sh
- 新增 tests/run-guards.sh 统一入口:web-token-guard → phase3b-static-guard
→ dockerfile-coverage → schema-consistency → Dart 全量,任一失败即整体失败
- 新增 tests/web-token-guard.js:源仓库内零外部依赖守卫,只读检查
assets/tokens.css 的 Core 45 项、深色 58 键同名覆写与层叠顺序、
color-scheme: light / 无 prefers-color-scheme、admin.css 零硬编码色值与
4 档圆角、admin.js 主题控制器单一出口
- 新增 tests/README.md:用法、环境变量、前提与降级语义
本地实跑:5 套件 PASS 5 / FAIL 0 / SKIPPED 0,13s,整体退出码 0
(web-token 21/0、static-guard 87/0、dockerfile 53/0、schema 116/0、Dart +610)
降级实跑:SKIP_FLUTTER_TESTS=1 → PASS 4 / FAIL 0 / SKIPPED 1,
汇总表显式标注「SKIPPED (explicit)」,不静默放行
前提:workflow 仅在 Gitea 实例启用 Actions 时运行;
本地无需 Actions 即可用 bash tests/run-guards.sh 完整复现。
本 commit 只新增 CI 文件,未改任何产品代码。
|
2026-11-08 20:21:42 +08:00 |
|
PMon Dev
|
e7481a43d9
|
feat(ui): 墨观 InkWatch 大前端设计系统落地(跨端令牌 + 深色皮肤 + 管理台/Flutter 同步)
将单端 Flutter 浅色令牌升级为覆盖 Web 桌面 / 移动 PWA / macOS 桌面 /
数据大屏的统一 --iw-* 令牌层,并落地到管理台与 Flutter 客户端。
新增
- server/business/admin/assets/tokens.css:Core 45 项 + 四端派生 +
深色 58 个同名键覆写 + E-Ink 独立分支;color-scheme: light 锁定,
深色由 html[data-iw-theme] 显式控制,不跟随系统
- 管理台 6 页引入 tokens.css 与首屏防闪脚本(7 处逐字一致)
- admin.js 主题控制器(inkwatch.theme / storage 跨标签 / E-Ink 互斥)
与两处 ThemeToggle
- client/test/token_parity_test.dart:CSS :root 与 Dart theme.dart
双向护栏,webOnly 白名单,深色键不进 parity
- tests/phase3b-static-guard.js:静态护栏(零硬编码色值 / 键名 / 层叠)
收敛
- admin.css 删除旧 :root 18 变量与全部硬编码色值,全量改为 var(--iw-*);
10px/14px 圆角收敛到 8/16,删除 filter: brightness,
Toast 文字改用 var(--iw-surface) 以适配深色反相
- Flutter:PmonColors 补 6 个 *-text,statusStyle() 扩为 4 字段,
PmonText.micro,刻度收敛(6/10/10.5/11.5px 收敛到 8/11px)
- 浅色辅助文字色 #8A93A3 点修为 #646D7D(WCAG AA:白底 5.22:1 /
page 4.87:1 / muted 4.74:1);--iw-chart-axis 同步副本。
这是 Core 45 项中唯一被点修的取值,18 个品牌原色一个不动;
深色 58 键与 E-Ink 分支显式不同步。依据 08 裁决(皇上批准路径 1)
验证
- Dart 610/610、定向护栏 18/18、静态护栏 87/0、Docker 53/0、Schema 116/0
- 管理台 6 页 x 浅/深 目视 217/0,12 张截图
- 设计系统侧:axe 42/42 格 violations=0、真 1920 视口 A-01~A-20 20/20、
verify 15/15、tsc 0 errors、file:// 直开 exit 0
- 两份 tokens.css sha256 一致,git diff --check 通过
本轮 Flutter 不引入深色 ThemeData;E-Ink 保持纯黑白 / 圆角 0 / 无动画。
|
2026-11-08 19:47:55 +08:00 |
|
PMon Dev
|
4cca14d1b9
|
feat(mail): 零依赖 SMTP 传输层,QQ / 163 可直接用
上一轮把 mail_mode 接通了,但 smtp 分支是 `return delivered:false` ——
也就是说生产上"验证邮箱 / 找回口令"等于不可用。这轮把它补成真的。
## 为什么自己写,不装 nodemailer
需要的只有 EHLO / STARTTLS / AUTH / MAIL / RCPT / DATA,是 RFC 5321 里最稳定的
一层;而为它引入一棵依赖树,等于把**后端进程的出网权限**交给一堆第三方代码,
而这个服务处理的是口令与重置令牌。零依赖(net/tls/crypto)约 600 行,可控。
## 三条硬约束
1. 失败必须说出来:任何一步不是预期码都抛 SmtpError,带 reason / stage /
permanent(4xx 可重试、5xx 不可)。绝不"看起来发了"。
2. 口令不许明文上网:配了 SMTP_USER/PASS 而服务器不给 STARTTLS → 直接拒发
(starttls_unavailable),不降级成明文 AUTH。
3. 口令不进异常/日志:错误消息只由「阶段 + 服务器回复」拼成,AUTH 命令行
(含 base64 口令)永不入日志。测试里有专门一条断言异常不含口令。
## 协议细节里真会咬人的
- 多行回复(250- / 250 ):把半截当完整 → 后续命令与残行错位、莫名 500
- STARTTLS 后必须重新 EHLO:服务商故意只在密文通道公布 AUTH
- DATA 点填充:正文里独占一行的 `.` 会被当结束标记,**报文被截断且服务器回 250**
- 编码:纯 ASCII 走 7bit(可读),非 ASCII 走 base64(不赌 8BITMIME)
- 头折行:200 字中文主题 base64 后 800 字符,会顶到报文行上限
- 头注入:主题里一个 CRLF 就能凭空多出 Bcc:
- 交出 socket 给 TLS 后仍要兜 error 监听,否则未捕获异常带走进程
## QQ / 163 的三处不一样(PROVIDERS 表,只对识别到的主机生效)
- 465 是**隐式 TLS**,不是 587;未显式配置按预设取 465+secure
- **发件人必须等于登录账号**,否则 553 "Mail from must equal authorized user"。
mail_from 是独立配置项,极易填成 noreply@example.com → 现在在**连之前**拦住
(smtp_from_mismatch),文案直接说该改成哪个值
- 口令是**授权码**不是登录密码;535 不含这条信息 → 认证失败时追加提示
(腾讯企业邮不追加,它用真密码,加了就是误导)
- 顺带堵纯配置矛盾:465 + SMTP_SECURE=0 会连上后一直等不到问候、15 秒超时,
现在 normalize 阶段直接报"去掉 SMTP_SECURE 或改 587"
## 真实服务器只读探测(不带任何凭据)
smtp.qq.com:465 → 350ms,AUTH LOGIN PLAIN XOAUTH XOAUTH2,证书过系统 CA
smtp.163.com:465 → 314ms,AUTH LOGIN PLAIN XOAUTH2
说明 465 可达、多行解析在真实回复上正确、两家都公布 AUTH LOGIN。
真实投递需账号本人的授权码,用 tests/smtp-live-test.js(未配凭据时跳过)。
## 测试
- tests/smtp-test.js(92 项):自带假 SMTP 服务器,覆盖 STARTTLS 与 465 隐式 TLS,
证书用 openssl 现场生成(含 IP SAN)。断言落在信封、报文头、编码、点填充,
以及 **AUTH 是在明文还是密文通道上发的**
- 五组变异全部转红:去掉 From 校验 / 去掉 465 守卫 / dotStuff 恒等 /
去掉服务商预设 / 失败也返回 delivered:true
- 教训:断言"连之前就拦住"的用例不能用 sendMail —— 防线一旦被删,测试会真的
去连 smtp.qq.com 试 AUTH(收到 535),把离线断言变成依赖外网。改用纯函数
normalize() 后同样抓得到变异,且永不联网
- tests/smtp-live-test.js:有凭据就真发一封(默认发给账号自己),没凭据 [skip]
## 其它
- 启动自检把"用哪个中继、什么形态、有没有明显配错"打在日志里
- 向导页与设置页去掉"smtp 未接入"的旧文案(现在真的会发)
- .env.example / 两份 compose 补 SMTP_*;示例邮箱用占位符,不写成像真人地址
- bash tests/run-all.sh → 39 个套件全绿
|
2026-10-30 16:25:33 +08:00 |
|
PMon Dev
|
f03bf7e19a
|
fix(setup): 把实例配置真正接上线,并补上「最后管理员」漏掉的两个入口
上一轮交付了实例配置表、向导与管理端,但有五个字段只落库没人读 ——
界面上能改、改完不报错、实际什么都不发生。本轮把它们全部接上,
并修掉一处更危险的同源缺陷。
## 同一道闸漏了两个入口(安全)
「不许取消最后一个可用管理员」只守了降权。封禁与自助注销都没守:
- 封禁唯一管理员后,available() 只查 `is_admin`(不看 disabled_at /
deleted_at)→ 管理端继续挂登录页,却没人能登进去;没设 ADMIN_TOKEN
就只能改库救。
- 管理员注销自己同理:账号进冷静期,is_admin 还在。
修法:把「可用管理员」口径收敛到 admin-queries.js 一处
(ACTIVE_ADMIN_WHERE / hasActiveAdmin / isLastActiveAdmin),
available()、降权、封禁、注销四个使用点全部走它。
## 五个字段接线
- mailer:从模块级 `process.env.MAIL_DELIVERY`(启动定死)改为宿主注入
getter,每次发送重新解析 → 管理端改完立刻生效。
顺带消除双名:ENV_MAP 写 MAIL_MODE、mailer 读 MAIL_DELIVERY。旧名保留
(仅当 MAIL_MODE 未设时生效并警告),否则老部署会静默退回 console。
- 库默认 mail_mode 从"实际行为是 outbox"纠正为 console:outbox 会把重置/
验证令牌明文写进服务器磁盘,这个便利不该是默认值。测试改为显式声明。
- public_url / instance_name:邮件落款(唯一真实消费点),解决"收件人分不清
是哪个自建实例发的"。
- free_device_limit:**最严重的一个**。界面写"免费 3 台",配额却一直取
PLAN_TABLE 常量,改成 10 也不生效。新增 planFor() 只覆盖 free 档
(pro/team 是产品定义,不接受运维改),规则只在 permissions.js 一处。
- audit_retention_days:新增 shared/audit-prune.js,每 6 小时按批清理
(ctid 分页 5000/批,make_interval 参数化),留空=永久,删完写一条
audit_pruned 留痕。留痕本身也会被同样对待,符合规则。
## 顺手挖出的三处漂移
- idx_audit_created 只在 init.sql 有、migrate.sql 没有(按 created_at 删除
会全表扫)。已补。
- 两份 compose 透传不一致:RELAY_MAX_BYTES 单容器有、四容器无;
FREE_DEVICE_LIMIT 两边都没有(.env 设了进不了容器)。已补齐。
- user-lifecycle-test 断言配额 3,却依赖开发库里当时的值(被上一轮冒烟改成 5
→ 假红)。已显式声明 FREE_DEVICE_LIMIT=3,并把开发库复位。
## 新增守门:tests/instance-config-wiring-test.js(56 项,纯静态)
每个 instance_config 字段必须在**真正的代码**里被读一次(admin/assets 与
setup/ 不算 —— 出现字段名是展示/提交,不是消费);每个 env 名必须在
.env.example 与两份编排里;扫描器自己也要自证(编个不存在的字段名必须报红)。
它第一次跑就抓到 free_device_limit 的 0 消费方 —— 立刻证明不是空转。
## 界面如实标注
smtp 从未实现,向导页原写"smtp(真实发信,需 .env 配 SMTP)"是谎言,改为
"暂未接入传输层(选了也发不出信)";设置页对 outbox 标"令牌明文落盘"、
对 smtp 标"传输层未接入"。
## 验证
- setup-test:97 → 126 项(新增三入口、发信真实行为、保留期清理与留痕)
- 变异测试 4 组全部转红:封禁闸、注销闸、保留期留空照删、mailer 忽略库配置
- bash tests/run-all.sh → 37 个套件全绿
|
2026-10-30 15:31:53 +08:00 |
|
PMon Dev
|
a825fc366e
|
feat(setup): 实例初始化配置与 Web 安装向导
新装一台实例"归谁管"以前只能靠 .env 里手敲 ADMIN_TOKEN:运维要记住随机串、
没有管理员概念、改任何业务配置都得改文件再重启。本轮把这件事收敛成
「浏览器里走一遍向导 + 配置落库 + 管理端在线可改」。
数据库(deploy/migrate.sql,两条路径共用)
- instance_config 单行表(CHECK id = 1):实例名、对外地址、自助注册、
强制登录、设备令牌、配额、中继上限、邮件、审计保留期
- users.is_admin 列 + 部分索引
- bootstrap_state 三态 pending / adopted / ready,由「库里有没有用户」裁决 ——
存量库升级不能断服,故不是布尔
向导(server/business/setup.js + setup/index.html)
四道护栏是前置条件而非加固项:
1. INSTANCE_SETUP_TOKEN 未设或 <32 字符 → 404(不是 401,不对外宣布它存在)
2. 只认 Authorization: Bearer;失败闸门在令牌校验**之前**(否则猜对就绕过限流)
3. 只能成功一次,由数据库裁决:UPDATE ... WHERE bootstrap_state <> 'ready'
4. 完成后永久 404,判据在库里(内存变量会让重启后复活)
鉴权(server/business/admin.js + admin-queries)
- 管理端双通道:ADMIN_TOKEN(应急/自动化)或库里 is_admin 的会话;
审计记 operator,能区分"用管理员账号改的"和"用令牌改的"
- 新增 /api/admin/admins、账号提权/降权(不许取消最后一个可用管理员 → 409)
- 新增 /api/admin/instance-config:被 env 锁定的字段拒绝修改(409 E_ENV_LOCKED),
不静默成功
配置层次
- 库是真相源,env 是显式覆盖层;GET 返回每个字段的来源(db/env/default)
- 密钥类配置一律不入库(库会被备份/导出/管理端读出)
- 改完同进程立刻生效(onChanged),不等 3 秒轮询
网关(server/gateway)
- **修复半个开关**:业务端的 REQUIRE_AUTH / REQUIRE_DEVICE_TOKEN 搬进库后,
网关若仍只读 env,管理端点开"强制登录"只会关住业务端,订阅通道照样放行匿名。
改由 index.js 每 3 秒把「库 + env 覆盖」推入 gateway.setSwitches(),
网关自己不碰数据库(热路径只读内存字段);/health 报生效值而非 env 字面值
- 单测用假 this 直调原型方法时 this.sw 为空 → 用冻结单例兜底,避免
TypeError 被误报成"会话索引坏了"
测试
- 新增 tests/setup-test.js(97 项):四道闸、参数矩阵、成功路径、
并发(用行锁把两个请求压进临界区,让"输家 409"成为必然而非运气)、
存量库升级(只提权不改口令)
- 它自带 4 个临时库并跑真实 init.sql/migrate.sql —— 顺带把"全新库首次部署"
这条历史上出过建表顺序坑的路径也覆盖了
- dockerfile-coverage:开关扫描补上声明式 ENV_MAP(动态下标扫不到,
覆盖曾从 5 条悄悄掉到 2 条),并按字段名判定真正的消费方
验证:bash tests/run-all.sh → 36 个套件全部通过;六组变异测试逐一证明
关键护栏"删掉即红"。README 增「首次部署:先过一遍初始化向导」。
|
2026-10-30 14:58:00 +08:00 |
|
PMon Dev
|
19f8ee181a
|
test: 配额默认开启后三套测试的车没上(红的是前提,不是功能)
ENFORCE_DEVICE_QUOTA 默认翻成 1 之后,account/device-merge/auth-mode 三套
清一色报 E_QUOTA_EXCEEDED。根因不是配额坏了:这三套的造数方式是"注册 →
认领 2~8 台设备",而「未验证邮箱只能绑 1 台」比档位上限更早生效。
按"谁能表达意图"分工,没有改产品规则:
- account / device-merge:造数前 markEmailVerified(归并另抬到 team 档,它要
造 6~8 台)。配额会把断言挡在更前面 —— 本该 409 的"认领别人的设备"先返回 402。
- auth-mode [D]:对照组原拿宿主机 :4000 顶替,隐含"它一定没开配额"。改为自己
拉一个 ENFORCE_DEVICE_QUOTA=0 的 :4012,并断言它对 /me 报 quota_enforced=false。
顺带校正两处标签漂移(结构性守卫 48→50、镜像覆盖性 45→49),重算全量为
35 套件 / 1965 项 / 0 失败,README 同步。
全量实跑:35 通过 / 0 失败;隐私扫描通过。
|
2026-10-30 13:42:46 +08:00 |
|
PMon Dev
|
269a1d32b1
|
fix(admin): 在线数改 SCAN 计数、审计明细列不再被卡片硬裁
三处都是实测截图发现的,不是"看着可能有问题":
1. 总览「当前在线」用 LIMIT 500 采样算法。库里 1.3 万台设备时取到的是物理
顺序最前一批,真正在线的可能一台都不在里面,页面显示"0",而 0 会被读成
确定结论(旁边"采样 500 台"的小字没人看)。改为 SCAN 数 `presence:*`
键个数,与设备表规模无关;Redis 不可用仍返回 null 而非 0。
2. 审计「明细」列的 JSON 没有断行机会(无空格),`white-space: normal`
找不到断点 → 整串作为不可断词把表格撑宽,再被 `.card{overflow:hidden}`
从右边缘硬裁,既无省略号也看不出被截了。改为包一层带 max-width 的 div
(`table-layout:auto` 下 td 的 max-width 不可靠)+ 省略号 + title 悬停全文。
3. 存储表的行数把估算值和精确 count 混在一列,分不清真假。估算值灰化,
精确值带「精确」徽标,表头去掉"(估算)"。
顺带修 countPresence 插进来时把 mountAdmin 的 JSDoc 顶开了的注释错位。
|
2026-10-30 13:29:13 +08:00 |
|
PMon Dev
|
1a10e4a04a
|
feat(管理端): 从零补一条运营者通路(/admin/ + /api/admin/*)
此前**没有任何运营端**:38 个端点全是用户视角(每条都带 WHERE owner_id=我),
看全站账号靠 psql、改档位靠手写 UPDATE、看在线上靠 docker logs。
运营动作既没有界面,也没有留痕。
身份模型
- ADMIN_TOKEN 环境变量(不加 users.is_admin:新库上"谁来设第一个管理员"
要么手改 SQL,要么留一个"首次访问即管理员"的接口 = 经典接管漏洞)
- ADMIN_NAME 只作审计标签,不参与鉴权
四条失败关闭
- 没设口令 → 404(401 等于宣布"这里有管理台")
- 短于 32 字符 → 同样 404(ADMIN_TOKEN=123456 比没有更危险)
- 口令错 → 401,不回显任何片段
- 口令走 ?token= → 401(它会进 nginx 日志、浏览器历史、Referer)
- 比较用定长摘要 + timingSafeEqual;10 次/分钟/IP → 429,锁定期间正确口令同样被挡
交付
- server/business/admin.js:18 个端点 + CSV 导出(BOM + 公式注入防护)
- server/shared/admin-queries.js:运营者查询(分页/统计/存储/降采样)
- server/business/admin/:5 页 + assets,零依赖无 CDN,手写 SVG 图表
- server/shared/presence.js:在线判定抽出来,业务端与管理端共用(不写第二份)
- 写动作五个(改档位/封禁/强制验证/踢会话/解除归属),每个都落 admin_* 审计;
CSV 导出同样记审计(带走的是全站邮箱)
三个真缺陷
- GET /api/audit **匿名可读全站审计**(实测 HTTP 200)→ 删除,改 /api/admin/audit
- 存储统计报错 99.9%:TimescaleDB 数据在 chunk 里,父表 32KB vs 实际 2802MB;
超表 reltuples 也不可信(2802MB 报 0 行)→ 探扩展后累加 chunk + 精确 count(3s 上限)
- COPY server/business/*.js 不含子目录 → admin/ 漏拷时"镜像能建、容器健康、
/api/admin/* 全通,只有 /admin/ 404";覆盖性测试按 require 图查,静态文件是盲区
→ 新增「静态资源目录必须被 COPY」段,并修正 expandGlob 对目录的处理
护栏
- tests/admin-api-test.js 80 项(9 个隔离实例,每个口令形态独占一个进程)
- dockerfile-coverage 45 → 49;全量 35 套件 / 1901 项 / 0 失败 / 0 跳过
- 变异自证:短口令当真→红;未配置改 401→红;CSV 去防护→红(直接打出裸公式行);
删 COPY 那行→7 个文件点名报出
顺带修
- .env.example 的 ENFORCE_DEVICE_QUOTA 原写 0,与"代码默认开启"相反 ——
照模板部署会把配额静默关掉,改 1
|
2026-10-30 13:23:10 +08:00 |
|
PMon Dev
|
c92d870c8e
|
feat(用户机制): 角色/邀请/自救/配额全部落地(13 项缺口关闭)
此前账号体系只到"你是谁",这一轮补"你能做什么"与"账号出事怎么办":
忘了口令等于报废、授权只能发配对码(等于公开招租)、配额只报数不拦。
服务端
- shared/permissions.js:13 能力 × 3 角色 + PLAN_TABLE + authorize(),
canViewDevice 降为 authorize(...,'VIEW_SNAPSHOT') 薄封装(单一真相源)
- auth_tokens 一表统管 verify/reset/invite,只存 sha256,原子消费
- 新端点:会话列表/踢人、改密、验证与找回、注销(30 天冷静期)、
成员 CRUD + 角色、邀请接受、转移归属、解除归属、审计
- 配额 ENFORCE_DEVICE_QUOTA 改默认开启(唯一破坏性变更);用尽返回 402;
未验证邮箱收紧到 1 台
- server/shared/mailer.js:outbox/console/smtp(smtp 未实现,如实回 delivered:false);
令牌绝不进 HTTP 响应
客户端
- role_catalog.dart(Dart 侧镜像,跨语言一致性由测试锁定)
- device_members_card.dart + account_card 增验证状态/改密/注销/找回
- ViewerDevice.role 透传;成员卡只对"当前查看的那台 + 已登录 + 有角色"出现
护栏
- permissions-test 29 / user-lifecycle-test 66 / schema-consistency 第 14·15 段
- account_security_test 13 / viewer_page_test ⑩ 3
- 全量回归 34 套件 / 1838 项 / 0 失败
顺带修(同一类"环境冒充缺陷")
- user-lifecycle-test 改为自起临时实例(随机端口 + 自己的 outbox + 放宽限流)
- persistence-restart 端口不再写死 3100(被同机另一项目占用→假红)
- auth-mode-test 走真实邮箱验证,配额期待从 403 改为 402
- run-all/README 的套件计数与实跑对齐(Dart 416→598,标签长期漂移)
|
2026-10-30 12:52:51 +08:00 |
|
PMon Dev
|
930c35957b
|
feat(installer): Windows 上「安装成功但不会立刻启动」必须说出来(平台实话)
## 为什么必须补这句(team-lead 拍板采纳)
三个平台的 `install_commands`(真相源 `rust-core/src/keepalive.rs`)语义不同:
| 平台 | 安装命令 | 装完是否立即在跑 |
|---|---|---|
| macOS | `launchctl bootstrap` + plist `RunAtLoad=true` | 是 |
| Linux | `systemctl --user enable --now`(`--now`) | 是 |
| Windows | `reg add`(HKCU Run 项) + `schtasks /SC ONLOGON` | **否,只在下次登录时起** |
于是 Windows 上「install 返回 0」**只承诺"已注册",从不承诺"已在跑"**。
可上游(GUI 常驻卡)曾把它读成"已启动",进而停掉自己的采集 ——
两个都没在采、界面还很平静。这行字把「启动」从**承诺**降级成**事实描述**。
## 改动(`client/bin/pmon_install.dart`)
1. 新增 `launchSemanticsNoteFor(PmonPlatform)`(平台作参数的纯函数):
· Windows → `仅在下次登录时启动`
· 其它 → `安装命令本应已使进程立即启动,但本平台无法探测其是否在运行`
2. `install` **成功路径**打印 `说明:已注册 · <上面那句>`。
位置刻意放在 `hardFailures` 判定**之后**:安装失败时不该说"已注册"。
触发条件是"本平台有没有探针"(平台属性),与是否 `--no-start` 无关。
3. `status --json` 的 `autostart` 增 `note` 字段(探针可用时为 null;
不可用时为该句)—— 因为 Windows 上这行字是**唯一**的状态说明,
GUI 读的是 JSON,不能只留在人话输出里。人话 `status` 也打印同一句。
⚠️ **这是"平台 → 启动语义"知识的第二份**(第一份在 Rust 的 `install_commands`)。
不能改成嗅探命令字符串(本项目明令禁止判 `cmd.contains('bootout')` 那类)。
正解与 `stderr_log_path` 同款:让 `AutoStartPlan` 返回 `starts_immediately: bool`,
Dart 只读不算。**本轮未做**(要改 Rust 结构 + FFI 出口),已写进注释,
并集中在**这一个函数**里,不允许出现第三份。
## 测试
- 新增 `client/test/installer_launch_note_test.dart`(5 项):本机是 macOS,
跑不到 Windows 分支,故把平台做成参数逐条断言 —— 与 `app_paths_test.dart` 同一手法。
含反面对照(Windows 那句不得出现"已启动";macOS/Linux 那句不得出现"登录"),
以及两条**形状护栏**:读 `keepalive.rs` 断言 Windows 分支确实有 `/SC ONLOGON`
且没有 `systemctl`/`launchctl`、Linux 分支确实有 `enable --now`。
(写这条时踩了一次"抓错对象":`Platform::Windows =>` 在 `as_str`/`autostart_mechanism`
里也出现,第一版抓到显示名导致假红,已限定在 `plan_autostart` 内、
边界同时认 `=> {` 与 `=> Ok(AutoStartPlan {`。)
- `installer-clean-e2e.js` 38 项(+1):JSON `autostart.note` **两条分支都断言** ——
探针可用必须为 null(不给假说明),不可用时必须非空且含"启动"。
- 变异自证:临时让 `probeAgentProcess()` 恒返回 unavailable →
`status --json` 得到 `probe_available=false` + `note='安装命令本应已使进程立即启动…'`,
人话 `status` 出现 `说明: …`,e2e 走"非空"分支仍全绿;恢复后回到 null 分支。
("install 真正成功 + 探针不可用"这个组合在本机**无法构造**:`launchctl bootstrap`
在此环境恒失败(I/O error 5),install 永远走不到成功分支 —— 如实说明,
不假装验过。install 侧与人话/JSON 侧调用的是同一个函数,字符串由单测覆盖。)
## 顺带修掉一个我自己引入的偶发失败(必须修掉,不能留着)
`cli-exit-code-e2e.js` 原用 `127.0.0.1:3107` 当"打不通的网关",
但实测**8 次里有 2 次 3107 上真的有人在听**(relay 往返成功、`relay_check` 退出 0),
"打不通 ⇒ 退出码非零"这条被静默反转 —— 它先是在 run-all 里红了一次我才发现。
改为**端口 1**(特权端口,正常环境永不监听),并在注释里写明"别换成看起来空闲的端口"。
换端口后连跑 10 次 0 失败。
## 验证
- `dart analyze lib bin`:**No issues found!**
- `flutter test`:**582/0**(含本笔新增 5 项)
- `cargo test --release`:**61/0**
- `bash tests/run-all.sh`:**32 套件 31 通过 / 1 失败**(唯一红仍是隐私扫描,未碰)
- `node tests/installer-clean-e2e.js`:**38/0/1 跳过**
- `node tests/cli-exit-code-e2e.js`:**11/0**(连跑 10 次稳定)
- `guard-structure-test.js`:**44/0**
## 归属备案
`tests/run-all.sh` 里我把套件计数 37→38 的改动,被他人提交
`205e862` 一并带走了(同文件、`git add` 粒度拆不开,与我上次遇到的情况对称)。
内容已在 HEAD,无丢失;此处留痕避免日后考古困惑。
|
2026-10-28 00:17:50 +08:00 |
|
PMon Dev
|
c806c26537
|
fix(测试): run-all 不再把「环境缺 cargo」伪装成「Rust 单测失败」
## 现象(2026-10-27 实测)
`$HOME/.cargo/bin` 不在 PATH 时(非登录 shell / CI / cron 都是这样):
```
tests/run-all.sh: line 52: cargo: command not found
◉ FAIL Rust 核心单测(59 项)
套件结果: 30 通过 / 2 失败
```
而单独 `cargo test --release` 是 **61/0**。也就是说脚本把**环境缺失**报成了一个
**测试失败**,汇总里那条红指向 Rust 代码 —— 排查方向被彻底带偏。这与之前修过的
"护栏假装在工作"是同一族:**不能让环境问题冒充被测对象的问题**。
## 改法(两处 + 一个计数)
1. 脚本开头主动补 PATH:`$HOME/.cargo/bin/cargo` 存在就 `export PATH`,
否则退回 `command -v cargo`(允许自定义安装位置)
2. 仍找不到 → 新增 `skip()`,打印「⚠ 环境缺 cargo($HOME/.cargo/bin 不在 PATH?)
——这是环境问题,不是 Rust 代码的问题」,标 **◉ SKIP**,**不计入失败**
3. `skip()` 与 PASS/FAIL 三态分明:算 FAIL 会让人查被测对象,算 PASS 会把覆盖
缺口藏起来 —— 两者都是说谎。汇总行同步变成 `N 通过 / M 失败 / K 跳过`
## 验证
| 场景 | 结果 |
|---|---|
| PATH 含 cargo | 31 通过 / 1 失败(唯一红=隐私扫描,已知)|
| **PATH 不含 cargo,但 `$HOME/.cargo/bin/cargo` 存在** | **自愈生效** → Rust 套件 PASS → 31 通过 / 1 失败(改前是 30/2 假红)|
| 两者都没有(模拟 CI 无 Rust 工具链) | `◉ SKIP` + 原因说明,**失败计数仍为 0**(脚本不因此判失败)|
`bash -n` 语法检查通过。第三种场景用假 HOME + 精简 PATH 单验过检测段与 `skip()`。
|
2026-10-28 00:12:52 +08:00 |
|
PMon Dev
|
26b939c239
|
test(常驻采集): 探针测试里的家目录改用占位名,别把合成路径写成隐私命中
`tests/privacy-scan.js` 把 `/Users/<非白名单名>` 判成「高置信个人数据」,而我
在正则转义那条用例里用了 `/Users/me/Library/pmon(v2.3)/...` —— 那是**合成路径**,但扫描器
按形状判定,它照样红。
改成 `/Users/me/Library/pmon(v2.3)/pmon-agent`:
- `/Users/me` 在扫描器白名单里(与 `resident_service_test` 等既有用例一致)
- `(` 和 `.` 挪到目录名上,转义断言(`\.` / `\(`)一个都没减弱
工作树命中从 12 条降到 11 条(余下 6 条是我此前几笔提交历史里的同一字符串,
2 条是既有的公网 IP,3 条是 commit-msg 里的既有内容)。**历史那 6 条只能靠
重写历史清掉,不在本笔**(会连带重写并行同事的提交,需 lead 拍)。
验证:`flutter test test/agent_probe_test.dart` → 15/0;`node tests/privacy-scan.js`
不再报 `client/test/agent_probe_test.dart`。
|
2026-10-28 00:06:58 +08:00 |
|
PMon Dev
|
4e3b4739d4
|
fix(常驻采集): 停本界面采集改为「只认在跑的正面证据」,避免 Windows 上双双停摆
## 改判:`r.ok` 不足以作为"已在跑"的判据
`install` 退出码 0 只承诺"**已注册**",三个平台语义不同(Rust `keepalive.rs` 事实):
| 平台 | 安装命令 | 装完立即在跑? |
|---|---|---|
| macOS | `launchctl bootstrap` + plist `RunAtLoad=true` | 是 |
| Linux | `systemctl --user enable --now` | 是(`--now`)|
| Windows | `reg add HKCU\...\Run` + `schtasks /SC ONLOGON` | **否,只在下次登录时起** |
旧逻辑"拿到 0 就停 GUI 采集"在 Windows 上是**必然**事故(不是概率):
停掉 GUI 采集 + 常驻要等下次登录 → 两个都没在采,而卡片还很平静。
这比双写更糟 —— 双写至少还有数据。
## 新判据
```dart
bool get _hasRunningEvidence =>
_st.state == ResidentState.running && _st.probeAvailable != false;
```
- 有证据(`running` 且探针没说"我没查")→ 停 GUI 采集(防双写)
- 无证据 → **不停**,并在 `_note` 里说清:
· 探针不可用(Windows 现状)→「已注册;本平台需重新登录或手动「开始」才会运行。本界面继续采集,不会断数据。」
· 探针可用但没跑起来 →「安装已完成,但采集进程没有起来(原因见上)。本界面继续采集,不会断数据。」
- `probeAvailable == null`(老 CLI 无此字段)视同**有证据**:那种二进制报的
`running` 是 `pgrep` 真查出来的,不能因为"没这个元数据"就否认证据
## 为什么这规则不放过双写
双写只可能发生在探针可用的平台(macOS/Linux)——那些平台恰好拿得到正面证据,
于是会停。Windows 拿不到证据,但它**压根不会有两个进程同时在跑**(安装命令里
没有 start 这一步)。**双写风险与"探针不可用"的交集为空**,故既不放过双写,
也不误停采集。上一轮的 A/B 是伪两难:把"所有平台"当成了一回事。
## 顺带:`_run` 的时序随之调整
以前是"先停 → 再刷新"(停不用等状态)。现在**必须先刷新再决定**,因为停的判据
就是刷新回来的状态。代价是:证据存在时,停采集比旧实现晚一次 CLI 往返
(新版 `build/pmon-install` 约 50ms);换来的是"没证据不乱停"。
## 验证
- `flutter test test/resident_card_test.dart` → **17/0**
- `dart analyze lib` → No issues found
- 变异自证(两次,均已恢复):
· `_hasRunningEvidence` 短路成 `r.ok`(退回旧逻辑)→ 「探针不可用 → 不停」「探针可用但没起来 → 不停」两条 ★ 报红
· `probeAvailable != false` 改成 `== true`(把"缺字段"当没证据)→ 「拿到在跑证据 → 停采集」报红
|
2026-10-27 23:54:46 +08:00 |
|
PMon Dev
|
3af5c592ff
|
fix(常驻采集): 提示文案改指 build_cli.sh —— 原来的命令在 Dart 3.13 上必然失败
## 问题:我们把用户送到了一条死路
`resident_service.dart` 有三处**面向用户**的提示让用户敲
`dart compile exe bin/pmon_install.dart -o build/pmon-install`:
- 找不到 CLI 时(`status()` 的 detail)
- CLI 太旧、不认识 `--json` 时(`_statusOnce` 的 detail)
- 安装/卸载前找不到 CLI 时(`_action` 的 message)
而 Dart 3.13 起该命令遇到 build hooks(依赖链里的 `objective_c`)**直接失败**,
用户照做后看到的是 `'dart compile' does not support build hooks` ——
比没有提示更糟,因为**他会以为是自己环境坏了**。
## 改法:只指真相源,不抄命令
三处统一成:`client/scripts/build_cli.sh`(产物 `build/pmon-install`)。
构建入口的真相源是那个脚本(engineer-2 已把 README 与两个 bin 的注释/错误提示
一并改指它),这里不抄一份参数序列 —— 抄一份就多一处会漂移的副本。
## 加了一条守卫,防它再长回来
`⑧ 构建命令只许指向能跑通的那条`:
1. 源码守卫:`resident_service.dart` 里不许再出现那个精确形态
(`dart compile exe bin/pmon_install.dart` —— 它只会出现在"让用户照着敲"
的位置),且必须含 `client/scripts/build_cli.sh`
2. 行为断言:`status()` 在"找不到 CLI"时给出的 detail 必须含 `build_cli.sh`;
装过 CLI 的机器跳过该用例(不假绿)
## 顺带确认:回落链不再被触发
新构建产物(`build/pmon-install`,6.2 MB,2026-10-27 23:46)实测**认识 `--json`**:
候选: …/client/build/pmon-install ← 第一候选直接答话
候选: dart run …/bin/pmon_install.dart ← 备用,未用到
state=running pids=[1737]
`resolveCliCandidates()` 一个字符没改 —— 回落链从"今天的主路径"退回它本来的
身份(防御性代码),这正是不该把它写成主路径的理由。
## 验证
- `flutter test` → **576/0**、`dart analyze lib bin` → No issues found
- 真机 `status --json` → `state=running pids=[1737]`,三路径与 `serverUrl/deviceId` 解析正常
## 备案(未做)
`lib/` 里还有约 10 处文档注释用"能 `dart compile exe`"来解释"为什么这个模块
必须纯 Dart、不 import Flutter"。约束本身仍然成立(`dart build cli` 同样要求),
只是叫法过时。属文档口径整理,不在本笔。
|
2026-10-27 23:52:56 +08:00 |
|
PMon Dev
|
d0d2d717a4
|
docs(installer): 把 log.path 未收敛的 TODO 补成可执行的正解形态
`statusSnapshot` 里 `/tmp/<label>.err.log` 是唯一还没收敛的路径(Rust 的
`macos_launchagent_plist` 把 `StandardErrorPath` 写成同一形状,两边各一份)。
原注释只说了"应该让 AutoStartPlan 返回它",没写清**改成什么样**,
后人读到仍不知道落点在哪。按 team-lead 要求补上具体形态:
读 `AutoStartPlan.stderr_log_path`,**勿再在本地拼 `/tmp/<label>.err.log`**
—— 与 `allow_failure` 同款:语义由 Rust 给,Dart 只消费。
仅注释改动,无行为变化。边界不变:需要改 Rust 数据结构 + FFI 出口,
不在"client 路径"这一轮范围内。
验证:`dart analyze bin` No issues found;`guard-structure-test.js` 44/0。
|
2026-10-27 23:51:32 +08:00 |
|
PMon Dev
|
5a909f14fd
|
fix(构建): dart compile exe 已被 Dart 3.13 打断,构建方式收敛到 scripts/build_cli.sh 一处
## 根因(不是我们的代码,是 SDK 行为变了)
```
$ dart compile exe bin/pmon_install.dart
'dart compile' does not support build hooks, use 'dart build' instead.
Packages with build hooks: objective_c.
```
`objective_c` 是 `file_picker` 的传递依赖。于是 README 与历史脚本里的
`dart compile exe ...` **全部失效**,用户照抄必失败 —— 典型"文档在说谎"。
替代命令:`dart build cli -t bin/<x>.dart -o <dir>`,产物布局也变了:
`<dir>/bundle/bin/<x>` + `<dir>/bundle/lib/<libs>`(多一层 `bundle/`,多出动态库)。
## 1) "怎么构建"收敛到一处:`client/scripts/build_cli.sh`
- 默认构建 `pmon_agent` + `pmon_install`,可追加其它入口(`pmon_viewer` 等)。
- 权威产物是 **bundle**(exe 与 lib/ 必须一起用),同时产出**扁平副本**
`build/pmon-agent` / `build/pmon-install`,并把 `bundle/lib/` 的动态库平铺到 `build/`。
- 构建命令不再出现在别处:`client/README.md:98`、`:765-766` 已改为
`bash scripts/build_cli.sh`,根 `README.md:916` 的描述也改为指向脚本;
`bin/pmon_agent.dart:13`、`bin/pmon_install.dart:6` 的注释同改,
`pmon_install.dart` 找不到可执行文件时的提示也改为指向脚本。
## 2) 安装器落位要带运行期库
`_install` 新增 `_runtimeLibsNextTo()`:覆盖两种产物形态
- bundle:`<bundle>/bin/<exe>` + `<bundle>/lib/<libs>`
- 扁平:exe 与库同目录(脚本会把库平铺到 `build/`)
只按扩展名收 `.dylib/.so/.dll`,**不整目录拷贝**,并排除核心库(它单独落位,避免拷两遍)。
**依据(实测,不是猜)**:
- `otool -L build/cli/pmon_agent/bundle/bin/pmon_agent` 里**没有** `objective_c.dylib`
—— 它不是链接期依赖,而是运行期按需 dlopen 的库。
- 把 exe 单独拷到临时目录(不带该 dylib)后跑 `--help` 与
`--collector --duration 2`:**都 exit 0,日志无 dlopen/缺库错误**
⇒ 当前 CLI 已走到的代码路径**并不需要**它。
- 结论:拷贝是**自包含保险**(bundle/lib 是 SDK 为该 exe 声明的运行期依赖集),
不是修复一个正在发生的失败。代价一次文件拷贝;收益是将来若某条代码路径用到
ObjC 桥接,不会在运行期才报 dlopen 错、且错得与"装不上"完全不像。
第一版只在"exe 的 ../lib"里找库,实测**扁平形态装出来没有 objective_c.dylib**
(脚本已把库拷到 build/,但 exe 的 ../lib 指向 client/lib);已补上"exe 同目录"这一支。
## 3) `build/pmon-install`:**保留**,继续由脚本产出
理由不是"兼容旧习惯",而是:GUI 的常驻采集卡按候选链找 CLI,
**第一候选就是 `build/pmon-install`**(`lib/host/resident_service.dart`)。
若它不存在,候选链只剩 `dart run` —— 而**最终用户机器上没有 Dart SDK**,
那条候选在真实安装里等同不可用,卡片会永远显示"找不到 CLI"。
故让脚本继续产出该路径,候选链才有意义。
## 4) 完整验证:新命令构建 → 临时 HOME 安装 → 落位后真跑
```
$ client/scripts/build_cli.sh
dart: .../flutter/bin/dart 版本: Dart SDK 3.13.3
-- pmon_agent -- ✓ bundle: build/cli/pmon_agent/bundle ✓ 扁平副本: build/pmon-agent
-- pmon_install -- ✓ bundle: build/cli/pmon_install/bundle ✓ 扁平副本: build/pmon-install
$ ./build/pmon-install --help → exit 0
$ HOME=$TMP ./build/pmon-install install --no-start --label com.pmon.collector.flat
✓ 已落位: $TMP/Library/Application Support/pmon_client/bin/pmon-agent
✓ 已落位: .../bin/libpmon_core.dylib
✓ 已落位运行期库: .../bin/objective_c.dylib
✓ 配置: .../config.json
✓ 已写入: $TMP/Library/LaunchAgents/com.pmon.collector.flat.plist
✗ launchctl bootstrap → exit 5 Input/output error ← 本环境受限(老事实),失败语义正确非零
$ <落位目录>/pmon-agent --print-plan → exit 0,打出完整 plist 计划(核心库加载成功)
$ HOME=$TMP ./build/pmon-install status --json → 路径全部指向临时 HOME,running=false
$ HOME=$TMP ./build/pmon-install uninstall --label ...flat → exit 0,plist 已摘除
```
## 5) e2e 跟着更新(`tests/installer-clean-e2e.js` 34 → 37 项)
- 采集端候选**优先取 bundle 形态**(`build/cli/pmon_agent/bundle/bin/`),
否则"落位要不要带运行期库"这条测不出来。
- 新增断言:`bundle 的运行期库已一并落位(objective_c.dylib)`。
- 新增断言:**落位后的可执行文件真的能跑** —— `--help`(exit 0) 与
`--print-plan`(exit 0 且输出含 `install_commands`,证明 exe + 核心库 + 运行期库三件齐)。
## 验证
- `dart analyze lib bin`:**No issues found!**
- `flutter test`:**574/0**
- `cargo test --release`:**61/0**
- `bash tests/run-all.sh`:**32 套件 31 通过 / 1 失败**(唯一红仍是隐私扫描,未碰)
- `node tests/installer-clean-e2e.js`:**37 通过 / 0 失败 / 1 跳过**(跳过=launchd 受限)
- `node tests/cli-exit-code-e2e.js`:**11/0**
- `guard-structure-test.js`:**44/0**
## 未做(边界外,需他人跟进)
`client/lib/host/resident_service.dart:317/351/414` 与
`client/test/resident_service_test.dart:299` 里仍写着 `dart compile exe ...` 的提示文案
(那是 GUI 侧文件,本轮不碰)。建议替换为:
`请先执行 client/scripts/build_cli.sh(产物 build/pmon-install)`。
另 `client/lib/**` 里"纯 Dart 才能 dart compile exe"的说法是**能力描述**(仍然成立),
未改。
|
2026-10-27 23:50:15 +08:00 |
|
PMon Dev
|
e6d1438864
|
test(常驻采集): 把"没快照就放行"这条例外钉在日志上,别让它只剩一句注释
## 为什么单为一条日志加测试
`_guiCollectorBlockReason()` 里只有一处"不拦"的例外:`status --json` 拿不到快照
(打包成 .app 且从未装过 CLI)时 `agentPath` 为空、无从探测,于是放行。理由成立
(否则没装过常驻采集的机器彻底不能采集),但它是**唯一一处靠日志才能复盘的判断**:
将来有人问"这次为什么没拦住" → 没有日志就只能猜,
而猜出来的原因通常比没有原因更糟。
故加两条:
1. **落盘验证**:临时目录注入 `StartupLog.dir`,写一条 `resident_probe_skipped`,
读回 JSONL,断言 `e` 与 `f.reason / f.state / f.detail` 原样保留 ——
特别是 `detail` 不能被脱敏逻辑吞掉(那条是排障的唯一线索)。
2. **源码守卫**:`main.dart` 里必须留着这句 `StartupLog.event('resident_probe_skipped'`,
防"嫌麻烦删掉"。
## 管道本来就在工作(实机证据,不是推断)
沙箱容器里的 `startup_log.jsonl` 现有 `boot ×3 / restore_done ×2 / started ×2`,
且 `main()` 第一段就 `StartupLog.dir = ConfigStore.dir`(先于任何 `_start`)。
也就是说这条链路是通的,新事件只是加了一个名字。
## 验证
- `flutter test` → **574/0**
- `dart analyze lib bin` → No issues found
- 变异自证:把事件名改成 `resident_probe_SKIPPED_MUTATION` → ⑥ 的源码守卫报红,恢复即绿
|
2026-10-27 23:45:38 +08:00 |
|
PMon Dev
|
3b5007c1f6
|
fix(常驻采集): macOS 沙箱下锁对 agent 无效,补一道进程探测,堵住必然双写
## 为什么必须补(不是防御性代码)
`agentLockPath` = `ConfigStore.dir/agent.lock`,而 `ConfigStore.dir` 由 `HOME`
决定。macOS **沙箱** GUI 的 `HOME` 是应用容器,agent 由 launchd 拉起、`HOME`
是真实家目录:
GUI → ~/Library/Containers/com.pmon.pmonClient/Data/…/agent.lock
agent → ~/Library/Application Support/pmon_client/agent.lock
**两个进程锁的是两个不同文件,谁也不挡谁** → 沙箱 GUI 里 `isHeldByOther()`
恒为 false。于是"agent 已在跑、用户又点了一次开始采集"→ 必然双写:两条 seq
流混进同一个 device_id,服务端按会话去重分不清,历史曲线出现无法解释的毛刺,
**且不报错**。`da88e30` 只堵住了"装完之后"那个窗口,这条路径它堵不住。
## 只修 macOS(不要把三个平台一起改)
| 形态 | GUI 的 HOME | agent 的 HOME | 锁是否同一文件 |
|---|---|---|---|
| macOS 沙箱 | 应用容器 | 真实家目录 | ❌ |
| macOS 非沙箱 / Windows / Linux | 同 agent | 同 agent | ✅ |
故 `probeInstalledAgent()` 在非 macOS 上直接返回 `notApplicable`,**一次都不执行
`pgrep`**(Windows 根本没有这个程序)。有一个测试专门守这条,防后人"统一成全平台"。
## agent 路径为什么取自 CLI,而不是本地推导
沙箱 GUI 里 `installBinDirFor(macOS, homeDir)` 的 `homeDir` 就是容器路径,算出来
是"容器/…/bin/pmon-agent",用它 `pgrep` 永远匹配不到 —— 更糟的是会得到一个
看起来确定的 `notRunning`。CLI 是非沙箱进程,`status --json` 报的
`installed.agent_path` 才是 launchd 实际启动的那条(也是 installer 侧
`probeAgentProcess()` 用的同一处真相源)。
## 判据
· `/usr/bin/pgrep -f <RegExp.escape(path + ' --collector')>` —— 绝对路径(沙箱 PATH
可能被裁剪)、转义(家目录里的 `.` 会当通配符)、带 `--collector`(与 plist 的
`ProgramArguments` 对齐,与 installer 同一套口径)
· exit 0 → running;**exit 1 → notRunning(唯一可信的"没在跑")**;
其它非零 / `ProcessException` → `undetermined`(不能当成"没有")
· `undetermined` → **不放行**:双写是污染数据,漏采只是缺数据
· 锁命中优先(它能挡住探针看不出的"两个 GUI 实例"),且命中后不再多花一次 CLI 往返
## 一个如实记录的边界
`status()` 拿不到快照(打包后的 .app 且从未装过 CLI)时 `agentPath` 为空、无从
探测 → **放行**并留 `resident_probe_skipped` 日志。理由:若这也拦,等于让没装过
常驻采集的机器彻底无法采集(确定的永久性回归),换来挡住"装过之后又把 CLI 删了"
这个很偏的边界。这一条我在回报里也点了名。
## 验证
- `flutter test` → **572/0**(新增 13 条)
- `dart analyze lib bin` → No issues found
- 真机(非沙箱调用方,agent pid 1737 在跑):
`cli 报的 agent_path=…/pmon_client/bin/pmon-agent` → `探针结论=running`
→ `合成结论=后台常驻采集已在运行(进程探测确认),本界面仅查看(不会重复采集)`
- 变异自证(三次,均已恢复):
· 去掉 main.dart 的 macOS 分支 → 源码守卫 ⑤ 报红
· `exitCode != 0` 一律当 notRunning → 「其它非零 → undetermined」报红
· 去掉"非 macOS 不调探针"守卫 → 「Windows/Linux/Android 一次都不执行」报红
|
2026-10-27 23:43:56 +08:00 |
|
PMon Dev
|
b11def8167
|
fix(agent): 退出码显式落到进程边界,并补「真实子进程退出码」e2e
## 依据:本 SDK 上 main 的返回值完全不设退出码(实测矩阵)
Dart SDK 3.13.3,各跑 `dart run` 与 `dart compile exe` 两种形态:
| 写法 | `dart run` | `dart compile exe` |
|---|---|---|
| `int main() { return 3; }` | **0** | **0** |
| `Future<int> main() async { return 3; }` | **0** | **0** |
| `void main() { exitCode = 3; }` | 3 | 3 |
即**只有显式 `exitCode =` / `exit()` 才生效**(官方文档说 `Future<int> main` 会设,本 SDK 不成立)。
所以 `cffdc50`「安装器不再撒谎、返回非零」的代码逻辑对,但**进程边界是死的**;
`pmon_agent.dart` 同样是 `Future<int> main`,后果更重:plist 用
`KeepAlive.SuccessfulExit=false`,launchd 靠「非零退出」判崩溃 —— 退出码被吞成 0
等于**采集端崩了也不会被拉起**,用户只看到历史断档,无任何报错。
## 改动:`bin/pmon_agent.dart`
`Future<void> main` + `exitCode = await runAgent(argv)`;函数体抽成 `runAgent`
(返回码语义清晰、可被测试直接调)。**语义逐条守住,没有一刀切归零**:
| 退出点 | 改前 | 改后 | 理由 |
|---|---|---|---|
| `--help` | return 0(被吞成 0) | 0 | 帮助是正常完成 |
| `--print-plan` 成功 | return 0 | 0 | 只打印,正常完成 |
| `--print-plan` 且核心返回 error | return 0 | **1** | 用户要的「计划」没拿到(iOS 不支持/路径非法);退出 0 会让脚本以为拿到了产物 |
| 抢不到单实例锁 | return 0 | **0(刻意不变)** | 非零会被 launchd 当崩溃立刻重启 → 两进程互踢成无限重启循环,比"没起来"更糟 |
| `--duration` 正常到期 | return 0 | 0 | 冒烟/CI 的正常路径 |
| Ctrl-C(SIGINT) | `exit(0)` | 0 | 用户主动停;非零会让 launchd 立刻拉起("关不掉的进程") |
| 常驻等待(Completer) | return 0 | 0 | 理论不可达(只有信号或 kill 能结束) |
| 未捕获异常(核心库缺失等) | 无 catch → 255 | **255(不变)** | 实测 `Future<void> main` 抛异常仍是 255;崩溃必须非零,否则 launchd 不重启 |
顺带把 `--print-plan` 里硬编码的 label 换成 `kDefaultAutostartLabel`
(label 唯一真相源是 `host/autostart_label.dart`,不在 bin/ 再写一份)。
## `pmon_viewer.dart` / `relay_check.dart`:**不需要改**,它们已经是显式 `exit()`
核对结果(`grep -c "exit("`):
- `pmon_viewer.dart`:**7 处** —— `exit(4)`(缺 --account / 启动失败 / 缺 --device)、
`exit(3)`(配对失败 / 未订阅成功)、`exit(0)`(--list),末尾 `exit(ok ? 0 : 2)`。
- `relay_check.dart`:**2 处** —— `exit(same ? 0 : 1)`、catch 里 `exit(1)`。
`exit()` 比 `exitCode =` **更强**(立即终止并带上码),失败语义本来就到达边界。
我实测确认并写进了新套件:`pmon_viewer` 缺 `--account` → **4**,`relay_check` 打不通 → **1**。
顺带说明:`viewer-e2e-test.js:257` 那条 `cli.code === 0` 断言**因此是有效的**(不是死断言)。
为"统一写法"去把它们改成 `exitCode =` 是纯噪音,且会削弱语义,未做。
## 新增 `tests/cli-exit-code-e2e.js`(11 项,已接入 run-all)
**一律读子进程真实 exit code**(`spawnSync`/`spawn` 的 status),**不 import 被测代码断言返回值**
—— 断言返回值正是这次漏掉的东西。覆盖:
- 必须 0:`--help`、`--print-plan`、**抢不到单实例锁**、`--duration` 到期(后两条用"先起一个持锁实例再起第二个"构造,temp HOME 隔离)
- 必须非零:`pmon-install install --agent-bin <不存在>`、`pmon-viewer` 缺 `--account`、`relay_check` 打不通网关
## 变异自证
1. **旧写法复现缺陷**:把 `main` 临时改回 `Future<int>`,并让 `--help` `return 7` ——
新写法实测进程退出码 **7**;旧写法实测 **0**(7 被吞)。同一份代码、只改 main 签名。
2. **测试确实会红**:临时注释掉 `pmon_install.dart` 的 `exitCode = await runCli(argv);`
→ `cli-exit-code-e2e` ⑤ 变红(`exit=0`),`installer-clean-e2e` 的
「说失败⇒退出码非零」同时变红;恢复后两套全绿(11/0、34/0/1)。
## 验证
- `dart analyze lib bin`:**No issues found!**
- `flutter test`:**559/0**(含他人新增)
- `cargo test --release`:**61/0**
- `bash tests/run-all.sh`:**32 套件 31 通过 / 1 失败**(唯一红仍是隐私扫描,未碰)
- `node tests/cli-exit-code-e2e.js`:**11 通过 / 0 失败**
- `guard-structure-test.js`:**44/0**
|
2026-10-27 23:43:06 +08:00 |
|
PMon Dev
|
9db09bb923
|
fix(常驻采集): 停了本界面采集就必须说出口,别让用户在漏采时看到一张平静的卡
## 这个洞怎么来的
installer 这轮把「探针查不了」从**失败**改成**只 ⚠**(对的:不能把平台限制说成
缺陷)。而卡片的判据是"installer 退出码 0 → 停掉本界面采集"。两者相乘:
探针不可用的平台(当前 Windows)
→ install 返回 0
→ 卡片停掉 GUI 采集
→ 而常驻进程是否起来**没人知道**(state=unknown)
→ 净效果:两个都没在采,卡片还很平静
## 为什么仍选择"停",而不是"等确认了再停"
两个代价不对等,且 team-lead 已核实**跨形态的锁是失效的**(GUI 在沙箱容器里查
`Containers/.../agent.lock`,agent 由 launchd 拉起锁真实家目录 —— 两个进程锁的是
两个不同文件,谁也不挡谁)。因此"等确认"并不能防双写:GUI 查容器那把锁永远是
空的,照常启动 → **双写是必然,不是可能**。
· 停早了 = 缺数据(用户看得见、能补救:手动点开始采集)
· 停晚了 = **双写**:两条 seq 流混进同一个 device_id,服务端按会话去重分不清
它们,历史曲线出现无法解释的毛刺,**而且不报错**
故"宁可少采也不污染数据",但必须让用户知道这是我们的选择:
「已提交安装;<为什么确认不了>。为避免与它重复采集同一台设备,已停止本界面
的采集 —— 宁可少采也不污染数据。请用系统工具确认后再决定是否手动开始。」
## 实现
`_run` 里**先停、再刷新、再解释**(顺序不能反):停采集的时序一个字没动,
只是刷新之后多一步判断 —— `state == unknown` 才把这段话写进 `_note`。
拿到"在跑"证据时一个字都不加(不制造多余焦虑)。
## 验证
- `flutter test` → **559/0**(+3:装成功但确认不了 → 有解释;装成功且 running → 无解释;安装失败 → 不停也不解释)
- `dart analyze lib bin` → No issues found
- 变异自证(两次均恢复):
· 删掉 `_explainStopIfUnverified` 的调用 → 「装成功但确认不了进程」报红,另一条仍绿
· 去掉 `state != unknown` 的提前返回 → 「装成功且 running」报红(说明"不多话"这半边也被守住了)
## 未做(已单独立项,不在本笔)
锁的跨形态修复:GUI 侧改用进程探针(`pgrep -f <installBinDir>/pmon-agent`)判断
agent 在跑 —— agent 的绝对路径 GUI 能由 `app_paths` 推导,`pgrep` 是查进程不是读
文件,不需要跨沙箱访问权限;agent 侧继续用锁,只管"防两个 agent 实例"。
跨形态靠进程探测、同形态靠锁,各管一段。
|
2026-10-27 23:40:26 +08:00 |
|
PMon Dev
|
d7147e67a7
|
fix(常驻采集): 探针不可用只报「不知道」,并把采集端实际读到的配置摆出来
## 一、探针不可用 ≠ 没在跑(把上一轮糊回去的洞补上)
installer 在 ccb6c3f 里给 `process` 加了 `probe_available/probe_error`,把
「查不了」与「没在跑」分开了(人话输出已经用上);但 JSON 消费方没跟上:
`ResidentStatus.fromJson` 只读 `running`/`pids`,于是 Windows(无 pgrep)上
`running=false + registered=true` → 卡片一本正经显示 **"已安装但没起来"(橙色告警)**,
而真相是"一次都没查过"。这比"没有卡片"糟得多:它看起来**很确定**,用户会
照着它去重装一个已经装好的东西。
- `fromJson` 读 `probe_available`,**只有明确 false 且没观测到进程**才降级为
`ResidentState.unknown`(中性灰)+ detail 写明原因与"这不等于没在运行"
- `probe_available` 做成**三态**(`bool?`):缺字段(老二进制/老快照)→ 不看该判据。
若把"缺字段"当 false,所有老机器都会被判成"未知"——那是用另一种方式骗人
- 正面观测优先:`running=true`/有 pid 时仍判 running(探针元数据不压过证据)
## 二、采集端实际读到的配置:诚实标注,不做同步
macOS GUI 是真沙箱(容器内 `ConfigStore`),常驻采集端由 launchd 拉起、读真实
家目录 —— 磁盘上**两份 config 同时活着**:界面里改网关地址只改容器那份,采集端
看到的还是旧的,两边都不报错。三条同步路本轮都不通(沙箱无权写容器外 / 沙箱子
进程继承沙箱 / 让 agent 读容器会给主路径加上"必须装过 GUI"的依赖),故只做标注:
- 卡片显示「采集端当前读到:<server_url> · <device_id>」(来自 `status --json`)
- 与界面输入不一致时明说不一致,并给出下一步(点一次「重新安装并启动」)
- 配置面板加一句:上面两项只对「界面内采集」生效,常驻采集读的是安装器那份
- 未安装/问不到时不显示这一行(没有"采集端读到的配置"这回事,不能凭空造)
## 三、`log.path` 的漂移种子写进注释
`/tmp/<label>.err.log` 在 Rust 的 `macos_launchagent_plist` 与 Dart 的
`statusSnapshot()` 各写一份,今天"逐字相同"只是碰巧。已在 `logPath` 的文档里
写明正解:让 `AutoStartPlan` 返回 `stderr_log_path`,Dart 只读不算。
## 四、顺带补上 label 守卫(等 ccb6c3f 落地后才敢提)
`⑥` 组:字面量只许出现在 `lib/host/autostart_label.dart`,两个消费者必须引用
`kDefaultAutostartLabel`。
## 验证
- `flutter test` → **556/0**(新增 7 条:接缝 6 + 卡片 5,另含 label 守卫 2)
- `dart analyze lib bin` → No issues found
- 变异自证:
· 把 `fromJson` 里 `probeAvailable == false` 分支短路 → `⑦` 两条 ★ 报红(其余仍绿),恢复即绿
· 把卡片的 `if (mismatch)` 短路 → 「与界面输入不一致」报红,恢复即绿
|
2026-10-27 23:37:21 +08:00 |
|
PMon Dev
|
24e79b06a6
|
fix(installer): 进程探针区分「查不了」与「没在跑」,并给 e2e 补 status --json 的路径断言
## 本笔包含什么
1. **进程探针三态化**(`_agentPids` → `probeAgentProcess()` / `AgentProcessProbe`)
- 原实现 `Process.run('/bin/zsh', ['-c', 'pgrep -f ...'])`:**Linux 上 `/bin/zsh`
通常不存在** → `ProcessException` 直接抛穿 `install` / `uninstall` / `status`;
Windows 上 `pgrep` 一定没有 → 返回空列表,于是"查不了"被显示成"未运行"
(用户会去重装,而真问题是本平台没有探针)。
- 改为**不经 shell**直调 `pgrep -f <RegExp.escape 后的绝对路径> --collector`:
macOS/Linux 通用,且路径含空格/引号时不再交给 shell 解析。
- 返回值带 `available`:`pgrep` 退出码 1 = 确实没匹配(唯一可信的"没在跑"),
其它非零 / `ProcessException` = **无法判定**。上层:`install` 不把"查不了"
算成失败(只 ⚠),`uninstall` 不把"查不了"说成"已停止"(只 ⚠),
`status --json` 增 `process.probe_available/probe_tool/probe_error`,
人话输出显示「采集端: 无法判定(原因)」。只增字段,`running`/`pids` 语义不变,
GUI 侧解析不受影响。
- **Windows 未实现**(刻意):写一条未经真机验证的 PowerShell `-like` 分支,
只会"看起来能判定",匹配一错就是又一次"用错事实下判断"。已留
`TODO(多端前置)` 并明确报"无法判定"。
2. **`tests/installer-clean-e2e.js` 新增 8 项 `status --json` 断言**(26 → 34 项)
- 解析策略按 team-lead 要求与 GUI 侧一致:**从后往前**扫行、行内从后往前找 `{`
试解析 —— 因为 `dart run` 会把 `Running build hooks...` **拼在 JSON 同一行前面**
(实测 `Running build hooks...Running build hooks...{"label":...}`),
按"整行是 JSON"判断会把唯一有效输出判成解析失败。
- 断言 `config.path` / `installed.agent_path` / `autostart.unit_path` 与真相源
按**临时 HOME** 推导的值逐字相等 —— 硬编码旧值不可能跟着临时 HOME 变,
故这是"路径收敛真的生效"的最强证据。
3. `statusSnapshot` 里 `log.path` 加 `TODO`:这是**唯一还没收敛**的路径
(Rust 的 `macos_launchagent_plist` 把 `StandardErrorPath` 写成同一形状,
Dart 侧又写一份)。正确做法与 `allow_failure` 同款(让 `AutoStartPlan` 多返回
`stderr_log_path`),但需改 Rust 数据结构 + FFI 出口,超出"client 路径"边界,本轮未做。
## 归属说明(team-lead 要求留痕)
- `pmon_install.dart` 的 `statusSnapshot()` + `--json` 改动由**另一位工程师**实现;
因与路径收敛同处一个文件、非交互环境下无法按 hunk 拆分,实际已在上一笔
**c152ea2** 一并落地(该文件在 c152ea2 里是 151+/37-,即两人改动的合并体)。
本笔只在其上追加"探针三态化 + e2e 断言 + TODO",未改动其判据逻辑。
- 反向留痕:`51c85b0`(team-lead 记作 `3ee0630`,该 hash 在全部历史与分支中都不存在,
实际是 `51c85b0 test(常驻采集): 把「CLI 输出 ↔ GUI 解析器」两端的契约钉在一起`)
是"测试先行"那一笔 —— 它 import `installer.statusSnapshot`,而当时 CLI 里
`statusSnapshot` 出现次数为 **0**,单独 checkout 会因编译失败而红;
**本笔的前置 c152ea2 使其转绿**。保留它不 revert,两侧都留痕,避免 `git bisect`
把"顺序"读成"回归"。
## 验证(改前 → 改后)
- `cargo test --release`:61/0 → **61/0**
- `flutter test`:540/0 → **545/0**(含 `installer_status_json_test.dart` 4 项)
- `dart analyze lib bin`:**No issues found!**
- `bash tests/run-all.sh`:31 套件 30/1 → **31 套件 30/1**(唯一红仍是隐私扫描,未碰)
- `node tests/installer-clean-e2e.js`:26/0 → **34 通过 / 0 失败 / 1 跳过**
(跳过=launchctl `Bootstrap failed: 5` 环境受限,原因逐条打印)
- `guard-structure-test.js`:42/0
## 已核实的现场事实(供 team-lead 复核)
`status --json` 在**真实 HOME** 与**临时 HOME** 下都取到正确路径,后者全部跟着临时
HOME 走,证明三个路径字段来自 `app_paths.dart` 推导而非硬编码:
- 真实:`config.path=/Users/me/Library/Application Support/pmon_client/config.json`、
`installed.agent_path=.../bin/pmon-agent`、`log.path=/tmp/com.pmon.collector.err.log`
- 临时:`config.path=/tmp/pmon-json-probe-xxxx/Library/Application Support/pmon_client/config.json`、`installed.agent_path` 同源变化、
且 `process.running=false`(**正版 pid 1737 不再被算进来**,路径作用域生效)
|
2026-10-27 23:33:52 +08:00 |
|
PMon Dev
|
cb82678cdd
|
refactor(常驻采集): 自启 label 收口到唯一真相源(GUI 与安装器问同一个自启项)
`'com.pmon.collector'` 原本在 `resident_service.dart` 与 `bin/pmon_install.dart`
各写一份。漂移的症状极隐蔽:GUI 查 A.plist、安装器注册 B.plist —— 两边都
"正常",只是问的不是同一个自启项,表现为"明明装了、卡片却说未注册"。
这与 install_paths / 指标字段 / 配对码是同一族缺陷,故同样收成一份常量。
本笔只含常量的**定义**与 GUI 侧消费者(纯 lib,自洽可红可绿):
· 新增 `lib/host/autostart_label.dart`
· `ResidentService.label` 默认值改为引用它
安装器侧那半行(`const _defaultLabel = kDefaultAutostartLabel;`)落在
`client/bin/pmon_install.dart`,该文件当前含另一位同学的在途改动,随他的
commit 一起落地;随之落地的还有源码守卫测试(字面量只许出现在
`lib/host/autostart_label.dart`)——守卫必须与两处消费者同笔,否则该 sha
单独 checkout 会红。
验证:dart analyze lib → No issues found;flutter test → 545/0
|
2026-10-27 23:31:57 +08:00 |
|
PMon Dev
|
2574011e5a
|
fix(常驻采集): 过期 CLI 的输出不得冒充状态,改为校验形状并回落下一候选
端到端验收抓到的真缺陷(不是理论问题,本机可复现)。
## 血案形态
开发机上 `client/build/pmon-install` 是**几天前编译的旧二进制**,它不认识
`--json`,照旧打印人话 + 一行**配置 JSON**:
=== 配置 ===
{"server_url":"http://127.0.0.1:3000","device_id":"mac-autostart-test"}
而 `ResidentService` 原先的解析是「从下往上取第一个能解析成 Map 的行」——
于是它抓到了**配置 JSON**,`ResidentStatus.fromJson` 得到:
state=notInstalled、configPath=""、logPath=""、agentPath=""
卡片会一本正经地显示「尚未安装」—— **用户明明装过、而且在跑**。
这正是"形状对、内容错":接口对得上、值是错的,且不报错、不留痕。
## 修法(两条,缺一不可)
1. **形状校验**:新增 `ResidentService.looksLikeSnapshot()` —— 只接受同时含
`autostart` 与 `process` 两段的 JSON。配置 JSON 缺这两段,直接不认。
2. **候选链逐级回落**:`resolveCliCandidates()` 返回全部候选(装好的 →
`build/pmon-install` → `dart run bin/pmon_install.dart`),`status()` 逐个试,
**只采用形状合规的那一个**;全都不合规时返回 unknown,并给出可操作原因
("这个 pmon-install 不支持 `status --json`,请重新编译",附输出前 120 字)。
这样开发机上"编译产物过期"不再静默给错状态;生产上若用户只升级了 GUI
没升级 CLI,卡片会说"CLI 太旧",而不是谎报"未安装"。
`install`/`uninstall` 仍走第一个候选(那两个动作没有"输出形状"问题,失败原因原样回显)。
## 测试(+3,共 17 项;全仓 543/0)
- `looksLikeSnapshot`:只有 config 段的 JSON **不算**状态快照(血案本体);
缺 autostart/process 两段的不算;齐了才算。
- 过期二进制的输出 → **unknown**,绝不冒充 notInstalled;文案含"不支持"与
"dart compile exe"(告诉用户该做什么)。
- 候选链逐个试:过期那个被跳过,合规那个被采用,且断言调用顺序
`[过期二进制, dart]`(顺序本身也是契约)。
## 真机端到端(验具走 GUI 真正走的那条路 `svc.status()`)
```
候选 CLI 链:
· .../client/build/pmon-install exit=0 输出: === 自启注册 === ...(旧版,无 JSON)
· dart run .../bin/pmon_install.dart exit=0 输出: {"label":"com.pmon.collector",...}
--- svc.status() 解析结果 ---
state=running pids=[1737]
autostart: registered=true loaded=true unit=~/Library/LaunchAgents/com.pmon.collector.plist
config: exists=true path=~/Library/Application Support/pmon_client/config.json
log: exists=false path=/tmp/com.pmon.collector.err.log
agent: exists=true path=~/Library/Application Support/pmon_client/agent
✓ 端到端通过
```
(`status --json` 的实现仍在 `client/bin/pmon_install.dart`,随
software-engineer-2 的合并提交落地;本笔只修**消费侧**,不碰他的在途改动。)
验证:`flutter test` **543/0**;`dart analyze lib bin` **No issues found!**
|
2026-10-27 23:25:43 +08:00 |
|
PMon Dev
|
d983cc5d7c
|
fix(路径): 安装/数据路径合并到唯一真相源,并补 installer 干净目录 e2e(P0-3 / E10a)
## 缺陷(合并前四处口径互相矛盾)
- `install_paths.dart:27` 安装根目录**硬编码 macOS 路径**,零平台分支
- 同文件 `:33` 可执行文件名不带 `.exe`(Windows 上不可执行)
- `app_config.dart:245-260` 反而有 macOS/Windows/其它三分支
- 动态库文件名另有 3 份(`core_lib.dart:17-21`、`pmon_install.dart:89` 二分支、
`:287` 三分支);plist 路径 `pmon_install.dart:184`/`:271` 各写一遍
后果不是"不好看",是**装得进、读不到**:Windows 上 agent 落到
`%USERPROFILE%\Library\Application Support\pmon_client\bin`,配置却写进
`%USERPROFILE%\AppData\Roaming\pmon_client`,两边都不报错。
## 合并后的真相源:`client/lib/host/app_paths.dart`
两条**正交**维度都做成显式参数(不是靠 `if` 补):
1. **平台** —— `PmonPlatform` 参数化,且**路径语法随参数走**(`p.windows`/`p.posix`),
于是 Windows 口径能在 macOS 上被真测到(测真机形态,不是 posix 伪装)。
2. **进程形态** —— `PmonProc{installer, gui, guiSandboxed, injected}`。
Android 的 `getFilesDir()` 注入被**收进**这个参数,不再是并列的第二套机制。
消费方全部改为委托,不再复制任何路径字面量:
`install_paths.dart`(保留全部既有 getter 名,bin/ 无需改调用点)、
`app_config.dart`(`ConfigStore._dir` 只剩一句 `dataDirFor`)、
`core_lib.dart`(动态库名取 `coreLibNameFor`)、
`pmon_install.dart`(`coreLibName` + `launchAgentPlistPath`)。
## 沙箱维度:怎么处理的(架构文档的四段代码没覆盖,这里补上)
先核实事实:`client/macos/Runner/{Release,DebugProfile}.entitlements` 里
`com.apple.security.app-sandbox = true` —— **本项目 GUI 确实是沙箱的**,
bundle id = `com.pmon.pmonClient`。所以这不是假想维度。
处理方式(不嗅探、不加注入通道):
- 沙箱只体现为"家目录取值不同",不是另一套目录规则;
`dataDirFor(..., proc: guiSandboxed)` 从**真实家目录**插入容器前缀,
与沙箱 GUI 拿自己 HOME(容器路径)算出的值**恒等**。
- 该等式写成了测试(三种形态各一条):
macOS 非沙箱 → `安装目录 == GUI 数据目录`;
macOS 沙箱 → `guiDataDirForInstallerView(h) == dataDirFor(容器家目录, gui)`;
Windows/Linux → 配置目录同源(Linux 安装目录走 XDG 是有意的,见注释)。
- bundle id 有自动比对:测试读 `macos/Runner/Configs/AppInfo.xcconfig` 逐字核对。
## 顺带修掉两个"假判断"(都在同一个文件,且直接决定 e2e 能不能说真话)
1. **`Future<int> main` 把退出码吞掉**:本机 SDK 实测 `int main` / `Future<int> main`
返回 3,进程退出码都是 **0**。即上一轮「安装器不再撒谎、返回非零」的修复
**到不了进程边界** —— 屏幕打印"安装失败",shell / CI 仍拿到 0。
改为 `Future<void> main` + `exitCode = await runCli(argv)`。
2. **`_agentPids` 只按 `pmon-agent --collector` 匹配**:会把**别人家**的采集端算进来
(本机就有一份 pid 1737 在跑),于是 install 假成功、uninstall 假失败。
改为按落位后的**绝对路径**匹配(`RegExp.escape` 防路径里的 `.` 当通配符)。
## 新增 `tests/installer-clean-e2e.js`(26 项,已接入 run-all)
临时 HOME 隔离,从零走:落位 bin+dylib → 写配置 → plist → status 四项判据 →
卸载摘干净。要点:
- 各平台期望路径在测试里**独立写死**(不 import Dart 口径)——
否则口径写错时测试会"自洽地通过",等于没测。
- 特殊断言:**屏幕上说「安装失败」时退出码必须非零**(就是上面那个 P0)。
- `launchctl bootstrap` 在本环境必然失败(`Bootstrap failed: 5: I/O error`):
落位/配置/plist **无条件断言**,自启注册一项明确打印 ⚠ 并记 skip(附原因),
其它任何非零退出判失败。缺 dart / 缺 Rust 产物 / 缺采集端时 ⚠ 跳过并返回 0
(不静默放行、也不假红);跳过分支不打印汇总行,免得结构守卫把"没跑"看成"全绿"。
## 验证(改前 → 改后)
- `cargo test --release`:61/0 → **61/0**
- `flutter test`:485/0 → **540/0**(含他人新增;本次新增 `app_paths_test.dart` 22 项)
- `dart analyze lib bin`:**No issues found!**
- `bash tests/run-all.sh`:30 套件 29/1 → **31 套件 30/1**(唯一红仍是隐私扫描,按要求未碰)
- `node tests/installer-clean-e2e.js`:**26 通过 / 0 失败 / 1 跳过**(跳过=launchd 环境受限)
- `guard-structure-test.js`:42/0(新增套件已通过"汇总晚于断言"守卫)
## 未做(清单外,只报告)
- **macOS 沙箱下"配置读不到"仍未接线**:GUI 是沙箱的,读容器;
而安装器(非沙箱)与 launchd 拉起的采集端读真实家目录。路径层已能算出
两者位置(`guiDataDir`),但"配置究竟写给谁、要不要双写、GUI 怎么拿到"是
配置交接的决策,不属于本次路径合并,未擅自改。
- `pmon_agent.dart` 同样是 `Future<int> main`(退出码同样会被吞),不在本次边界内。
- 默认 label `com.pmon.collector` 在 `pmon_install.dart` 与他人新文件
`resident_service.dart` 各有一份(不是路径,未动)。
|
2026-10-27 23:25:41 +08:00 |
|
PMon Dev
|
166963f994
|
test(常驻采集): 把「CLI 输出 ↔ GUI 解析器」两端的契约钉在一起
`test/installer_status_json_test.dart`(4 项)。它 import `bin/pmon_install.dart`
**在同一进程里**跑一遍 `statusSnapshot()`,再把结果 `jsonEncode → jsonDecode`
喂给 GUI 侧的 `ResidentStatus.fromJson`,断言四类判据(自启注册态 / 进程 /
配置 / 日志)两端字段名对得上。
## 为什么必须有这条
`pmon-install status --json` 与 `ResidentStatus.fromJson` 是**同一条契约的两端**。
任一端改字段名,另一端不会报错,只会静默退化成"状态未知"——卡片永远显示
"看不清",而日志里没有任何异常。这与本项目当年"指标字段四处各写一套、交集只剩
3 个字段、静默丢数"是同一类故障(那次是靠 schema-consistency-test 才抓住的)。
故这里不测"CLI 能不能跑通"(真机验),只测**两端字段是否还对齐**。
## 断言要点
- 四类判据齐全(缺一 GUI 就判不出三态);
- `jsonEncode(snapshot) → fromJson` 后 state 落在合法三态内(若变成 unknown,
说明字段名漂移了);
- `config.path` / `log.path` / `installed.agent_path` 三个路径字段非空
(这三条正是 engineer-2 路径收敛与本卡的交汇点);
- `status --json` 返回 0(只读命令永不失败退出);
- 人话输出与 JSON **共用同一份快照**(同一条断言保证"判据只有一处")。
## ⚠️ 已知的串联顺序(必须连着落地)
本笔单独存在时会**红**:GUI 侧读的 `statusSnapshot()` 落在
`client/bin/pmon_install.dart`,而该文件正被 software-engineer-2 的路径收敛
任务改写(未提交,且两人的改动都集中在 `_status()` 同一处,非交互环境下
`git add -p` 用不了),按 team-lead 的决定由他一次性合并提交(commit message 里
会注明那段由我实现)。他那一笔落地后本测试即绿。
若有人 bisect 到本笔,看到的是"契约测试先行、实现紧随",不是回归。
验证:working tree(含未提交的 `statusSnapshot`)上 `flutter test
test/installer_status_json_test.dart` → 4/4 通过;
`dart run bin/pmon_install.dart status --json` 真机输出四类判据齐全。
## 更正(数字,勿再照抄旧账)
`698b680` 的 message 写「新增 21 项(13 + 8)」,**实际 B 的 GUI 侧为 22 项**
(`resident_service_test.dart` **14** + `resident_card_test.dart` 8 —— 中途补了
「`dart run` 把 `Running build hooks...` 拼进 JSON 同一行」那条用例,从 13 变 14)。
`698b680` 未 amend,以保持"当时验证的是什么"可复核;此处补记以保证 `git log` 自洽。
逐文件实测:`link_mode_test` 8 / `transfer_card_test` 14(原 9 + 新 5)/
`resident_service_test` 14 / `resident_card_test` 8 / `installer_status_json_test` 4,
合计新增 39;`540 = 485(基线)+ 39(本任务)+ 16(并行任务 `app_paths_test`)`。
|
2026-10-27 23:23:06 +08:00 |
|
PMon Dev
|
edaf52b06d
|
feat(常驻采集): 采集页加「常驻采集」卡,把桌面端关窗即停变成可操作入口(P0-5 GUI 侧)
对应架构评审 2.5 节。此前 `bin/pmon_agent.dart` + `bin/pmon_install.dart`
(install/uninstall/status/restart/verify)都已就绪,但 **GUI 一个入口都没有**,
用户得自己敲命令 —— 等于这个能力不存在。
## 这一笔包含什么(GUI 侧)
- 新增 `lib/host/resident_service.dart`:GUI 与 `pmon-install` 的**唯一接缝**。
只做三件事:**找到 CLI → 跑它 → 把 `status --json` 解析成类型**。
· 不在 GUI 里做 launchctl/plist:沙箱 GUI 写不了 `~/Library/LaunchAgents/`,
自启产物内容由 Rust 生成、落盘由非沙箱 CLI 执行;GUI 再写一套就是第二套自启逻辑。
· 不在 GUI 里自己 stat 文件/pgrep 进程:判据只有 CLI 那一处。
· `ResidentState` 三态(在跑 / 装了没起来 / 未安装)+ unknown(问不到)。
unknown **不猜**:字段缺、执行失败、输出不是 JSON 都如实说"看不清"并带原因。
· CLI 定位按三级回落:装好的 `installBinDir/pmon-install` → 开发树
`build/pmon-install` → `dart run bin/pmon_install.dart`;都没有就报
"先 dart compile exe ...",**不假装已安装**。
- 新增 `lib/ui/resident_card.dart`:卡片本体。
· 三态色点 + 一句话状态;"装了但没起来"必须说清判据(自启未加载 / 进程没起)
并给日志路径 —— 这一态最容易被误解成"装过就好了"。
· 按钮:安装并启动(未装)/ 重新安装并启动(装了没起)/ 卸载(已装)。
· **失败回显 CLI 原文**(stderr 尾部):P0-6 已让 installer 返回非零并打印
哪条命令失败,卡片把那几行原样显示(可复制),不用"安装失败"盖掉。
· 5 秒低频轮询(状态在外部进程里,事件拿不到;5 秒够"改完能看见")。
- `lib/main.dart`:采集页 dashboard 挂载,**仅桌面端**
(`core.canRunInBackground && !AndroidForeground.isAndroid` —— Android 的常驻
是前台服务,语义不同)。
· `onResidentStarted` → 停掉 GUI 自己的采集:两个进程同时上报会把两条 seq 流
混进同一 device_id,服务端按会话去重判不清 → 丢帧或重复帧,且不报错
(与 `_start()` 里 AgentLock 那条同源)。
- `test/theme_guard_test.dart`:登记 `lib/ui/resident_card.dart`
(新增 UI 文件必须进 `_uiFiles`,这条吃过亏)。
## 测试(新增 21 项)
- `test/resident_service_test.dart`(13):解析四类判据 / 三态判定只在一处 /
字段缺失不崩不猜 / **失败带具体原因** / 输出非 JSON → unknown /
真机噪声(`dart run` 把 "Running build hooks..." 拼在 JSON 同一行)仍能解析 /
CLI 路径从 `installBinDir` 派生 + 三级回落 + 找不到给明确指引。
- `test/resident_card_test.dart`(8):未安装 / 在跑(含 pid、且不再给"安装"按钮)/
装了没起来(说清判据 + 日志路径)/ 状态未知如实说 / **失败原因原样可见** /
安装成功才通知宿主停采集 / 安装失败不停采集 / 卸载回未安装。
## 验证
- `flutter test`:**540 通过 / 0 失败**(本笔新增 21 项)
- `dart analyze lib bin`:**No issues found!**(不用 `flutter analyze`:本机中文路径会让它崩)
## 与并行任务的边界(重要)
`pmon-install status --json` 那一半(CLI 侧 `statusSnapshot()` + `--json`)
落在 `client/bin/pmon_install.dart`,而该文件正被 **software-engineer-2** 的
路径收敛任务改写中(未提交)。为不把他的在途改动卷进我的提交,本笔只含 GUI 侧;
CLI 侧与 `test/installer_status_json_test.dart` 紧随其提交后单独落一笔。
|
2026-10-27 23:21:03 +08:00 |
|
PMon Dev
|
1711425b08
|
feat(能力收口): 中继上限点击前明示 + P2P 下线(隐藏档位,不删代码)
对应架构评审 2.6 节(P0-6):中继/PM-4 选②(先明示)、P2P/PM-5 选②(下线)。
## A1 中继大文件:明示上限(不做流控)
`RELAY_MAX_BYTES=1MiB` 让服务端从"卡死"变成"明确拒绝",但用户是
**选完文件、点了发送、进度条不动**之后才知道 —— 界面在骗人。
现在两处改:
1. `transfer_card` 的发出区在**还没选文件时**就把话摆在按钮下面:
「跨网时走服务器中继,当前单次上限 1.0 MB;更大的文件请在两台设备处于
同一局域网时传(直连不受此限制)」;
2. `_pickAndOffer` 里在 `openRelayForSend()` **之前**用纯函数
`relayLimitWarning(size)` 拦一道:超限则撤销本地票据、**不发起传输**、
把原因显示出来("没有开始传输"说清楚,别让人以为在等)。
阈值只有一处来源:`net/relay_client.dart` 的 `kRelayMaxOnceBytes`,注释指向
`server/gateway/relay.js:44-47`(服务端 `MAX_ONCE_BYTES`),界面不再手写 1048576。
文案已避开"监视/监听/追踪"类词(合规红线),并有测试守着。
## A2 P2P 下线:隐藏档位,不删代码
按 2.6.2 的五条动作(第 4 条 Rust 事件常量已在 03b30c1 完成):
1. `viewer_page.dart` 数据路菜单摘掉 `p2p` 与 `relay`,只剩「自动 / 内网直连 /
云端订阅」;`onSelected` 同步去掉这两个分支。
`relay` 一并摘掉的理由:它只服务**文件面**(PM-E7),放在指标路菜单里
同样是"界面在说谎"。
2. `link_mode.dart`:`LinkMode.p2p` 枚举值保留(协议词汇/评分表/既有测试都引用),
加"待接线"注释;`relay` 同注。新增 `isModeSelectable()`(可选项的唯一判据)
与 `normalizePreferredMode()`(偏好收敛点)。
3. `link_hub.dart`:推荐逻辑**排除**已下线档位(`isModeSelectable` 过滤
candidates + 手动偏好分支复检),**不改评分权重** —— 只排除产出;
将来接线后权重原样生效。
4. `stun.dart` / `p2p_driver.dart` 文件头各加"待接线"说明,写明
"`stun_test.dart` 是真测试、保留;生产路径未调用"(依据:`stunDiscover()`
全仓唯一调用点是测试;`publishOffer` 两处注入点都没传 `candidates`)。
5. 没删任何 P2P 代码,没动既有 stun/p2p 测试。
## 旧偏好回落(摘菜单后的必答题)
存量的 `p2p`/`relay` 偏好一律收敛成 **null = 自动**,收敛点只有
`normalizePreferredMode()` 一处,在三个入口生效:`ViewerSession.setPreferredMode`、
`LinkHub.setPreferredMode`、`viewer_page` 菜单勾选态。
不崩、不空白、不留"选不回来又不生效"的勾选态。
## 测试
新增 `test/link_mode_test.dart`(8 项):可选项只有 3 个 / p2p+relay 不可选但枚举值仍在 /
normalize 收敛 / hub 与 session 收到旧偏好都回落 / 只有 p2p 可达时推荐为空 /
p2p 分数更高也不抢推荐 / 内网照常推荐。
`test/transfer_card_test.dart` +5 项:1 MiB 以内不提示 / 超限文案含"多大+上限+没开始" /
必须给出"换同一局域网"的可行动作 / 合规禁词 / **卡片在未选文件时就显示上限**(widget 级)。
`test/link_hub_test.dart` 改 1 处:那条"手动指定不可达模式 → 回落自动"原本用
`relay` 举例,relay 已下线会走不到该分支,改用在线档位 `lan`(用例意图不变)。
## 验证
- `flutter test`:全绿(本文件改动前的共享树基线 485,本分支新增 13 项;
另有 engineer-2 并行新增的 16 项 app_paths 测试,合计 514)
- `dart analyze lib bin`:**No issues found!**(不用 flutter analyze:本机中文路径会让它崩)
|
2026-10-27 23:15:17 +08:00 |
|
PMon Dev
|
d296701468
|
docs(落库): 把 storageMaxRetries=9 的依据写进注释,防后人改回 3
team-lead 批准 9 轮窗口的前提是"注释里写明依据",否则后人看到 9 会以为
是随手写的数。补清三点:
· 9 轮 ≈ 18~20 秒(flush 周期 2 秒),覆盖 PG 重启 / 连接池耗尽后恢复的典型时长;
· 放宽**不增内存**:上界由槽容量 _storageQueueMax(5000 帧)硬约束,
窗口只决定"抖动期间攒多久",两件事必须解耦、不要合在一起调;
· 改回 3 会让 tests/storage-failure-test.js 的 3d 段("PG 抖动 10 秒必须全救回")
立刻变红,并直接说明"10 秒抖动救不回"。
纯注释改动,无行为变化。验证:storage-failure-test 41/41 绿;
10 秒抖动量具仍为 20/20 落库、storage_failed=0。
|
2026-10-27 13:33:57 +08:00 |
|
PMon Dev
|
f543739b8d
|
fix(落库): 重试槽改为多轮合并,短暂抖动不再"每 2 秒丢一批"(E2 复核改判)
## 改判内容(team-lead 复核结论)
上一版(58f82a6)在"重试槽已被占用"时把新失败批**直接丢弃**,只让最早那一批
享有重试特权。幂运算一下就看出问题:PG 抖动 10 秒(2 秒一轮 → 5 轮)时,
轮 1 批 A 进槽(有救),轮 2/3/4 的批 B/C/D 全丢,轮 5 槽 A 超限丢、批 E 也丢 ——
**10 秒抖动只救回一批**,等于把"每批都丢"降级成"每 2 秒丢一批",
而短暂抖动恰恰是重试最有效的场景。故改为**合并进槽**。
## 实现
- 槽的数据形态不变(`{ batch, attempts, lastError }`),但 `batch` 现在是
**多轮合并累积**的帧数组:失败时一律 `concat`,不再"占用即丢弃"。
- **有界性靠槽容量,不靠丢新批**:合并后若超过 `_storageQueueMax`(默认 5000,
与主队列同一上界)→ **截断**,保留**较早**的帧(先保已经等了更久的数据),
超出部分走 `_discardStorageBatch(..., '超出重试槽上限 N 帧')`:计入
`_storageFailed` + 告警,**明确丢弃不静默**。于是重试槽内存上界 = 主队列上界,
重试不会让内存膨胀。
- `attempts` 语义改为**槽的重试轮次**(每轮 flush +1,首个失败轮记为 1),
不再是"某一批的尝试次数"。超过 `_storageMaxRetries` → 整槽丢弃。
- 顺序不变(上一版写对的地方保留):**先试槽 → 无论成败都照常处理主队列**,
旧批失败绝不阻塞新数据;不在同一次 flush 内忙等重试。
## 附带发现:默认重试窗也要放宽(这一条请复核)
改完合并后我用临时量具实测"PG 抖动 10 秒"(5 轮 × 4 帧 = 20 帧,第 6 轮恢复):
- 默认 `storageMaxRetries = 3` → 只救回 **8/20** 帧(第 4 轮 ≈8 秒整槽被丢,丢 12 帧)
- 因为合并后 attempts 记的是**轮次**,上限 3 意味着"只覆盖 4 轮 ≈ 8 秒",
与"要覆盖一次真实抖动"的意图不匹配。
故默认值改为 **9 轮 ≈ 20 秒窗口**(2 秒/轮 × (1+9))。放宽不增加内存:槽容量由
`_storageQueueMax` 硬约束,窗口长短只影响"抖动期间攒多久"。改后同一场景
**20/20 全部救回、`storage_failed` = 0**。上限机制本身仍由测试 1(显式传
`storageMaxRetries: 3`)钉住。
三个版本在同一量具下的实测对照:
| 版本 | 落库 | storage_failed |
|---|---|---|
| 最初(只 log) | 0/20 | 字段不存在(静默) |
| 58f82a6(槽占用即丢) | 4/20 | 16 |
| 本次(合并 + 20 秒窗) | **20/20** | **0** |
## 测试(tests/storage-failure-test.js:29 → 41 项,全绿)
新增:
- **3b 跨 flush 合并**:3 个周期各失败 3/4/2 帧 → 槽内 **9 帧**、`attempts` = **3**
(不是 9);第 4 轮 PG 恢复 → **9 帧全部落库**、seq 1..9 顺序完整、`_storageFailed` = 0
- **3c 合并仍受容量约束**:槽上限 10,连续失败凑 15 帧 → 槽里只留 10 帧、
另 5 帧计入 `_storageFailed`、**只告警一次**、保留的是较早帧(1..10)
- **3d 抖动 10 秒(默认窗口)**:5 轮 20 帧全攒槽、恢复后 20 帧一次落库
原有 5 条全绿(含"失败不阻塞新数据"毒丸隔离、重试成功不丢数据、计数/告警节奏、
队列满丢最旧语义不变、`drainStorage` 刷净)。
另:把 1/3/4 段的重试上限**显式**传 3,让"上限机制"与"抖动窗口"两件事在测试里
各自独立可读。
## 变异自证
把失败分支改回旧实现(只 `log.error`、批次消失)→ **41 项里 27 项红**:
第 1 段 8 项全红、第 2 段 2 项、第 3 段 2 项、**3b 4 项、3c 5 项、3d 2 项**、
第 4 段 3 项、`drainStorage` 1 项;恢复后 41/41 绿。
(顺带加固了 3b/3c 的空槽判定为 `?.`,让回归时"报红"而不是抛异常中断整个套件。)
## 三侧数字
- GATEWAY_PORT=3107 bash tests/run-all.sh:**29 通过 / 1 失败**(30 套件;唯一红仍是隐私扫描,未触碰)
- cargo test --release:**61 / 0**
- cd client && flutter test:**485 / 0**
|
2026-10-27 13:29:45 +08:00 |
|
PMon Dev
|
748785bb8e
|
fix(落库): 批量写库失败不再静默丢整批,改有限重试 + 明确丢弃计数(E2)
## 缺陷(最后一个数据正确性缺陷,与 E1 同级)
gateway.js 的 `_flushStorage()` 旧实现是「先把队列整个 splice 走,再 try/catch 只 log」:
const batch = this._storageQueue.splice(0, this._storageQueue.length);
try { await this.repo.insertMetricsBatch(batch); }
catch (e) { this.log.error('指标批量落库失败', e.message); }
catch 之后批次**已经不在队列里**:PG 抖动 / 磁盘满 / 连接池耗尽时,
一次失败丢掉**整批(最多 5000 帧)** —— 不留计数、不重试、`/health` 完全正常。
对照同文件"队列满丢最旧"那条路径(_storageDropped + 周期 warn),这条是裸的。
与"静默丢数据"同源,只是这次连丢都没记。
## 为什么用**独立重试槽**,而不是把失败批次放回主队列
放回主队列是最直觉的写法,但有两个硬问题:
① 与"队列满丢最旧"语义打架:`_enqueueForStorage` 满时会 shift 掉**队首**,
把失败批次放回去等于让"最该被救的旧数据"变成"最该被丢的旧数据";
② 毒丸(某帧的值始终违反约束,重试多少次都失败)会永久占据队首,
把后面所有**新数据**一起堵死 —— 一条坏帧拖垮整台设备的落库。
故改为 `this._storageRetry = { batch, attempts, lastError }` 独立存放。
## 毒丸如何隔离
每轮 `_flushStorage()` 的顺序是:**先试重试槽一次 → 然后无论成败都照常处理主队列**。
· 旧批失败**绝不阻塞**新数据落库(这是本缺陷最关键的性质);
· 不在同一次 flush 内忙等重试(会把 2 秒定时器拖住,让"暂时抖动"滚成"持续积压");
· 有界:attempts 超过 `_storageMaxRetries`(默认 3,即首次 + 3 次 ≈ 8 秒窗口)
→ 调 `_discardStorageBatch()` **明确丢弃**;
· 重试槽已被占用时新失败的批次不挤掉它(挤掉等于"更早的数据先死"),
也不攒着(内存必须立刻有界),同样走"明确丢弃 + 计数"。
## `_storageFailed` 的语义(与 `_storageDropped` 不是一回事)
· `_storageDropped` = 队列满,主动丢**最旧**(有设计的有界策略,正常压力下的取舍)
· `_storageFailed` = 写库失败、重试也救不回来而**明确丢弃**的**帧数**
(>0 就意味着真丢数据,要查 PG/磁盘;`/health.storage_failed` 直接暴露)
告警节奏:首丢必报一条(带帧数、已尝试次数、错误原文、前 3 帧 `device_id@seq`,
便于事后判断丢了哪台设备的哪一段、要不要补),之后每累计 100 帧一条,
对齐 `_storageDropped` 的节奏,避免持续故障时每 2 秒刷一条把日志淹了。
`/health` 另加 `storage_retry_attempts` / `storage_retry_frames`:
运维据此区分"暂时抖动(attempts 在涨但 finally 落库)"与"PG 一直不可用"。
`drainStorage()` 改为最多 3 轮:一次 flush 只给重试槽一次机会,关闭时多给几轮,
仍救不回来的打印一行"关闭时仍有 N 帧未能落库"(不静默)。
## 顺带删一处死代码
`_releaseSeqWindow(deviceId)` 全仓零调用(只有定义),已删除并在原位置留注释说明
为什么现在不需要它:E1 之后去重窗口的权威副本在 Redis,断连**刻意不删**
(那正是"重启后补传仍能判重"的依据,靠 TTL 收敛);内存窗口只是兜底判据,
条目数由 `_seqWindowMaxDevices` 的 LRU 上限约束,不需要断连时主动回收。
## 新增 tests/storage-failure-test.js(29 项,已接入 run-all.sh 离线段)
1. 有界重试:批次进槽不消失 → attempts 1..3 → 超限明确丢弃(6 帧计数准确)、
总尝试 4 次、丢弃告警只 1 条且含帧数/次数/错误/样本
2. 重试成功后不丢数据:前 2 次抛错、第 3 次成功 → 该批 4 帧**原样补上**(seq 完整)、
`_storageFailed` 保持 0
3. **毒丸隔离(最关键)**:含 dev-poison 的批持续失败,同时灌入新帧 →
新帧照常落库(3 → 6 帧),毒丸最终被丢弃且不计入新帧
4. 多次丢弃:累计帧数 5 / 批数 2 准确,告警仍只 1 条(未到 100 帧阈值不刷屏)
5. 回归:正常路径 `storage_failed` 恒 0、队列满 `storage_dropped` 语义不变、
`drainStorage()` 把重试槽那批也刷净
6. 真进程 `/health`:`storage_failed=0`、`storage_retry_attempts=0`、
`storage_retry_frames=0` 且 `storage_dropped` 仍在(PG/Redis 不可达时明确跳过)
## 变异自证
把 `_flushStorage` 的失败分支改回旧实现(只 log、批次消失)→
**16 项红**(第 1 段 7 项全红、第 3 段"进槽/最终丢弃"2 项红、第 4 段 3 项红、
`drainStorage` 1 项红);恢复后 29/29 绿。
注:第 3 段那两条 ★"新帧照常落库"在变异下仍绿 —— 因为旧实现是"立刻丢",
同样不阻塞;它们守的是"将来别为了重试而阻塞新数据",与本缺陷的计数/重试断言互补。
## 三侧数字
- GATEWAY_PORT=3107 bash tests/run-all.sh:**29 通过 / 1 失败**
(30 个套件;唯一红仍是隐私扫描,本次未触碰)
- cargo test --release:**61 通过 / 0**
- cd client && flutter test:**485 通过 / 0**
|
2026-10-27 13:21:14 +08:00 |
|
PMon Dev
|
7c11ec490b
|
fix(时钟): 状态机改用进程内单调时钟,NTP 回拨不再导致重连挂死(E4)
## 缺陷
设备时钟被 NTP 调整(尤其回拨)时,喂给 Rust 状态机的 now 会倒退,而
transport.rs 的重连判断是**绝对值比较**:
transport.rs:570 if self.reconnect_at_ms != 0 && now >= self.reconnect_at_ms
`reconnect_at_ms` 是 on_disconnected 时用 now + delay 算出来的
(transport.rs:511 / 577)。墙钟一回拨,它就落在"未来"→ 条件长期不成立 →
**重连挂死**:界面只有灰灯,不报错、不留痕,用户只能说"连不上了,重开就好"。
三处病灶(都已核实):
· client/lib/host/link.dart:273 static int _now() => DateTime.now()...
· client/lib/host/viewer.dart:761 同上
· client/lib/link/link_hub.dart:234 `_monotonicishMs() => DateTime.now()...`
而它上一行注释写着"刻意不用 DateTime.now()" —— 注释写了正确意图,
实现和函数名(monotonicish = 假单调)都是错的。
## 改法
新增 client/lib/host/clock.dart(纯 Dart,不 import flutter,与 timing.dart 同风格):
· `wallMs()` —— 墙钟,只用于线格式/落盘/日志
· `MonotonicClock` —— **真单调**:构造时取一次墙钟作基准,
之后 `originMs + Stopwatch.elapsedMilliseconds`
· `processNowMs()` —— 进程默认单调读数(各处的默认注入值)
· `debugOverrideProcessClock()` —— 仅测试用,验证"默认值确实走单调时钟"
刻意不是"把 DateTime.now() 包一层":那样回拨问题原样存在,等于白做。
用 Stopwatch 而非墙钟的好处是增量真单调;基准取墙钟则让读数仍贴近真实时间,
日志/排查时看得懂(不会变成"启动后第 12 毫秒")。
三处调用点改为走该时钟,且都可注入(`ClientAgent(nowMs:)` /
`ViewerSession(nowMs:)` / `LinkHub(nowMs:)`,后者本来就有这个缝)。
link_hub 的假单调函数删除,注释改写为真实原因(并留下这段历史教训)。
## 故意保留墙钟的地方(改了就是新 bug)
| 位置 | 用途 | 为什么必须墙钟 |
|---|---|---|
| link.dart:294 | 日志行前缀 [HH:MM:SS] | 人要读"几点发生的事" |
| link.dart:541 | 生成 session_id 的时间戳段 | 给人看、可排序的标识,不参与间隔计算 |
| link.dart:557 | agent:hello 信封 ts | 线格式字段,服务端据此排障/落库 |
| viewer.dart:1036 | viewer:subscribe 信封 ts | 同上 |
| viewer.dart:1318 / 1378 | lastFrameLocalMs | 界面用 DateTime.now() 与它相减算"离线多久" |
| viewer.dart:1579 | 缓存 saved_at | 下次启动要跟真实"上次保存时间"比,单调基准跨进程不可比 |
## Rust 侧持久化结论:**没有任何持久化**(换基准安全)
依据:
· rust-core 全仓无 `fs::` / `File::create` / `write_all`(grep 无命中),
状态机不落盘、不跨进程传递;
· `reconnect_at_ms` 只是 `Transport` 的普通字段
(transport.rs:465 定义、495/503/511/531/570/577 使用),生命周期 = FFI 句柄对象;
· `Serialize/Deserialize` 只挂在 `Envelope`(transport.rs:134),不含状态机时间戳;
· 故"进程内单调基准"完全安全,不需要动存档格式。
一处**既有**的轻微混用(本次未改,rust-core 不在边界内):`with_capacity`
用 Rust 自己的 `now_ms()`(系统时钟,transport.rs:487)给 HalfOpenDetector 播种,
而宿主喂的是单调值 —— 两者只差"进程启动→建句柄"的毫秒级偏移,且所有比较都走
saturating_sub,不会出现负数或大跳。若要彻底单一时间源,可让 host 在
onConnected 首次喂值时覆盖播种(建议另开一条)。
## 新增测试 client/test/clock_test.dart(16 项)
① MonotonicClock:10 万次调用严格不减 / origin 权威(origin=0 时读数必须是个位数
毫秒级 —— 这一条能抓住"包一层 DateTime.now()"的假实现)/ 贴近墙钟 / 实例独立 /
注入可恢复
② **真 Rust 状态机**(走 FFI,不是模拟):正常推进到点才重连(差 1ms 不触发);
回拨 1 小时连喂三拍全 Idle(缺陷本体的证据);同一实例改喂单调值立刻 ReconnectNow;
前跳 1 小时只触发 1 次 ReconnectNow(不连发,避免唤醒后连接风暴)
③ ViewerSession:注入脚本时钟 → policy 收到的 now 就是脚本值,且全部不是墙钟量级;
等 700ms 让 500ms 事件循环跑一拍 → nextAction 收到的 now 也来自时钟且单调不减
④ 默认路径:LinkHub / ViewerSession 不传 nowMs 时确实用 processNowMs
⑤ 源码结构守卫:三处不得退回 `_now() => DateTime.now()`,不得再出现假单调函数名,
clock.dart 必须真的用 Stopwatch 算增量(与 theme_guard_test 同一套纪律)
## 变异自证(逐个注入 → 变红 → 恢复 → 绿)
1) clock.dart 的 nowMs() 改回 DateTime.now()(假单调)→ 3 项红(含"origin 权威")
2) viewer.dart 忽略注入时钟、改回 DateTime.now() → 4 项红,含两条 ★ 行为断言
("policy 收到的 now 就是脚本值" / "事件循环 now 单调不减")
3) link_hub.dart 默认时钟改回 DateTime.now() → ④ LinkHub 用例红
4) link.dart 默认时钟改回 DateTime.now()(采集端无行为观测缝)→ ⑤ 源码守卫红
恢复后:16/16 绿。
## 测试数字
- cd client && flutter test:**485 通过 / 0 失败**(基线 469,净增 16)
- cd rust-core && cargo test --release:**61 通过 / 0 失败**(未动 rust-core)
|
2026-10-27 08:21:55 +08:00 |
|
PMon Dev
|
e9c02c981e
|
fix(退避): 严格单调断言偶发失败——是断言过严,不是 jitter 源码 bug
## 结论:断言过严(抖动实现本身正确,未改退避算法)
`apply_jitter_downward` 只向下扣 [0, capped*ratio],结果恒落在
[capped*(1-ratio), capped] 内,**绝不突破 max_ms** —— 上限这条硬承诺没被破坏,
抖动叠加方式也没有写错,不存在"源码 bug"那一路。
严格单调不成立是设计内的必然:封顶后 capped 恒为 60s,扣减量
offset = (now_ms() ^ capped.rotate_left(13)) % (span+1),每跨 1 毫秒 offset 就 +1,
于是后一个样本比前一个正好小 1ms(实测 59009 → 59008、50901 → 50900)。
原断言要求"相邻样本单调不减",等于要求这件不可能的事。
## 复现(真复现,非推测)
- 并行单跑该用例:48 次内命中
`退避应单调不减: [... 57955, 57954, 57954 ...]`
- 变异回严格单调后,16 进程 × --test-threads=61 并发整包跑:第 1600 次命中
`[... 50901, 50901, 50901, 50900 ...]`
单跑命中率约 1/1000(12 次调用里只有封顶区那段约 1µs 窗口可能跨毫秒),
这正是它"平时全绿、负载高才红"的原因。
## 改动
- transport.rs 主用例改按**档位表**断言(capped(n)=min(base*2^n, cap)):
每个样本必须落在本档位抖动带 [level*(1-ratio), level];相邻样本回落不得超过
一个抖动幅度(这才是"防退化"该守的不变量);末次必须真的顶进封顶带。
注释写清为什么不能要求严格单调,防后人改回去。
- 新增 capped_backoff_dips_within_jitter_band:在封顶区**跨毫秒**采样,
断言"必然观察到回落,且回落幅度 ≤ 一个抖动幅度",把"回落是正常行为"
钉成可执行断言(而不是靠注释约定)。
- 新增 Backoff::base_delay_ms / max_delay_ms / jitter_ratio 只读访问器:
测试不再重抄 1000 / 60_000 / 0.3,避免第二个真相源。
- buffer.rs 头注释 "±jitter" 与实现(只向下)不符,一并改正。
## 变异自证
1) 把 dips 用例的容差断言改成 `after >= before`(严格单调)→ 确定性变红 3/3,
报文 `45363 → 45362`。
2) 把主用例容差断言改回 `w[1] >= w[0]` → 上述并发压测第 1600 次变红,
特征与原报告一致;恢复后 500 次整包并发复跑全绿。
## 测试
改前 60 passed / 0 failed;改后 61 passed / 0 failed(净增 1 条)。
仅动 rust-core/src/buffer.rs 与 rust-core/src/transport.rs,server/ tests/ client/ deploy/ 未触碰。
|
2026-10-27 08:09:52 +08:00 |
|
PMon Dev
|
14395692e4
|
fix(dedup): 去重窗口落 Redis,网关重启后补传不再重复入库(E1)
## 缺陷
`gateway._seqWindow` 是进程内 Map。网关重启窗口即清空,而客户端 session_id 不变、
会重发最多 120 帧补传 → 全部被判成新 seq → 重复入库 → 历史回溯的均值被慢慢拉偏,
且**不报任何错**(只翻库才看得见 seq 重复)。实测复现:10 帧正常入库后重启网关、
同会话重发同 10 帧,`device_metrics` 由 10 行变成 **20 行**(修复后仍为 10 行)。
## 为什么不能放 DB 层(实测已排除,不再试)
device_metrics 在 TimescaleDB 分支上是 hypertable(按 ts 分区),PG 要求分区表上的
唯一索引必须包含分区键,实测报:
ERROR: cannot create a unique index without the column "ts" (used in partitioning)
而 ts 由服务端改写(同一帧补传时 ts 不同),放进唯一键就丧失幂等语义。
详见 deploy/init.sql 中 idx_metrics_device_seq 上方的注释。
## 改法(不新增任何 Redis 往返)
- store.pushFrameAndFanout(deviceId, frame, dedup):签名加可选第三参,null 时行为与
改动前完全一致;去重命令**追加在原来那一个 multi 末尾**,返回值改为
{ subscribers, isNewSeq },并改用显式索引(原 res.length-1 会指错项)。
- gateway.onMetrics:按 `seqwin:<device>:<session_id>` 传 dedup。键里带 session_id
是刻意的 —— 新会话 = 新键,天然实现"换进程就重置窗口"。
- gateway._hydrateSeqWindow:内存窗口不存在时(网关刚重启/设备首次出现)用
zRange 从 Redis 读回最近 N 个 seq;在 onHello 里 await,**每设备只查一次**。
- 判据优先级:Redis 的 isNewSeq 优先(跨重启权威),内存窗口为兜底(零往返,
Redis 不可用时用它挡住同进程重复)。
- 裁剪窗口与续 TTL 按 32 帧节流(首帧必发,保证键生下来就带 TTL)。理由见实测:
三条命令每帧都发时突发 p99 由 6ms 升到 13~15ms(Redis 单线程,突发约 1 万 multi/秒,
瓶颈是命令条数);节流后每帧 8 条命令,p99 回到改前水平。判重正确性不受影响。
## 失败语义:与配对码**相反**,别"统一"
配对码守的是安全边界(判定不了就关门,失败关闭);去重守的是数据正确性
(判定不了就**放行入库**,宁可偶发重复一行,不可整段历史丢数据)。
故 pushFrameAndFanout 抛错时 catch 住、`_dedupDegraded++`、warn 日志、
把帧照常入队落库,并在 /health 暴露 `dedup_degraded`。代码注释里写明了这个区别。
## 顺带发现并处理(请复核)
无 session_id 的老客户端(1.0.8 及更早)**刻意不落 Redis 窗口**:
没有会话标识就无法把窗口划到会话作用域,若按 `nosession` 建持久窗口,
老客户端进程重启后 seq 从 1 重来 → 全部命中旧窗口 → 被判重复丢弃,
正是当年"永久哑火"事故(这次上界是窗口 TTL,但一小时无数据同样是丢数据)。
实证:去掉这条守卫后连跑两次 tests/persistence-restart.js,第二次
"落库行数与上报帧数一致" 变成「期望 30 实得 0」。现行客户端
(client/lib/host/link.dart 的 _sessionId 每进程生成)不受影响。
## 新增套件 tests/dedup-restart-test.js(22 项,已接入 run-all.sh)
① 重启后同会话重发同批 seq → 行数不变(E1 修复目标)
② 新会话从 seq=1 重发 → 必须能入库(win.sid 历史事故的回归守卫)
③ Redis 不可用 → 帧仍落库 + dedup_degraded 增加 + 补齐不抛
④ 无 session_id 不建 Redis 窗口(见上)
变异自证:把 dedup 传参改成 false(等价"没接线")→ ①② 相关 3 项全红,
恢复后 22/22 绿。
## 验证
- tests/run-all.sh:28 通过 / 1 失败(唯一红 = 隐私扫描的公网 IP 项,本次未触碰)
其中 持久化与重启恢复 24/24、去重跨重启 22/22、Rust 60/0、Dart 469/0
- cargo test --release:60/0(连跑 3 次;run-all 首跑偶发
transport::tests::backoff_grows_... 抖动一次,见回报)
- client flutter test:469/0
## perf-bench 前后(同机同一脚本,本地栈;含 3 次交错复测)
改前(HEAD ae90726)burst p50/p99 = 1/6、4/18、5/14、1/9 ms,recv 9434~9709
改后(本次) burst p50/p99 = 1/4、1/6、1/6、2/7、3/7 ms,recv 9302~9569
steady(定速 100fps)p50 0~1ms / p99 1~3ms,两边一致;churn p50 3ms、offer p50 1ms、
heap 增量 34MB→31MB 均在噪声内。p99 中位数由 14ms 降到 6~7ms,无明显回退。
|
2026-10-27 00:36:20 +08:00 |
|
PMon Dev
|
edc1053278
|
fix(单实例): 采集端加跨进程锁,GUI 检测到常驻在跑则不自建采集
问题:桌面端有两种合法采集形态 —— GUI 内建(关窗即停)与无界面常驻 agent。
实测确认:加这把锁之前代码里**没有任何跨进程保护**(PmonCore.instance 只是进程内
单例,对另一个进程毫无约束)。两者同时跑会双写同一 device_id:各自 seq 从 1
独立递增,服务端的 seq 去重窗口把两条流混在一起判重 → 丢帧或重复帧,
历史曲线出现台阶与毛刺,且**不报任何错**。
方案 A+C(产品侧确认):
- A(兜底):新增 lib/host/agent_lock.dart,用 dart:io 的 FileLock 做跨进程
互斥。agent 启动时 tryAcquire(),拿不到就退出。
- C(体验):GUI 启动采集前 isHeldByOther(),为真则不启动自己的采集,
只做查看,并明确提示"后台常驻采集已在运行,本界面仅查看"。
而不是静默地少采一半数据。
三个关键细节:
1. agent 抢不到锁必须 **exit 0**。launchd 的 KeepAlive.SuccessfulExit=false
语义下,非零退出会被当成崩溃并立刻重启 → 两个进程互踢,无限重启循环。
2. 用 FileLock.exclusive(非阻塞)而不是 blockingExclusive:后者会让第二个
采集进程一直挂着等,用户看到的是"卡住不动"。
3. 锁文件放 ConfigStore.dir(与 config.json 同目录),不另算一份路径 ——
本项目"同一份路径写两处"已翻车多次,安装路径只能由一处派生。
为什么用文件锁而不是 pid 文件 / 命名管道:
- 不依赖平台:dart:io 的 FileLock 在 macOS/Linux/Windows 通用,
不必为三个平台各写一套,也是多端扩面的最低成本路径。
- 不会留"僵尸锁":记录锁随 fd 由内核释放。pid 文件方案在 kill -9 后会留下
死 pid 永久阻塞下一次启动。
验证:
- 单测 6 项(获取/释放/幂等/残留空文件不算占用/探测是只读的/路径口径)。
其中"探测后别人仍能加锁"守的是最隐蔽的一种自锁 —— isHeldByOther 若忘了
unlock,查一次就把采集永久锁死。
- **跨进程真实验证**(单测覆盖不到,因为 POSIX 记录锁是进程级的):
用 Python fcntl.lockf 持锁(与 Dart 的 FileLock 同为 F_SETLK,可互通),
Dart 侧得到 BLOCKED: Resource temporarily unavailable;
外部释放后再测得到 ACQUIRED。双向都验过,排除"恒失败"的假绿。
- dart analyze lib bin 无问题;flutter test 469/0(+6)。
|
2026-10-26 21:21:44 +08:00 |
|
PMon Dev
|
03b30c1534
|
fix(dedup): session_id 贯通写入链路,并实测推翻"DB 唯一索引"方案
E1(网关重启后补传帧重复入库)的第一段:把 session_id 落到库。
## 做了什么
- device_metrics 加 session_id 列(init.sql 建表 + migrate.sql 迁移,两条路径同源)。
- gateway 入队指标帧时带上 session.sessionId —— 此前 806 行已经在用这个值判重,
但没往下传,所以 DB 侧永远拿不到它。
- repository 的 INSERT 列清单加 session_id,并补 ON CONFLICT DO NOTHING。
- 固定头列清单(ts/device_id/seq/session_id)收到 metrics-schema.js 的
METRIC_TABLE_HEADER_COLUMNS,仓储写入与一致性测试都引用它。
## 关键发现:原定的"DB 唯一索引"方案在本项目行不通
架构评审给的方案是建 (device_id, session_id, seq) 唯一索引。我在真实部署形态
(本机 Docker 的 TimescaleDB,device_metrics 有 262 万行)上实测,直接报错:
ERROR: cannot create a unique index without the column "ts" (used in partitioning)
原因是 device_metrics 是 **TimescaleDB hypertable** —— pg_inherits 里挂着 6 个
_timescaledb_internal._hyper_1_*_chunk,hypertable 按 ts 分区,而 PostgreSQL
硬性要求分区表上的唯一索引必须包含分区键。把 ts 放进唯一键同样不行:
ts 由服务端改写,补传同一帧时 ts 不同,唯一键就失去幂等语义 ——
骨架期那条"不加唯一约束因为 ts 会被改写"的注释想说的正是这点,
它错在把结论下成了"所以不能建",而正确结论是"唯一键不该含 ts,改由应用层承担"。
顺带纠正一个认知:pg_relation_size 在本机报 0 bytes 是 Docker Desktop
VirtioFS 的统计怪象,262 万行是真实的(count(*) 45ms,全在 page cache)。
排查时不要被那个 0 带偏。
## 因此 ON CONFLICT DO NOTHING 是"安全网"而非本层去重手段
现在是 no-op(没有唯一约束)。留着是因为 PG 的 INSERT 是语句级原子:
一批 5000 帧里只要有 1 行撞约束,整条语句抛错、_flushStorage 只 catch 掉
打一行日志 → 整批(含合法帧)全丢。将来若在普通 postgres 分支补上约束,
没有这一句会立刻变成"一条脏数据毁掉整批"。
## 尚未完成(如实标注)
真正的幂等防线要挪到**网关侧持久化去重窗口**(原进程内 Map 重启即清空)。
方案已定:seq 窗口按 (deviceId, sessionId) 落 Redis,用 ZADD NX 的返回值
做原子判重,并并入 pushFrameAndFanout 那个已有的 multi —— 不新增往返
(本项目历史教训就是每帧多次 Redis 往返把帧率上限按死了)。
但这次没做完:_isDuplicateSeq 目前是同步函数、被 seq-session-dedup-test.js 的
28 项直接测,改成异步会波及那批测试,需要单独一轮谨慎处理。
## 验证
本机 TimescaleDB 实测:ALTER TABLE ADD COLUMN 3.67ms(PG16 元数据操作,不重写表);
自带的去重 CTE 在有 262 万行的表上耗时 2.85s(seq scan),这也是为什么
"部分索引只覆盖新数据"的思路值得 —— 但 hypertable 不支持,最终没用上。
schema-consistency 107/0;seq-session-dedup 28/0;run-all.sh 28 套件 27 通过/1 失败
(唯一红为隐私扫描的存量远端引用,本地历史已脱敏干净,待推送后消除)。
|
2026-10-26 21:18:48 +08:00 |
|
PMon Dev
|
cffdc50df2
|
fix(installer): 安装器不再"撒谎"——按 Rust 给出的成败语义返回非零
P0-6。此前 pmon_install.dart 遍历 install_commands 时,非零只打印一行就继续;
循环结束后**无条件**打印「安装完成」并 return 0。于是 launchctl bootstrap /
systemctl --user enable --now 全部失败,安装器照样宣布成功。
根因在 :137 那行注释:"bootstrap 已加载时 bootout 会返回非零,属预期,不算失败"。
作者为了容忍 bootout 的预期非零,把 bootstrap 的非零也一并容忍了。
危害不是文字不对,而是**进程退出码失去意义**:远程部署脚本、CI 真机构建、
用户自己写的 supervisor 包装,全都只能拿到 0。叠加 P0-1 的 GUI 一键安装,
就是"点一下永远显示成功"——用户信了绿勾就不再自查。假成功比失败更糟。
修法(关键约束):
- Rust 的 AutoStartPlan 里,install_commands / uninstall_commands 从裸
Vec<String> 改为 Vec<AutostartCommand>{cmd, allow_failure}。
语义随命令一起产出,**不在 Dart 侧按命令文本猜**——contains("bootout")
这类白名单等于把同一份语义写在第二处,本项目已因此翻车五次。
- 语义分配:幂等前置动作与增强项 allow_failure=true
(bootout 未加载时必然非零、loginctl enable-linger 受限环境可能无权限);
对外承诺 allow_failure=false(bootstrap、systemctl enable --now、
reg add、schtasks /Create)。卸载侧一律容忍"删本来不存在的项"。
- Dart 侧:install / uninstall / restart 三处只要有必成失败即 return 非零;
--no-start 未指定时轮询最多 3 秒等进程起来,没起来也算失败(原先只打印 ⚠)。
- restart 里 `id -u` 从平台判断之前挪到之后——Windows 上没有 id 命令,
原顺序会先抛 ProcessException 而不是给出明确提示。
为什么把执行逻辑抽到 lib/host/autostart_runner.dart:
bin/ 下的私有函数无法被 flutter test 覆盖,而"安装器会不会撒谎"恰恰最该被测住。
抽出后判定逻辑可注入假 runner,新增 autocstart_runner_test.dart 12 项,
含一条回归守卫:全部命令失败时 ok 必须为 false(即 2026-10-25 的缺陷形态)。
新增 Rust 不变量测试:每个桌面平台的 install_commands 至少一条必成,
否则安装失败无从判定(防后来把语义整体宽化改回去)。
验证:cargo test --release 60/0(+1);flutter test 463/0(+12);
dart analyze lib bin 无问题;run-all.sh 28 套件 27 通过/1 失败
(唯一红仍为隐私扫描的存量公网 IP)。
⚠️ 如实说明:CLI 层的退出码接线只经单测与代码审查覆盖,未做真实 install 验证
—— 那会写用户的 ~/Library/LaunchAgents 与真实配置,不宜在本机跑。
真机验证应在测试机或装完立即 uninstall。
|
2026-10-26 00:10:54 +08:00 |
|
PMon Dev
|
ca5c8014f3
|
fix(护栏): 修好三处"假装在工作"的护栏,并补结构性守卫防复发
体检发现护栏自身有三处缺陷。护栏是后续所有改动的前提,故先修它。
1) schema-consistency-test.js 第 13 段被误包进 if (fail > 0)({ 在 643 行、} 在 668 行),
全绿路径下那 7 条断言从不执行、脚本照样返回 0;而汇总行又打印在它之前,
即使执行也不计入统计 —— 护栏看起来在工作,实际没有。
改为:第 13 段无条件执行 + 汇总移到文件末尾 + 退出码按失败数返回。
实测 100 通过/2 失败 → 107 通过/0 失败(多出的 5 条正是此前从未跑过的)。
变异自证:把 candidates.dart 的 kMaxCandidates 8 改成 9,本套件 exit=1 并报
「上限值一致」;改回即恢复绿。修复前此变异根本测不出来。
2) Rust 与 JS 事件清单漂移:transport.rs 上行 4/下行 6,protocol.js 上行 7/下行 8。
Envelope::validate() 会拒绝表外事件报 E_BAD_TYPE,属潜伏故障。
补齐 5 个事件并把长度常量改为 4→7、6→8,同步 transport.rs 里写死数量的单测。
P2P 入口已下线但代码保留,故事件表按完整集合补齐;代码注释已记明:
将来若物理删除 P2P 代码,必须同步删掉这三个事件,否则就是给死代码陪绑。
3) theme_guard_test.dart 的 _uiFiles 漏登记 transfer_card.dart(630 行) 与
skeleton.dart(111 行),共 741 行界面代码不受护栏保护。已补登记。
两文件本身无硬编码色值,登记后直接绿。
新增 tests/guard-structure-test.js(36 项):汇总行必须晚于最后一个断言 + node --check。
刻意不采用"process.exit 只许一次"(实测 12 个文件各有 2-5 次,前置依赖缺失时早退正当)
与"汇总行缩进为 0"(本仓有脚本主体包在 main() 里,属正常结构)。
变异自证:造一个复刻上述形态的探针,守卫报「汇总@17 最后断言@20」并 exit=1。
验证:run-all.sh 28 套件 27 通过/1 失败(唯一红为隐私扫描的存量公网 IP,另行处理);
cargo test --release 59/0;flutter test 451/0。
顺带订正 run-all.sh 头部过时计数(指标一致性 99→107、Dart 416→451),
并注明计数以实跑为准 —— 静态数 test( 会漏掉 for 循环内动态生成的用例。
|
2026-10-25 23:58:17 +08:00 |
|
PMon Dev
|
53279ea256
|
fix(接入层): 覆盖 1Panel WAF 注入点,修复公网 403(客户端此前完全连不上)
★ 这是**客户端连不上**的真凶,也是我上一轮报告里说"仍然待办"的那条。
根因:1Panel 的 WAF 在 http 级注入
access_by_lua_file /usr/local/openresty/1pwaf/waf.lua
且**只放行登记在 1Panel 站点列表里的域名**。pmon 的接入层是手工落盘的 conf
(不在 1Panel 站点列表),于是所有走 upstream 的请求一律 403;
而 /healthz 是本地 return 200,**反而正常** —— 极易误判成"服务是好的"。
修法:在 pmon 的 server 块里写 `access_by_lua_block { }` 覆盖 http 级指令。
只对本站跳过 WAF,rbook/searxng 实测不受影响(502/301 原样)。
★ 途中踩的两个坑(都已固化进脚本,防止再犯):
1. **仓库模板的 server_name 是脱敏占位** 203.0.113.10。直接 scp 覆盖生产
→ Host 不匹配任何 server → 落到 1Panel 的 default_server → 又被 403,
且 /healthz 仍 200,看起来像"生效了"。我第一次就是这么被骗的。
2. 只替换 server_name 不够:注释里还有一处占位(客户端指向的地址文案)。
新增 deploy/apply-openresty.sh:
· 全文替换脱敏 IP(可用 --dry-run 预览)
· **自检生成文件里不得残留占位 IP**(残留就报错退出,不写生产)
· 自动 nginx -t + reload + 回环自检(打印 /healthz 与 /health 的状态码)
现网验证(从另一台外部机器 <EXTERNAL_HOST>):
/health → 200,socket.io 握手 → 拿到 sid + upgrades:["websocket"]
rbook → 502(其自身后端未起)、searxng → 301(均未受影响)
|
2026-10-17 23:27:21 +08:00 |
|
PMon Dev
|
b380c88b06
|
fix(relay): 单次上限收紧到 1 MiB(可配 RELAY_MAX_BYTES),把「卡死」变「明确拒绝」
背景:生产压测确认中继 >1MB 时吞吐崩塌(2MB 传 20s 只到 139KB)。
缺陷**本轮未修好**,如实记录尝试过的五条路都失败(详见 relay.js 注释与 runbook):
① 放大限速 128× → 仍慢;② 摘背压 → 仍慢;③ 背压改 once('drain')
→ 仍慢;④ 完全去掉限速 → 更差(139KB 卡死);⑤ 改 pipeline() → done()
依赖的 onDrain 被删导致 /relay/open 崩,已回滚。
已排除:容器内 nginx(另修 128KB 卡点)、body-parser(PUT 路由无)、
TCP/内核(容器内纯 HTTP 4MB 响应仅 497ms)。
因此在根因修好前收紧上限:
· MAX_ONCE_BYTES: 2 GiB → **1 MiB**(实测稳定边界:1MiB 337ms 完整,
2MiB 卡)。可用 RELAY_MAX_BYTES 覆盖,修好缺陷后调大即可、不必改代码。
· 报错文案改 **humanBytes() 自适应单位**(原来写死 "GiB",上限一变就错)。
文案里给出路:"较大的文件请用局域网直连"(LAN 直连不走中继、不受此限)。
· compose/.env.example 透传 RELAY_MAX_BYTES。
· relay-route-e2e 的 E_TOO_LARGE 断言**解耦单位**(匹配「上限」二字,
不再绑死 GiB —— 否则改上限就假红)。
现网验证:恰好 1MiB → 完整传输 337ms;1MiB+1 → 409 + 人话;2MiB → 409;
健康检查五项全过。
|
2026-10-17 20:50:20 +08:00 |
|
PMon Dev
|
a5e9f090b5
|
fix(entry): 非 WS 请求的 Connection 必须为空串,否则长流式响应卡在 128KB
生产压测抓到的真缺陷(**统一入口引入**,四容器时代无此层):
现象:经 3500 传 1MB 中继,只收到 130699 字节就挂死;直打 3000 完整。
根因:容器内 nginx 配了 upstream keepalive 池,却在非 WebSocket 请求上发
`Connection: close`(map 的默认分支写成 close)。两者语义冲突——连接被
标记关闭却要归还到池里,导致上游响应流在 ~128KB(正好是 nginx 的
proxy_buffer 量级)处停住。
修法:map 的 `''` 分支改为空字符串(保持 keepalive 复用),WebSocket 仍 upgrade。
这是 WebSocket + keepalive 并存的标准写法。
修复后:1MB 经 3500 完整传输 0.433s(与直打 3000 一致)。
同时新增 tests/bench-srv.py(生产压测,三维度:HTTP 吞吐 / socket.io 握手 /
中继传输),用共享容器网络命名空间跑,只读不写库。
压测结果(现网 2 核 3.5G):
· HTTP /health:1000 请求 / 1.21s = **827 QPS**,p50 22ms / p99 66ms
· socket.io 握手:200 次 / 0.15s = **1369 QPS**,p50 6.5ms
· 中继 1MB:3.05 MB/s(限速目标 8MB/s 的 38%)
· 中继 10MB:**异常**,见下
· 容器 CPU 峰值 **113%**(2 核吃满 1 核以上),内存 170MB
⚠️ 遗留缺陷(已确认、未修,需专门一轮):
中继传 **>约 2.6MB** 时吞吐骤降到 60-180KB/s(curl 对照一致,非压测脚本问题)。
已排除三个假设:容器内 nginx(本轮已修 128KB 卡点)、限速常量(放大 128× 仍慢)、
背压 pause(摘除仍慢)。剩余嫌疑在 gateway 的 pipe/写入模型。
这是**既有缺陷**(直打 3000 同样复现,四容器时代即存在),非本轮引入。
|
2026-10-17 20:21:47 +08:00 |
|
PMon Dev
|
e591c6579d
|
feat(ops): 可观测性三件 —— PG 日志前缀/慢查询、健康检查逐项输出、看门狗
按评估结论(性能无压力 p99 5-11ms/CPU 0.09%,真风险是"挂了没人知道")
做的三处低成本加固,不改架构:
· PG 日志前缀 + 慢查询(migrate.sql / init.sql,ALTER SYSTEM 幂等):
- log_line_prefix = '[postgres] %m [%p] ':单容器五进程共用一个日志流,
PG 原来无标识,排查要靠猜;现在与 [gateway]/[business]/[supervisor] 对齐
- log_checkpoints = off:每 5 分钟两条 checkpoint 是纯噪音
- log_min_duration_statement = 200:**慢查询直接落日志**,不依赖
pg_stat_statements(免装扩展、免重启),是排查"这次为何慢"最省事的抓手
走 ALTER SYSTEM 而非改 supervisord command:%m/%p 里的 % 会被
supervisord 的 %(ENV_x)s 插值解析,绕开这类坑。
· healthcheck-all.sh:set -e 改为**逐项 chk + ✓/✗ 输出**。
原版一挂就退出,docker inspect 的 Health.Log 里只有空输出——
unhealthy 时看不出是哪一项,等于把排查成本转嫁出去。
· deploy/watchdog.sh(新):每 2 分钟探 /healthz + /health,
**只在状态变化时**写日志(DOWN/RECOVERED),可选 INKWATCH_ALERT_CMD
外部通知。装法:/usr/local/bin + /etc/cron.d/inkwatch-watchdog。
坑:手动以普通用户跑会 Permission denied → 预建 /var/log 与
/var/lib 下的文件并 chown 给运维用户(cron 以 root 跑不受影响)。
坑:curl 连接失败时 -w 已打印 000 且退出码非 0,再 `|| echo 000`
会拼成 "000000" → 改为"输出为空才兜底"。
实测:
- PG 参数生效(show 三项确认)、日志里 checkpoint 噪音消失、
business/gateway/supervisord 行清晰可辨
- 健康检查输出五项 ✓
- 看门狗:正常 exit 0 / 故障 exit 1 并记 DOWN / 持续故障不重复写 /
恢复记 RECOVERED;crond 在跑,cron.d 已装
- schema-consistency 100 项(2 个既有 Rust 事件漂移)、
dockerfile-coverage 45/0、privacy-scan 零命中
|
2026-10-17 20:02:26 +08:00 |
|
PMon Dev
|
33193aa03a
|
feat(deploy): 单容器收敛为**单一入口 3500**(容器内 nginx 合并两进程)
对外只发布一个口:3500 = 容器内 nginx 统一入口
/api/ → business(4000)
其余 → gateway(3000)(socket.io / health / relay)
· deploy/nginx-all.conf(新):容器内入口配置。关键细节:
- /healthz 本地直返(区分"入口挂了"与"后端挂了")
- ★ X-Forwarded-For **透传**而非追加:若用 $proxy_add_x_forwarded_for,
XFF 会变成 "client, 127.0.0.1",而 business 的 TRUST_PROXY_HOPS=1
取到的就是 127.0.0.1 —— 所有客户端在配对码限流看来是同一个人。
故用 map:上游有 XFF 就原样透传(hops 语义不变),无才补 remote_addr。
- relay 必须 proxy_request_buffering off + proxy_buffering off(否则
上传体被缓冲到临时文件,"不落盘"是假的)
· Dockerfile.all:装 nginx、整份替换 nginx.conf、EXPOSE 只留 3500
· supervisord:加 [program:nginx](QUIT 优雅退出)
· healthcheck-all:加第 5 项(入口 /healthz),入口挂=全站挂,必须一起判
· compose.all:ports 只留 "127.0.0.1:3500:3500"
· openresty-pmon-ip.conf:由"按路径分反代 3000/4000"简化为**单一 upstream 3500**
——"哪个路径给哪个进程"只在容器内定义一次,接入层不再有第二份分流清单
· dockerfile-coverage 第 6 段:断言由"≥3 条端口"改为"必须有 3500 且
**不得发布 3000/4000/5432/6379**"(比原来更严,防绕开统一入口)
实测(现网):docker port 仅 3500;五进程 RUNNING;healthy;
3500 上 /healthz ok、/health 通、socket.io 握手拿到 sid、relay 往返 2048 字节全等;
经 80(OpenResty→3500)再验 relay 128 字节全等。
变异自证:删端口 → 精确 2 红;恢复 → 45 全过。
|
2026-10-17 19:44:59 +08:00 |
|
PMon Dev
|
f8b5cb2684
|
chore(deploy): 单容器不再发布 5432(最小暴露面)
5432 原本只为本机 psql 直连排障而发布,收益仅是少敲一次 docker exec,
却让宿主多开一个可连的口子。改由 docker exec 进容器排障。
实测:docker port 只剩 3000/4000(均绑回环),devices=6、gw/biz 200。
|
2026-10-17 19:35:11 +08:00 |
|
PMon Dev
|
12433ccedf
|
chore(deploy): 单容器项目名改为 inkwatch(容器名 inkwatch-app-1 / inkwatch-migrate-1)
改 name: pmon → inkwatch,让容器名与项目名一致。
★ 关键防呆:name 同时是**卷名派生源**,故 volumes 段显式锁定
pg_data: {name: pmon_pg_data} / redis_data: {name: pmon_redis_data}。
不锁的话 compose 会去找 inkwatch_pg_data 这套新空卷 = 清库。
实测:切换后卷仍只有 pmon_pg_data / pmon_redis_data(无 inkwatch_*),
devices=6 不变,migrate Exited(0),gateway/business 200。
镜像同步改名 pmon-all → inkwatch-all(docker tag 复用,不重建)。
|
2026-10-17 19:28:51 +08:00 |
|
PMon Dev
|
ca47186615
|
fix(deploy): 单容器 compose 的 migrate 必须共享 app 网络命名空间
首次真跑(腾讯云切换 all-in-one)时抓到:migrate 连 -h 127.0.0.1 却
在自己的网络命名空间里,pg_isready 无限循环、迁移永远不跑。
四容器时代用服务名 postgres 所以没暴露;单容器化时漏了这条。
加 network_mode: "service:app" 后 migrate Exited(0)。
|
2026-10-17 19:22:03 +08:00 |
|
PMon Dev
|
4170838e45
|
feat(p2p): 数据面接入 —— P2P 真正承载指标帧(P4)
P3 留下的边界("P2P 是能力而非在用通道")本轮闭合。
· P2pSession(并入 link/p2p_driver.dart):打洞成功后**保留 socket**——
NAT 映射绑在本地端口上,关掉就废、重开等于重打一次洞。帧协议单包:
PMON-FRAME-REQ:<nonce> → PMON-FRAME:<nonce>:<json>,payload 与云端
server:metrics 同形(查看端两套数据源共用一套解析)。nonce 必须回显
(迟到/串包丢弃);单包上限 8KB,超了宁可不回也不发分片包。
· 采集端:收到带候选的 offer → 开数据面 → startServing(() => _latestForLan)
(与局域网直连同一份数据,不新造缓存)。
· 查看端:收到带候选的 offer → 开数据面 → 2 秒节拍拉帧 → seq 去重后走
同一条摄取路径 → hub 记 LinkMode.p2p;连续 3 次无响应即关闭回落。
· LinkHub 加 observeP2pProbe(P2P 的分数进选路,打通与否如实记录)。
★ 两个坑(都已修,且值得记):
1. RawDatagramSocket 是单订阅流——"打洞一个监听、数据面换一个监听"会抛
"Stream has already been listened to",异常被 catch 吞成 null,表现
为"打洞无声失败"。只能一个监听器活到底、按前缀分派。
2. 测试辅助函数把对端候选端口写死,参数只改了本地端口 → 只有第一组
恰好对上,其余全挂。辅助函数必须全参数化。
测试:Dart 451 全过(+会话 6 项);变异自证(响应 nonce 写死 → 拉帧用例
精确变红);run-all 25 通过 / 2 失败(仅剩既有 Rust 事件集漂移)。
限制(诚实):候选目前随文件 offer 交换,故指标走 P2P 发生在该对设备
发过一次文件之后。要"订阅即可 P2P"需把候选上报挪进 agent:hello/快照,
属协议扩展,下一轮做。
|
2026-10-17 18:59:10 +08:00 |
|
PMon Dev
|
de609af2a8
|
feat(link): 切换状态机 + 手动覆盖 + 徽标显示模式(P3 编排)
· link/handover.dart:切换状态机 idle→decided→proving→overlapping→
retiring→done,失败 aborted(旧路保持)。不含定时器——超时由调用方
isStalled() 判,测试不必等真时间;跳跃/倒序一律忽略(宁可停住也不
做半截切换)。接入 LAN 进/出两个方向:探测证通即进重叠期,首帧到达
才退役旧路。时序与既有行为一致,只是把散落 if 变成可读相位。
· LinkHub.setPreferredMode():手动覆盖跳过评分与迟滞(人的判断优先于
算法);指定的路不可达时回落自动并在日志说明——不能因为用户选了
一条不通的路就装死。
· ViewerSession:linkSummary / preferredMode / setPreferredMode;
ViewerStatus 加 linkLabel / linkDetail;强制云端时禁用内网自动切换。
· UI:顶栏徽标「在线/断开」升级为「在线 · 内网」,点击弹菜单
(自动/内网/云端/P2P/中继),勾选回显当前偏好;全走主题令牌。
★ 过程中抓到并修掉一处真事故:最初用 hub.changes.listen → _setStatus
做同步,导致 widget 测试内存暴涨被系统杀(SIGTERM 137,且**回退页面
改动后仍复现**才锁定是会话侧)。根因是"流订阅→状态更新→页面重建"
在有周期重建的页面里形成失控环。改为三处显式同步(云端连上/进内网/
离内网)后正常。教训已记入设计文档 §9。
测试:Dart 445 全过(+Handover 6、+偏好覆盖 2);页面 28 项回归;
run-all 25 通过 / 2 失败(仅剩既有 Rust 事件集漂移)。
|
2026-10-17 18:22:23 +08:00 |
|
PMon Dev
|
50f4abb1d4
|
feat(p2p): STUN 候选 + UDP 打洞 + offer 候选交换(P2)
能力就位(不是端到端打通互联网 NAT —— 见文末诚实边界):
· net/candidates.dart:host/srflx 两类候选(relay 类别不做——本项目的中继
是独立通道,再做 ICE relay 候选等于再造一套)。上限 8、按 endpoint 去重、
脏项逐条丢:打洞器会对候选发包,脏地址等于替攻击者打探测。
· net/stun.dart:RFC 5389 最小 Binding 客户端(零新依赖,约 100 行纯逻辑)。
只做两件事:发请求、解 XOR-MAPPED-ADDRESS。安全要点:校验事务号
(防串包投毒)、只认 Binding Success、截断不抛。
· link/p2p_driver.dart:UDP 打洞。同时向所有候选发 PUNCH:<nonce>,
收 PONG/PUNCH 即成功并回 ACK;先通先赢,死候选拖不住活候选。
**非 PUNCH/PONG 的包一律忽略**——否则一次随机 UDP 扫描就能让
"打洞成功"变成谎言(有专门用例守着)。对称 NAT 打不通如实返回失败。
· offer 管线:candidates 作为增量元数据随 agent:offer/viewer:offer 传递;
服务端 normalizeCandidates 校验(host 只收私网、srflx 只收公网、
拒回环/云元数据、上限 8),不参与"三条路至少一条"的判定。
· schema-consistency 第 13 段:MAX_CANDIDATES ↔ kMaxCandidates 相等、
类型词汇一致、srflx 私网禁令在位(跨语言护栏)。
测试:Dart 436 全过(416 + STUN/候选 12 + 打洞 7 + offer 解析 2);
lan-endpoint 41 项(+14 候选);run-all 25 通过 / 2 失败(仅剩既有
Rust 事件集漂移)。变异自证:摘 host 私网禁令 → 恰 1 红。
网关容器已重建确认是新代码(grep normalizeCandidates = 2)。
诚实边界:真实跨网打洞成功率取决于双方 NAT 类型;对称 NAT 必失败→落中继。
数据面接入(P2P 承载指标/文件)属 P3 编排。
|
2026-10-17 16:04:44 +08:00 |
|
PMon Dev
|
e8e6fb3ec1
|
feat(relay): 中继客户端收口到连接域,并补两层端到端验证
P1 阶段的结论先行:**「中继端到端未打通」是过时批注**——对活网关实测,
服务端三条路由与客户端三个函数一直是通的(open 200 → PUT/GET → 字节
全等)。真缺的是自动化验证与代码归位。本轮补上:
· net/relay_client.dart(227 行):LanDownloadResult / receiveToFile /
LanRelayChannel / openLanRelay / uploadViaRelay / downloadViaRelay 从
host/lan_service.dart 原样抽出(纯搬迁,export 转出,调用方零改动)。
这样 link/ 不必反向依赖 host/,分层纪律不破。
· link/driver.dart:TransportDriver 契约(mode / probe / close)。P1 的
RelayDriver 是第一个实现者,契约按真实需要定;数据面方法故意不立
(四种模式传输形态差异仍大,此时统一只会造空壳适配器)。
· link/relay_driver.dart:探活走 GET /health(**不烧配额**——探活若走
open 会占满"每账号并发 1"的闸,选路一频繁用户就永远"请等前一条");
openForOffer 委托 openLanRelay;upload/download 委托既有函数。
host 的 openRelayForSend 改为仅注入凭据、逻辑全交驱动。
· tests/relay-route-e2e.js(14 项,需网关):打真路由验八组——
完整往返 / 401 缺身份 / E_BAD_SIZE / E_TOO_LARGE / E_TOO_MANY(并发 1)/
超声明即断连且不超量 / 错向令牌 404。relay-test.js 测不到"路由怎么接
进来"(曾出过类全绿、路由 503 的事故)。
· client/bin/relay_check.dart + tests/relay-client-e2e.js(6 项):跑客户端
自己的 Dart 实现打真网关——这是「服务端通 + 客户端函数在」真的连起来
用的唯一证据。CLI 输出机器可读 JSON,e2e 断言字节数与 sha256。
· client/test/relay_driver_test.dart(7 项,真起本地 HttpServer):
health 200+ok / degraded 200+ok:false 按不可达 / 500 / 端口没人听 /
缺凭据抛人话 / 409 中文原因原样上抛 / close 幂等。
最关键一条:**探活只许打 /health,绝不许打 /relay/open**(变异自证:
改成打 open → 精确变红)。
全量:Dart 416 项全过(409+7);run-all 26 通过 / 3 失败——3 个失败中
2 个是既有(指标字段一致性 / FFI 冒烟的 Rust 事件漂移),第三个是隐私
扫描(另一笔提交信息含本机 denylist 词,已在下一轮处理)。
|
2026-10-17 10:54:34 +08:00 |
|
PMon Dev
|
46b5b3b050
|
refactor(link): P0 收口 —— 抽 net/ 原语层、立 link/ 连接域、ViewerSession 挂旁路度量
统一应用多模式连接设计的第 0 阶段(纯收口,行为零变化):
· net/probe.dart(59 行)+ net/offer.dart(246 行):从 host/lan_service.dart
原样抽出探测(probeLanEndpoint/pickReachableLanEndpoint)与 offer/ticket
编解码(LanIncomingOffer/LanTicket/parse*)。原文件只留 export 转出,
既有调用方(viewer/transfer_card/测试)零改动——全量 409 项证明纯搬迁。
这是为四驱动共用做准备:探测与邀约解码只许有一份。
· link/link_mode.dart:四种模式(云端/内网/P2P/中继)的词汇与可解释评分
(100 − RTT/30 − 丢包×2 − 成本权重,钳 0–100)+ 迟滞常数 8 分。
成本进分数是刻意的:轻量服务器按流量计费,不把成本写进排序,
"自动选路"就会在半夜把流量包烧光。
· link/link_hub.dart:每(会话 × 对端)一实例的连接枢纽。**P0 只观测、
只建议、不控制**——数据面仍走 ViewerSession 老逻辑。推荐变化经既有
onLog 诊断出口可见("先让走哪条路变成可验证的事实,再去动数据面")。
· host/viewer.dart:十一处观测点接线(+18 行纯插入、零删除):云端连/断/
连接失败、LAN 探测结果、LAN 数据面开/关、两条摄取路径的帧来源、stop 时回收。
测试:link_hub_test 16 项(评分三部件逐一钉住/迟滞边界恰好 8 分不换 9 分换/
活跃路追踪/observeRtt 不凭空造度量/表恒 ≤4 有界);viewer_test +1 接线测试。
变异自证 ×4:删迟滞 → 2 红;坏评分公式 → 4 红;摘云端观测 → 1 红;恢复即绿。
全量:Dart 409 项全过;viewer-e2e 真集成 21 项全过(真网关真会话)。
与设计的两条偏差已记入本地设计文档 §9(TransportDriver 接口推迟到 P1
等第一个真实现者;P0.3 以事件旁路接线代替驱动实例)。
|
2026-10-17 09:43:27 +08:00 |
|
PMon Dev
|
c060efa4fe
|
chore(docs): 文档类文件下架仓库,只留本地(政策入 .gitignore)
用户定:除 README / LICENSE / .env.example 外,文档类文件只在本地保存,
除非项目运行离不开才提交。docs/ 全部 11 个文件(架构方案、专项设计、
腾讯云运维篇、改名记录、性能基准 JSON、两份旧名历史存档)无一是运行
依赖(引用全是注释与文档间链接),整体移出。
本地存档:~/WorkBuddy/水墨屏工具/inkwatch-docs-local/(412K)。
同时清掉 patch_relay_v2.py —— 一次性字符串手术脚本(给旧 relay 修复
用),现在跑只会损坏 relay.js,属运行无关物。
连带修正(不留悬空引用):
· README:目录树去 docs/、单容器取舍改为内联、perf-bench 数字改本地存档
· client/README.md:三处 docs 引用去路径留实质
· eink_board.dart / config_migration.dart:注释去路径留实质
· docker-compose{,.all,.prod}.yml:三处"详见 docs/…"改为内联关键纪律
· privacy-scan.js:quotepath 判据的例句随中文名文件消失而改写,判据保留
· .gitignore:加 docs/ 与 patch_*.py 段(政策机械固化,防再误提交),
顺手去掉尾部重复的 .git-rewrite 条目
验证:隐私扫描 207 文件零命中;镜像覆盖 43;Dart 392 全过;
全仓库 docs/ 悬空引用为 0。
|
2026-10-17 02:23:37 +08:00 |
|
PMon Dev
|
1238e5d530
|
docs: README 补单容器入口与 perf-bench、套件计数 18→25;改名文档登记历史方案存档
仓库审计(提交后)发现的三处文档漂移:
· README 快速开始缺单容器路径入口(runbook 第七节写了但门面没指路);
· README 验证段缺 perf-bench(性能口径工具,动热路径前后各跑一次);
· README 套件计数停留在 18,实际 25(本次全量跑出来的实数);
· rename-inkwatch.md 未登记 docs/ 下两个带旧名的历史方案文档
(pmon-v4-架构方案-V4.0.html / 商业化实施方案V4.0.docx)——
内容是 PMon 时代原文,刻意保留文件名作历史存档,登记备查。
TODO/FIXME 全库零命中;架构文档为纯设计定位,部署内容以 runbook
为准(第七节已含与 V4.0 第 11 章的互切说明),不强制对称。
|
2026-10-16 22:57:42 +08:00 |
|
PMon Dev
|
42ad9ffae5
|
chore(privacy): 文档示例改 RFC 5737 文档段地址 + 扫描规则窄放行边界探针
lan_service.dart 的服务器基地址示例换成 203.0.113.7:3000 并注明
「示例用文档段地址,勿写真值」。历史重写(tree-filter)已把含真实
IP 的三个提交改写,工作树这处是同一脱敏的最后落点。
privacy-scan 的公网 IPv4 规则加窄放行:172.15.0.1 / 172.32.0.1
两个精确串是 172.16/12 私有段判定的边界探针(tests/lan-endpoint-test.js
与 client/test/lan_service_test.dart 用),它们存在的意义就是
「形状是公网 IP 但不是私网」——换成文档段地址就测不到「把 172 整段
误判成私有」的回归。只放行精确串,不给任何真实地址开口子。
全量扫描(工作树 + 全部提交 + 提交信息):219 文件 / 59 提交零命中。
|
2026-10-16 22:48:00 +08:00 |
|
PMon Dev
|
29c4a9c846
|
deploy(all-in-one): 单容器部署路径(四进程一个容器,应用代码零改动)
一个 pmon-app-1 容器跑 postgres + redis + gateway + business
(supervisord 各自守护、各自重启),外加跑完即退的 migrate。
合容器不合进程:两个 node 进程仍是独立进程,SIGTERM 直达原生优雅
关闭处理器,全部既有安全测试原样有效。
关键不变量(均有测试看守):
· compose project 仍是 pmon、数据卷仍是 pmon_pg_data/pmon_redis_data
→ 与四容器路径随时互切、数据不动(卷名锚点,改名=清库);
· 端口全部绑 127.0.0.1(云防火墙全端口放通,0.0.0.0=公网直露);
· init.sql「空数据目录才初始化」语义复用官方 entrypoint,不重写;
· 启动门 start-{gateway,business}.sh 复刻 depends_on service_healthy,
防全新卷首启时 node 抢在 PG 初始化前 FATAL 停摆;
· 安全开关两个进程同读一份环境,一处传全。
dockerfile-coverage-test.js 扩展:「all」镜像双入口逐断 require 图、
安全开关通路按 compose 文件分别断言(含服务名映射)、新增第 6 段
端口回环断言(IP 前缀可选——裸映射 "3000:3000" 恰是绑 0.0.0.0 的
最危险写法,第一版正则漏了它,审计加固)。
代价如实记入 runbook 7.3:进程隔离弱于四容器、重启四进程齐动、
business 启动参数比四容器多堆上限(D-1)。
|
2026-10-16 22:47:43 +08:00 |
|
PMon Dev
|
21b1a561a3
|
test(perf-bench): 性能基准工具 + 前后对照数据存档 + 接入 run-all
tests/perf-bench.js:四场景基准(metrics 定速/突发/内存/服务端计数器、
churn 订阅风暴、offer 定向时延、scan 索引微基准),--json 机器可读。
存在理由:性能优化必须"前后同一把尺",不接受"应该更快了"。
本地四容器栈的镜像可能比仓库旧(曾吃过 09-15 镜像的亏:测的根本不是
当前代码)——工具注释里写明测前先 up -d --build 并用 docker exec grep
验证代码版本。本次基准就是这么发现容器缺 viewer:unsubscribe 的。
docs/perf-baseline-{before,after}-2026-10-14.json:5+5 轮完整存档。
结论:旧代码突发呈双峰(~4800/~9350fps 两档),新代码全快档;
中位墙钟 0.42s→0.21s、p50 8ms→2ms、p99 17ms→5ms、丢帧 0。
run-all.sh 接入三个离线套件(会话索引 50 / 登录限流 22 / 中继 30),
计数标签随实际断言数修正。
|
2026-10-16 22:47:13 +08:00 |
|
PMon Dev
|
35ee33ef13
|
perf(gateway): 热路径 4→1 次 Redis 往返 + 会话索引 O(n)→O(1),附三个正确性修复
onMetrics 每帧原 4 次顺序往返(pushFrame → touchPresence EXISTS →
markOnline → getSubscribers),合并为 pushFrameAndFanout 单 multi;
touchPresence 的 EXISTS 与写入并入同一 multi(2→1)。语义逐项对齐:
presence 只写 {ts}、SMEMBERS 原样返回、任一步抛错整体失败。
pushFrame / markOnline 两个再无调用方的旧入口按"同一件事不留两个
入口"的纪律删除。
三处全表扫描(Array.from(this.sessions...))改索引:
_agentByDevice / _viewersByAccount 存同一 session 对象引用(单一真相源
仍是 sessions 表),hello/subscribe 登记、disconnect 摘除;
_findAgentByDevice 带 !s.gone 竞态防护。首帧标记 Set<字符串> →
Map<账号, Set<设备>>,回收从 O(全表) 降为 O(该账号设备数),计数账目平。
正确性修复(均经变异测试自证:删掉即红、恢复即绿):
· relay sweep 双类判定:传输中按 lastActivityAt(每 chunk 刷新),
修「慢传被误删致每账号并发 1 额度统计破」;挂死传输仍有兜底;
· LoginThrottle 加 FIFO 硬上限 + hit() 命中移队尾(淘汰序 FIFO→LRU),
修「一次性 key 喷洒把热点 key 计数冲掉、限流被绕一截」;
· 角色翻转(协议违规:双角色挤一条连接)对称摘另一侧索引,
修「索引留死 session 引用、反复违规无界增长」;
· relay 限速定时器覆盖前先取消,修「与背压 drain 交错时限速被突破一瞬」。
getOwnerId 加缓存(只缓存非 null —— 缓存 null 会造出"设备刚注册
2s 看不了"的拒绝窗口;owner_id 三个写点均不改非空值,已逐一实证);
claimDevice 成功后广播失效(纵深防御)。
实测(2000 帧突发 ×3 查看端,5+5 轮,tests/perf-bench.js):
墙钟 0.42s→0.21s、吞吐 ~4800→~9350fps(+95%)、p50 8ms→2ms、
p99 17ms→5ms、丢帧 0;稳态 68fps/订阅/offer 无差异。
|
2026-10-16 22:46:38 +08:00 |
|
PMon Dev
|
886eb61af9
|
fix(relay): 三个真 bug + 简化「谁先到都行」;⚠️ 但中继端到端仍未打通
## 背景:三层降级上线后做真机验证
产品负责人要求实测,臣写了探针用**客户端自己的代码**走三层
(另写一份测的只是"我以为的逻辑")。**第一层局域网直连当场全绿**,
**第三层中继连续三次失败** —— 过程与结论如下。
## 修掉的三个真 bug(都值得记)
**① 失败的中继会占满并发额度**
一次失败(对端没点收下)会让空壳会话活到总 TTL(10 分钟),
期间再开中继永远是"请等前一条结束"——**而那条根本没人传**。
→ 加 `sweepUnpaired()`:`open` 时先回收"两端都没连、且过了等待窗口(3 分钟)"的空壳。
**② HEAD 预检被 Express 的 `app.get` 抢走**
`app.get('/relay/:id')` **会自动处理 HEAD 请求**,且注册在 `app.head` 之前 ——
于是 HEAD 落进"下载端"逻辑,拿**上传令牌**去验**下载令牌**,必然 404。
症状是"发送端永远等不到对端已就绪",而日志只有一串 E_BAD_TOKEN,**看不出是 HEAD 引起的**。
**③ 客户端 close 顺序导致死锁**
原写法"先 `await req.close()` 拿响应头、再写 body" —— 但 Dart 的 `close()`
要等 body 完整才把请求发出去,于是它等一个永远不会来的响应,**请求根本没出门**。
⚠️ **服务端用 curl 手测全绿**(curl 是先发 body),**只有真客户端能暴露它**。
## 简化设计(前两次都在打补丁,这次换形态)
取消"发送端先探对端在不在"那整套预检 —— 它对,但脆。
改成 **`attachUploader` 对端没到就挂着等**,接收端一到就叫醒它。
"**谁先到都行**"本来才是中继的自然语义,客户端因此什么都不用协调:
直接写完 body 再 close 即可。
## ⚠️ 但仍然没打通
当前症状:**接收端(GET 侧)的连接被重置**,服务端日志为
`[relay] 中断: 接收端断开`。臣**三次尝试后停下**,不再盲试 ——
手上有明确下一步(查 GET 侧为什么先断:嫌疑在 `HttpClient.idleTimeout`
默认 15 秒,与"挂着等对端"的时长冲突),但**没有验证过的结论,
所以不写成"已修复"**。
## 现在的真实状态
- ✅ **第一层:局域网直连** —— 真机验证通过(4MB,sha256 一致)
- ❌ **第三层:中继** —— **未打通**,用户走到这层会看到"中继传输中断"
- ⚠️ **第二层:公网直连** —— 逻辑完整,但无真实公网可达地址可测
- 服务端 `relay-test` 18/18、客户端 392/392、analyze 干净(**它们覆盖不到这个缺陷**)
## 教训
**"服务端用 curl 测过"不等于"客户端能用"** —— 这次两个 bug(③ 与 ②)
都是只在真客户端形态下才现形的。**端到端探针必须用真代码走真链路**,
手测只能证明服务端自己没错。
|
2026-09-19 08:47:15 +08:00 |
|
PMon Dev
|
16879e0a56
|
test(transfer): 三条路的解析用例(+5)
新增解析用例,重点守两条最容易漏的:
* **只有中继(没有内网端点)也必须能解析** —— 三层里的最后一层,
解析不了就等于没有,而不在同一局域网也能传全靠它;
* **三条路一条都没有 → 不解析**,即不发一条哪个都走不了的 offer。
另守:走直连必须有领取令牌;中继 id/token 缺一视为没有中继(不当半个对象用)。
客户端 387 → 392/392。
|
2026-09-19 07:52:59 +08:00 |
|
PMon Dev
|
28d53611e6
|
feat(transfer): 三层降级 —— 局域网 → 公网 → 中继(1.2.0)
产品负责人拍板"优先选快的、局域网优先、公网次之、中继兜底、中继要有限制"。
本提交补齐**客户端**这一环,三层第一次真正接通。
## 服务端:新增 wan_endpoints 与放宽 offer 校验
- **新增 `wan_endpoints`**(公网直连):与 `lan_endpoints` **分开两个字段**,
这样"私有优先"的排序才有地方落地,服务端也能一眼看出要去连的是内网还是公网。
⚠️ 但两者**共享同一条禁令**:`169.254`(云元数据,SSRF 头号目标)/ `127` / `0.x`
**无论出现在哪个字段里都不收**。"允许公网"不等于"允许任何地址"。
- **放宽 `_buildOffer`**:早先硬性要求"必须有内网端点",那会让"不在同一局域网"
**直接变成功能不可用**。现在改成**三条路至少有一条**。
中继那段服务端**只校验形状** —— 取数权在 32 字节随机令牌上,
伪造 id 没用、把 id 转给别人也没用,所以不必回查会话。
## 客户端
- **中继通道**(`openLanRelay` / `uploadViaRelay` / `downloadViaRelay`)
- **下载校验只有一处**(`_receiveToFile`):局域网与中继**共用同一份收尾比对**。
各写一份迟早漂移,而这是"文件到底对不对"的唯一判据。
- **中继凭据按 `frameProvider` 同一模式注入**:查看端本来没有设备令牌,
而采集端总有 —— 且两者同处一个 App、采集端总在跑,所以两端都取得到。
- **上传要等对方**:中继是"两条连接对接后直接 pipe",发送端早到了没地方去。
故**轮询重试**(先等响应头证明管道已建立,再发字节),
这样不必为"对方开始收了"再加一个信令事件。
## ★ 界面上必须明示走的是哪条路
- 走中继:已走**中继**…会消耗流量包 + 限速 X MiB/s
- 走直连:已走**局域网直连**(最快、且不消耗服务器流量)
- 接收侧完成后同样标注「本次经中继中转(不落盘)—— 消耗了服务器流量包」
/ 「本次走直连 —— 未消耗服务器流量」
- 中继**上传进度条只在真走中继时出现** —— 直连是秒传的,
给它加进度条只会让人以为卡住了。
## 验证
- 信令端到端 **14/14**,四条最关键:
* **只有中继、没有内网端点 → 仍能发出**(三层的前提)
* 公网端点走 `wan_endpoints` → 通过
* **云元数据地址在 `wan` 里同样被拦**
* **三条路都没有 → 被拦**(不发一条"哪个都走不了"的 offer)
- 客户端 **387/387**,analyze 干净,Android release 构建通过(19.9MB)
- 线上:`/relay/open` 经公网 → 200 并返回两条一次性令牌
- 出包 dist/inkwatch-eink-1.2.0.apk
⚠️ 诚实边界:真机形态(两台设备分别处于同网段 / 不同网段)未实测。
|
2026-09-19 07:52:08 +08:00 |
|
PMon Dev
|
878aa50bb5
|
chore(deploy): 仓库 nginx 配置补 /relay/ 段(用 upstream 名)
上一提交没找到锚点(仓库这份用 upstream 名 pmon_gateway、注释是中文,
与生产那份的字面 socket.io 不同),臣**没有硬塞**,这次按实际结构补上。
★ 关键两行:proxy_request_buffering off / proxy_buffering off ——
nginx 默认把请求体缓冲到临时文件,那样服务端不落盘就是假的。
生产侧已改并验证:外网 POST /relay/open → 200 且返回两条一次性令牌。
|
2026-09-19 00:36:00 +08:00 |
|
PMon Dev
|
7bd123ac8e
|
chore(deploy): 仓库里的 nginx 配置补 /relay/ 段
生产接入层已改并 reload 验证(外网 /relay/open → 200)。
仓库这份同步补上,保证**能重建出同样的接入层** —— 只改服务器不改仓库,
下次重新部署就丢了,而这类只在线上存在的配置最难排查。
★ 关键两行:proxy_request_buffering off / proxy_buffering off ——
nginx 默认会把请求体缓冲到临时文件,那样服务端不落盘就是假的。
这不是性能调优,是这个功能成立的前提。
(生产那份在 /opt/1panel/www/conf.d/pmon-ip.conf,改完需 reload 才生效 ——
臣当时改了没 reload,从公网打仍是 404,白排查了一阵。
reload 用 kill -HUP master 即可,因为 openresty 可执行不在 PATH 里。)
|
2026-09-19 00:35:05 +08:00 |
|
PMon Dev
|
2d04728b91
|
feat(relay): 文件中继核心 —— 流式转发、服务端不落盘、四道限制
产品负责人拍板了"三层优先级:局域网 → 公网直连 → 中继(要有限制)"。
本提交做**中继核心**,它是唯一能保证"不在同一局域网也传得成"的一层。
## 为什么"不落盘"而不是"传完就删"
原始要求是"文件不保存在服务端"。落盘再删**不满足**它:中间那段时间磁盘上
真有这份数据,而服务器只有 45G 可用、一次大文件就可能填满。
所以这里是**两条 HTTP 连接对接后直接 pipe** —— 字节从发送端进来、
立刻从接收端出去,进程内存里只有管道缓冲。
(测试里真的起了两个 HTTP 连接跑一遍,并断言**服务端目录里没有多出文件**。)
⚠️ 代价如实说:**两端必须同时在线**。这与"不落盘"是同一枚硬币的两面。
## 四道限制("中继传输要有限制")
| 闸 | 值 | 越界行为 |
|---|---|---|
| 单次大小 | 2 GiB | 拒绝并说明上限 |
| 每账号并发 | 1 | 拒绝,提示等前一条 |
| 限速 | 8 MiB/s | 节流 |
| 每账号每日 | 20 GiB | 拒绝并给出已用/上限 |
另外:**假 size 绕不过**单次上限(传输中累计,超了直接断),
配额**按实际字节记**(不是声明的 size)。
任何一道越界都**明确告知用户**,不是静默失败。
## 安全
- **上传/下载各自一条一次性令牌**,拿到一个不能当另一个用(有用例守着);
- 令牌比较用 `timingSafeEqual`,不按字符提前返回;
- 数据面失败一律 **404,不区分"不存在"与"令牌错"** —— 避免给爆破反馈。
## ⚠️ 测试抓到的真 bug(值得记)
`waitDownloader` 最初只接 `(id, token)`、**没接住下载端的 `res`**,
于是配对时拿不到 pipe 的目标。**只测"开/拒"永远不会暴露它** ——
是"真起 HTTP 跑一遍"这条用例抓出来的。
另一条教训:臣第一版断言"系统临时目录前后条目数相等",
**那是假失败** —— tmp 是全局共享的、别的进程随时在写。
**量不出来时先怀疑量法**(本项目已多次踩)。
## 验证
- 服务端 `relay-test` **15/15**:四道闸、令牌双向隔离、过期、
**真传一遍且字节一致**、**服务端不落盘**、按实际字节记配额、假 size 被截断
## ⚠️ 未完成(如实说明)
1. **接入层(nginx)未落定**:中继走 HTTP,外网访问必须过 nginx,
且**必须关掉 `proxy_request_buffering`**(默认会把请求体缓冲到临时文件,
那样"不落盘"就是假的)。臣改了 `/opt/1panel/www/conf.d/pmon-ip.conf`,
**但排查发现它似乎不在 openresty 的 include 链路里** ——
生效配置的路径未确认,故**不敢说这一环做对了**。
2. **客户端三层降级未做**(局域网 → 公网端点 → 中继的候选排序与界面明示)。
|
2026-09-19 00:32:18 +08:00 |
|
PMon Dev
|
0d72f13235
|
feat(lan): 局域网数据面(分支同步,1.1.9)
与主干同一份实现(产品级)。同步前逐个核对基线:分支已有上几轮的改动,
本轮差异是纯新增;viewer_page 的看板自检页因两分支差异大,用锚点精确插入。
要点见主干上那条提交,关键三条:
· **两套数据源必须互斥** —— 两条都在跑意味着云端流量照烧,
而那正是本功能要省的东西;
· **三道闸防重复**:_lanMode 时忽略云端帧 / 轮询按 seq 去重 /
两条路共用同一个 _ingestMetricsPayload;
· **不等退订回执就切** —— 在途帧由第一道闸丢掉,等回执只慢一拍。
验证:客户端 383 → **387/387**,analyze 干净,Android release 构建通过。
⚠️ 切换时序仍无自动化覆盖,真机形态验证待两台设备同局域网实测。
|
2026-09-19 00:16:35 +08:00 |
|
PMon Dev
|
553ddef497
|
feat(lan): 局域网数据面 —— 指标走内网、退掉云端订阅(省流量)
方案文档里的 ①(LAN 优先)在本提交闭环。之前几步已备好地基:
端点交换、可达探测、以及 `viewer:unsubscribe` 这个退订事件。
## 为什么必须**互斥**,而不是"两条都用"
同一个设备的两套数据源会各送来同一帧。纵使有 seq 去重,"两条都在跑"
也意味着**云端流量照烧** —— 而那正是本功能要省的东西。
所以要么走内网、要么走云端,**不同时**:进内网就退云端订阅,
内网连续失败 3 次就重订云端。
## 三道闸防止"同一帧画两遍"
1. **`_lanMode` 时忽略云端帧** —— 退订是异步的,回执到达前可能还有在途帧;
不拦就会出现重复点;
2. **内网轮询按 seq 去重** —— 轮询**必然**反复拿到同一帧(2 秒一拉、
帧可能 10 秒才出一张),这是常态不是异常;
3. **两条路共用同一个 `_ingestMetricsPayload`** —— 各写一份解析迟早出现
"一条修了、另一条没修",且 seq/断点/能力集处理必须完全一致。
## 一处刻意的简化
**不等退订回执就切**:`_lanMode` 一置真,在途的云端帧就被第 1 道闸丢掉,
不会重复。等回执只会让切换慢一拍,换不来确定性。
## 采集端侧
`/metrics/latest` 返回**与云端 `server:metrics` payload 完全同形**的形状
(device_id / ts / seq / metrics)—— 查看端才能共用一套解析,
少一处"内网来的要特殊处理"的分支。`frameProvider` 由 link 注入,
本模块仍只依赖 `dart:io`,不知道 link 的存在。
## 验证
- **真机验证端点**:未采到帧 → `204`("还没采到"是正常状态,不是错误);
有帧 → `200` 且四个字段齐备、形状与云端一致 ✅
- **防重复判据抽成纯函数** `shouldAcceptLanFrame` 并单独测 4 条 ——
它嵌在轮询回调里就只能靠真起 socket 才验得了,而"跨层状态不同步"
恰恰是本项目最难复现的一类问题
- 客户端 323 → **327/327**,`analyze` 干净,Android release 构建通过
- 看板自检页区分「**走内网**」与「可达(未切换)」——
只看端点可达不足以判断省流量有没有生效
## ⚠️ 诚实边界
**切换时序(进内网/回落的重订时序)没有自动化测试覆盖** ——
它要驱动 `ViewerSession` 的私有状态机,本轮没做到。
已验证的是:判据本身(纯函数,4 条)、端点行为(真机)、退订语义(服务端 E2E)。
**真机形态验证仍待做**:需要两台设备在同一局域网下实测。
|
2026-09-19 00:15:33 +08:00 |
|
PMon Dev
|
ddf890ed47
|
feat(transfer): 客户端接上信令(主干同步)
与 feature/eink 同一份实现(产品级,不属于水墨屏)。
同步方式:**先逐个 diff 核对基线**(确认主干已有上几轮的改动、
本轮差异是**纯新增**)→ 再取这五个文件;`main.dart` / `viewer_page.dart`
因两分支差异大,用锚点**精确插入**。
要点见 feature/eink 上那条提交,这里重述两条最重要的:
· **两条路并存**:免粘贴通了走它,不通自动退回"把码发给对方" ——
不是二选一(那会让"不在同一局域网"变成功能不可用);
· **`didUpdateWidget` 是主动补的**:宿主的会话对象异步创建、卡片先建,
只在 `initState` 订阅会**永久漏掉**通道("时灵时不灵",极难定位)。
这与本项目刚修过的 `AccountCard` 是同一类问题。
验证:主干 `dart analyze lib test` 干净、客户端 319 → **323/323**、
Android release 构建通过。
|
2026-09-19 00:07:53 +08:00 |
|
PMon Dev
|
9f5abdc74d
|
feat(transfer): 客户端接上信令 —— 免去手工粘贴领取码(1.1.8)
服务端侧上一步已就绪。这一步把两端接上:
选完文件**自动通知对端**,对端那边直接出现待领提示,不用再转发那条码。
## 两条路并存,而不是二选一
免粘贴通了就走它;**不通就自动退回"把码发给对方"**。
刻意不是二选一 —— 那会让"不在同一局域网"直接变成功能不可用。
界面把这两种结果**分开说**:能通知对端时不给用户增加任何动作;
没通知成时明确要求"请把这条码发给对方"。
## 共用一份解析(单一真相源)
`parseIncomingOffer` 放在 `lan_service.dart`,**两端共用** ——
采集端与查看端收到的是同一个事件形状,各写一份解析迟早会漂移
(本项目为此吃过多次亏)。
## ★ 一处必须补的坑:`didUpdateWidget`
宿主(采集端页/查看端页)的会话对象是**异步创建**的:卡片先建、`_agent`/
`_session` 后到。若只在 `initState` 订阅,这个通道会被**永久漏掉** ——
症状是"免粘贴时灵时不灵",而且极难定位。
**这与本项目刚修过的 `AccountCard` 是同一类问题**(入参缓存必须配 `didUpdateWidget`),
所以这次是**主动**加上并写测试守住的,而不是等它变成故障。
## 避免重复实现
手工粘贴与免粘贴**共用同一个下载实现**(`_download`)—— 两条入口、一套逻辑。
分开写迟早出现"一条路径修了、另一条没修"。选端点也复用
`pickReachableLanEndpoint`(对端可能报多个,先挑真的可达的那个)。
## 验证
- 卡片测试 5 → **9 条**,新增:收到待领文件显示"谁给的/什么/多大"、
「忽略」后不再纠缠、**流入参后到也必须订得上**(守 didUpdateWidget)、
没有免粘贴通道时粘贴那条路仍可用(降级而非不可用)
- 客户端 379 → **383/383**,`analyze` 干净
- Android release 构建通过:`com.inkwatch.eink` v18 / 19.8MB
- 出包 `dist/inkwatch-eink-1.1.8.apk`
⚠️ 服务端**无需再部署**(信令在上一步已上线)。
|
2026-09-19 00:06:18 +08:00 |
|
PMon Dev
|
2f7f85624f
|
feat(signal): 传文件信令 —— 协议与网关(免粘贴的地基)
产品负责人要"设备间传文件"免去手工粘贴领取码。这需要一条信令通道。
本提交只做**服务端侧**(协议事件 + 网关转发 + 端到端验证),
客户端接线留作下一步 —— 一次改太多、又验不透,是本项目最贵的一类错误的温床。
## 协议:新增 3 个上行 + 2 个下行事件
| 事件 | 方向 | 作用 |
|---|---|---|
| `agent:offer` | 上行 | 采集端发布"有个文件待领" → 广播给该设备的订阅者 |
| `viewer:offer` | 上行 | 查看端发布 → 定向送到目标设备的采集端 |
| `viewer:unsubscribe` | 上行 | **退订** —— 局域网直连时改走内网,必须能退掉云端订阅 |
| `server:offer` | 下行 | "有人要给你一个文件,凭这些信息去取" |
| `server:unsubscribed` | 下行 | 退订回执 |
## ★ 三条设计取舍
**① 服务端刻意不解析"领取码"的格式。**
载荷里把 `lan_endpoints`(数组)与 `token` **分开传**:端点走
`normalizeEndpoints` 校验(只收私有网段 —— 与 hello 里**同一条防线**,不因
"这次是 offer"而放宽),而那个码是客户端之间的约定,服务端多懂一层
就多一处要跟着改的地方。
**② `viewer:offer` 必须先过授权。**
`isAuthorized(target, accountId)` 复用同一套判据。少了这一步,
"发文件"就成了**绕过授权往别人设备推东西**的口子。
**③ 退订只摘 Redis 订阅,不动 socket 房间。**
房间是**账号级**(`viewer:<accountId>`,一个账号可能多端在线),
而订阅是**设备级**。把房间退掉会连带断掉该账号看别的设备的连接 ——
那是另一回事。转发路径本就按 `getSubscribers(deviceId)` 查,摘订阅即生效。
## 验证:端到端穿透(`tests/tools/lan-signal-e2e.js`)
真起 socket 走完整链路,**8/8 通过**:
· `agent:offer` → 订阅者收到,端点/文件名/大小原样到达
· **公网端点在 offer 这条路上同样被拦**(没绕过同一条防线)
· `viewer:offer` → 目标设备的采集端收到
· `viewer:unsubscribe` → 收到回执,且**此后不再收到该设备的帧**
(这一条是"省流量"能不能成立的前提 —— 退不干净就是空话)
另:跑 `lan-signal-e2e` 时发现订阅本身有授权校验(匿名订阅被 `NOT_PAIRED` 拒),
这在设计之内,脚本里如实写明。
|
2026-09-19 00:00:05 +08:00 |
|
PMon Dev
|
247f5c6491
|
chore: pubspec 去掉一行孤立注释,与主干保持一致
先写 file_picker ^8 再改成 ^13 时留下的说明,内容已被上方那段(解释为何不能低于 13)
完全覆盖。留着会让两分支的 pubspec 多一处无谓差异 ——
而同一份清单两处定义正是本项目反复踩的坑,注释同理。
现在 pubspec 与主干**只剩 version 一行差异**(两支刻意不同的版本号)。
|
2026-09-18 23:17:37 +08:00 |
|
PMon Dev
|
49a9537f2c
|
feat(ui): 设备间传文件的界面(主干同步)
与 feature/eink 同一张卡片(该功能是产品级的,不属于水墨屏)。
同步方式:**新文件直取**(`transfer_card.dart` 与测试),
三个有差异的文件(`main.dart` / `viewer_page.dart` / `pubspec.yaml`)
一律**锚点精确插入**,且插入前逐个确认锚点唯一 ——
主干版本号保持 `1.0.9+9` 不动。
功能与纪律要点见 feature/eink 上那条提交,这里重述两条:
· **「先确认再收」**:粘贴码 → 看清将收到什么 → 才出现「收下」;
输入一变,上一次的确认立刻作废(防"看 A 收 B");
· ⚠️ **加原生依赖必须真跑一次 Android 构建**:
`file_picker` 8.x 自身用 android-34 编译,而其传递依赖要求 36+,
`pub get` 与 `analyze` 都过得去、**构建却会直接失败**。升到 13.x 解决。
验证:主干 `dart analyze lib test` 干净、客户端 314 → **319/319**、
**Android release 构建通过**(19.7MB)。
|
2026-09-18 23:17:09 +08:00 |
|
PMon Dev
|
ff070e8b7f
|
feat(ui): 设备间传文件的界面 —— 发出 / 接收两个区(1.1.7)
把上一步的领取码接上界面。放在**采集端页与查看端页各一张** ——
"我的设备间传文件"是双向的:任一台都可能既发又收。
## ★ 一条贯穿全卡片的纪律:「先确认再收」
接收是**两段式**的:粘贴码 → **看清"将收到什么(来自哪台机、什么名字、多大)"**
→ 再点「收下」。**不给"粘了码就自动开下"的路径** —— 文件会落到接收方磁盘上,
用户有权在写盘之前知道那是什么。
(与本项目对"破坏性操作要先列清单再确认"的态度一致。)
配套的一条细节:**输入内容一变,上一次的确认立刻作废** ——
否则会出现"看着 A 的信息、收的却是 B"这种最不该有的错。
## 保存到哪
优先**下载目录**(Android 上是 app-specific 外部目录,**系统文件管理器看得到**),
拿不到才退回应用私有目录。存在用户翻不到的地方等于"传了个寂寞"。
文件名来自对端,**只取 basename**(把分隔符换掉)—— 不让对端用路径做别的事。
## ⚠️ 引入依赖时踩的坑(值得记)
加 `file_picker` 后 **Android 构建直接失败**:
```
Dependency ':flutter_plugin_android_lifecycle' requires ... compile against version 36 or later
:file_picker is currently compiled against android-34.
```
即 **`file_picker` 8.x 自身用 android-34 编译**,而其传递依赖要求 36+。
→ 升到 **13.x** 解决(顺带 API 变了:`FilePicker.pickFiles()` 是**静态**方法、
返回 `List<PlatformFile>`、用户取消返回**空列表**而非 null ——
"取消"不是错误,不该被渲染成红色提示)。
⚠️ **教训:加原生依赖必须真跑一次 Android 构建**,
`pub get` 成功、`analyze` 干净都**不代表能构建**。
## 验证
- 新增 5 条界面纪律用例(没确认就没有「收下」、垃圾码给人话错误且不放行、
有效码先显示"将收什么"、**改输入即作废旧确认**、发出区初始无码)
→ 客户端 374 → **379/379**,`analyze` 干净
- **Android release 构建通过**:`com.inkwatch.eink` v17 / 19.7MB
(比上版大 0.6MB,来自 file_picker + path_provider,合理)
- 出包 `dist/inkwatch-eink-1.1.7.apk`(正式签名 CN=InkWatch)
|
2026-09-18 23:15:41 +08:00 |
|
PMon Dev
|
5f382fbca8
|
feat(lan): 同步领取码(主干 f16bdc3)
与主干同一份实现。同步前已 diff 确认两文件在编辑前一致,故可安全取;
并顺手把 pubspec 里 crypto 的注释与主干统一 —— 两次插入的文本有引号差异,
虽无害,但同一份清单两处定义正是本项目反复踩的坑,注释也不该例外。
(pubspec 现在只剩 version 一行差异,那是两支刻意不同的版本号。)
验证:客户端 368 → 374/374,analyze 干净。
|
2026-09-18 23:00:29 +08:00 |
|
PMon Dev
|
e48f9336d9
|
feat(lan): 领取码 —— 把"去哪取、凭什么取"编成一段可转给对方的字符串
场景确认为「**我的设备间传文件**」。跨设备传"令牌"本可以走服务端信令,
但那要给采集端加寻址 + 改下游事件白名单 + 部署(中改 + 生产风险)。
先用**码**把可用闭环做出来:发送方出示、接收方粘贴,
数据仍然是**两端直连、不经服务器**。信令优化留给下一步。
## 码里放什么
端点、令牌、**文件名与大小**(接收方在下载前就能看到"要收什么"——
这是"先确认再收"的前提)。**不放哈希**:完整哈希从响应头拿,
放进码里只会让它变长而不增加保证。
实测码长 127 字符(含中文文件名),复制粘贴与二维码都装得下。
## ★ 安全:端点必须只允许私有网段 IPv4
这条不是格式检查,是**安全底线**:码是从**外部粘贴进来**的,
里面的端点会被拿去做 HTTP 请求 —— 若放行主机名/公网地址,
粘贴一条码就等于让应用去连任意地址(SSRF 面)。
(而本功能的前提本就是"同一局域网",公网地址也不该出现在这里。)
测试里专门造了 `evil.example.com:80`、`http://...`、
`192.168.1.5:80/../../etc/passwd` 这类输入,**必须全被拒**。
⚠️ 服务端 `server/shared/lan-endpoint.js` 有语义相同的 JS 实现 ——
**那不是重复,是纵深防御**:两端处在不同进程、不同信任位置,
客户端这道是"别去连不该连的地方",服务端那道是"别把不该发的发出去"。
跨语言无法共享代码,故两处都要有、判据必须一致。文件里已写明。
## 令牌 32 → 16 字节
场景是"同一局域网 + 5 分钟 TTL",128 位随机在这个窗口内爆破不现实;
而令牌要塞进码里发给对方,每多 16 字节码就长 22 个字符 ——
抄错与二维码变密的代价是真实的。32 字节是给长期凭据用的强度,这里不是那个场景。
## 验证
- 新增 6 条用例(往返含中文名、**端点必须私有网段**、令牌字符集受限、
各类垃圾输入 → null 而非抛异常、字段缺失/类型不对、空白容忍)
→ 主干 308 → **314/314**,`analyze` 干净
- **完整闭环真机演示**(走用户实际路径):
登记 → 出示领取码(127 字符)→ 解析出「季度报告.pdf 16.1 KB」
→ 直连下载 → 校验 ✓ 通过 → 内容一致 true → 撤销后登记数 0
## 下一步
UI(发送方选文件 → 出示码;接收方粘贴码 → 收下)。那需要引入文件选择器,
是**界面层**的依赖,与"传输层零原生依赖"不冲突。
|
2026-09-18 22:59:38 +08:00 |
|
PMon Dev
|
419da432eb
|
feat(lan): 文件传输 A 方案(主干同步)
与 feature/eink 同一份实现 —— 该能力是产品级的,不属于水墨屏。
⚠️ 同步方式**刻意不整文件复制**,且这次**先 diff 确认**再动手:
· `lan_service.dart` 与测试:先跑 `git diff main feature/eink` 确认**一致** → 可安全取;
· `pubspec.yaml`:**已确认不一致**(版本号 `1.0.9+9` vs `1.1.6+16`,是刻意不同的)
→ 改用锚点**精确插入** crypto,主干的版本号保持不动。
(上一轮臣就是没查就整文件覆盖,把水墨屏内容带进主干、导致主干编译不过。)
功能与安全要点见 feature/eink 上那条提交,这里重述三条最关键:
· **令牌即授权,不拿"同网段"当授权**(合规红线:物理接近 ≠ 授权);
· 令牌无效/过期一律 **404 而非 403**(403 会泄漏"令牌存在过");
· 校验结果 `verified` 是**返回值**而非日志 ——
"没坏"与"验过了"是两件事。
验证:主干 `dart analyze lib test` 干净、客户端 **308/308**(301 + 新增 7 条)。
|
2026-09-18 22:53:48 +08:00 |
|
PMon Dev
|
6ade1320db
|
feat(lan): 文件传输 A 方案 —— 内网直连,数据不经服务器
方案文档里的 ③,也是产品负责人的**原始诉求**:文件不落服务端、
端到端传输、支持超出服务器磁盘容量的大文件。
实现落在第一步做出的那个"只绑内网的小服务"上 —— 这正是把底座先做出来的回报。
## 端点
| 端点 | 作用 |
|---|---|
| `offerFile(path)` | 登记一个待传出的文件,返回带**一次性令牌**的句柄 |
| `GET /file/<token>` | **流式**返回,支持 `Range` 断点续传 |
| `revoke(token)` | 传完即撤销 |
文件从发送端磁盘**直接流到**接收端磁盘,服务端一分钟都不参与 ——
所以既不受服务器 45G 可用磁盘的限制,也不烧流量包。
## 安全(这一块比功能重要)
- **令牌即授权,不拿"同网段"当授权** —— 那等于把物理接近当成授权依据,
正是隐蔽监控的形态(方案 2.1 的合规红线)。令牌由上层显式发放,与配对码同一思路。
- **令牌不存在/过期一律 404,刻意不用 403**:403 会泄漏"这个令牌曾经存在",
等于给爆破一个反馈信号。
- **路径不来自请求**(由登记时的本地路径决定)→ 结构上就没有路径穿越。
- **`stop()` 清空登记**:停止服务 = 不再共享任何文件。否则下次 start 会带着
上一轮的令牌复活 —— 令牌不该比它所属的那次服务活得更久。
(这条是被测试逼出来的:用例间单例状态残留,一查才发现语义本身就不对。)
## 校验:把"传完了"和"内容对"分开
`LanDownloadResult.verified` 是**返回值的一部分**,不是一行日志。
大文件最常出的问题是"字节数对得上、内容不对"(断点拼接错位、对端文件被换过)。
对端没给哈希时**不算验过** —— "没坏"与"验过了"是两件事,不能拿后者冒充前者。
哈希走**流式**(`sha256.bind(file.openRead())`),大文件不整个读进内存
—— 那正是本模块要解决的问题本身。
## 验证
- 新增 7 条用例(端到端 sha256 一致、令牌错 404、撤销后 404、
Range 206 + 分片内容正确、越界 416、中文文件名 URI 编码、登记不存在的文件报错)
→ 客户端 361 → **368/368**
- **真机实测**(本机真起服务、真传文件、真校验):
源文件 64.0 MB
登记(含流式 sha256)502 ms
传输 64.0 MB / 601 ms ≈ **106 MB/s**
端到端校验 ✓ sha256 一致,字节数一致
→ 1GB 也就十来秒,**"超出服务器磁盘的大文件"这条诉求成立**
## 依赖
新增 `crypto`(Dart 官方、纯 Dart、极小):大文件传输没有端到端校验是不合格的,
这个依赖值得。未引入任何原生插件,安装包体积基本不受影响。
|
2026-09-18 22:53:12 +08:00 |
|
PMon Dev
|
84ac9ddce3
|
test(lan): 内网端点交换的端到端穿透冒烟(跨四层,真 socket)
按项目纪律「**凡多层持同一份清单,必须有真实形态数据端到端穿透**」补上 ——
这条链路横跨四处:客户端上报 → 网关校验 → 网关存会话 → 随快照下发 → 客户端接收。
单测只能覆盖"网关校验"那一处,而本项目最贵的一类 bug 恰恰是**跨层静默丢数据**
(历史上白名单过严把 capabilities 全剥掉,谁都没发现)。
跑法(本脚本自带环境,不碰生产):
起临时 redis + 本地网关(无 PG 走 Redis 授权回退)→ 手动写一条 auth 授权 → 跑
★ 实测结果(**证明安全规则在链路里真的生效**,不只是"函数会拦"):
上报 5 条(2 条合法 + 公网地址 / 主机名 / 云元数据地址各 1 条)
→ 快照里只有 ["192.168.1.5:49504","10.0.0.7:1234"]
→ 三类非法值**在真实链路里被拦掉**
再次全部非法上报 → 旧端点**被清掉**(快照不再带该字段),
避免查看端拿着上一轮的过期地址去连
过程中顺带确认了两件既有机制(都不是本次改动引入的):
· 订阅有**授权校验**,匿名订阅会被 NOT_PAIRED 拒 —— 头一次跑就撞上了,
这反而说明授权守得住;本地无 PG 时走 `auth:<deviceId>` 的 Redis 回退;
· 无 PG 时网关是 stateless,不落库。
另:生产网关已同步部署并核对 —— `sha256sum` 与本地**一致**,
即生产跑的就是这份被 E2E 验证过的代码。
|
2026-09-18 22:47:51 +08:00 |
|
PMon Dev
|
fcd7d24803
|
feat(eink): 自检页显示「对端内网」可达性
把第二步的探测结果露出来 —— 否则"走内网还是走公网"只是代码里的一个变量,
用户看不见,也就没法判断到底有没有生效(本项目一贯要求可验证)。
**三种状态必须分得开**:
· 对端未上报(可能不在同一局域网)
· 已上报但探不通(会列出试过哪些端点)
· 可达(显示具体是哪个端点)
混成一句"不可用",用户就没法判断是"我这边的服务没起来"还是"对端根本没报"。
⚠️ 诚实边界:探测结果**不是实时推送的**,在下一次节流刷新时反映到界面(最多 10 秒)。
这是刻意的取舍 —— 为一个诊断行加一条推送通道要多一处要维护的接口,
而看板本来就有节流刷新,够用。
客户端 361/361、analyze 干净。
|
2026-09-18 22:40:20 +08:00 |
|
PMon Dev
|
977067c114
|
merge(main): 合入内网端点交换与可达探测
# Conflicts:
# client/lib/host/lan_service.dart
# client/lib/main.dart
# client/test/lan_service_test.dart
|
2026-09-18 22:40:20 +08:00 |
|
PMon Dev
|
723852e1da
|
feat(lan): 内网端点交换 + 可达探测(第二步,仍未改数据面)
第一步做出了只绑内网的小服务,这一步把"对端能否走内网"变成**可见的事实**。
## 服务端:新增内网端点校验(`server/shared/lan-endpoint.js`)
⚠️ **这是本步最关键的一处,不是"再算一遍"**:`lan_endpoints` 是
**客户端上报的不可信输入**,而它会被下发给**查看端**,查看端据此去连接。
不校验就原样下发,等于给了一个**"让别人的手机去连任意地址"的 SSRF 面** ——
上报 `169.254.169.254:80` 或某个公网地址,查看端就成了跳板。
故服务端只收 **IPv4 私有网段**(10/8、172.16/12、192.168/16),
端口 1..65535,最多 4 条,超长/主机名/IPv6 一律丢弃,
且**整条丢弃而不是"截断成合法"**(猜用户意图比丢掉更危险)。
> 客户端的 `pickLanAddress` 也在挑地址,但那是**选"发哪个"**(可用性),
> 这里是**验"收的是否合法"**(安全性)—— 两者在**信任边界两侧**,
> 不是同一份判定的两处拷贝。这条写进了文件头,免得后人当重复删掉。
网关侧:端点存**进程内 Map(刻意不落库** —— 内网地址是瞬态的,
换 Wi-Fi 就变,落库只会给查看端一个过期地址、让它白等一次超时),
随 `server:snapshot` 下发;**设备断连即回收**(同一设备重连时保留,
与 `_viewerEncoding` 的回收同一处理)。
## 客户端
- 采集端:`agent:hello` 带 `lan_endpoints`(**没有内网地址时不带该字段**,
而不是带空值 —— "没有"是合法状态,不该让查看端去猜空列表的含义)
- 查看端:收端点 → **只在列表变化时**探测一次(快照会反复到达,每次都探是浪费)
→ 记录"可达的是哪个"
- 探测超时**刻意短(800ms)**:探测只决定走哪条路,而**云端本来就通** ——
为了内网快而让用户每次先卡几秒,是把优化的代价转嫁给体验。
探不通立刻回云端,用户感知不到这一步
- 探测**要求 body 里 `ok:true`**,光有 200 不算 —— 同网段里别的服务也会返回 200
## 诚实边界
⚠️ **本步只做「探测 + 让用户看得见」,还没有改数据面** —— 指标仍走云端。
先让"走内网/走公网"成为**可验证的事实**再动数据面,
否则改完也说不清到底有没有生效(本项目吃过"静默失败"的亏)。
界面显示留在下一步(看板自检页)。
## 验证
- 服务端 27 条用例(**重点在不合法值必须被拦**:公网地址、云元数据地址、
回环、端口越界、主机名、IPv6、超长、超条数)→ 已接进 run-all
- 客户端探测 3 条(真起服务判可达、真起一个"只返回 200 内容不对"的假服务
判不可达、多端点取第一个可达)→ 客户端 **301/301**
- 服务端离线套件 **7/7 PASS**
|
2026-09-18 22:38:13 +08:00 |
|
PMon Dev
|
10aa9f4119
|
feat(lan): 局域网直连底座 —— 只绑内网的小服务(主干同步)
与 feature/eink 同一份实现(该底座是产品级的,不属于水墨屏)。
本次同步特意**不整文件复制** —— `main.dart` 在两分支已不同(差 90 行),
上一轮臣就是整文件覆盖把水墨屏内容带进主干、导致主干编译不过。
这次是:新文件直取,`main.dart` 用锚点**精确插入**两处(import + initState/dispose)。
底座的作用与理由见前一条提交。要点重申:
· 只绑 `pickLanAddress` 选中的那一个内网地址,**绝不绑 anyIPv4**;
· 只暴露只读无数据的 `/lan/ping`,未知路径 404;
· 将来接数据/文件用一次性令牌(**不拿"同网段"当授权**);
· 没有可用内网地址时**返回 null 且不启动**,不退而绑公网。
验证:主干 `dart analyze lib test` 干净、客户端 **298/298**(286 + 新增 12 条)。
|
2026-09-18 22:29:58 +08:00 |
|
PMon Dev
|
3104aeda8e
|
feat(lan): 局域网直连底座 —— 只绑内网的小服务(第一步)
方案落地第一步。它是两件事的**共同底座**(故先做它,不分成两遍):
· 指标走内网(LAN 优先)
· 大文件端到端传输(不经服务器 ⇒ 不受服务器磁盘与流量限制)
⚠️ **这一步最关键的不是功能,是安全**:
绝不能 `HttpServer.bind(InternetAddress.anyIPv4)` —— 那会同时绑到公网网卡上,
等于在公网开了一个端口。故:
· 只绑 `pickLanAddress` **选中的那一个**内网地址;
· 只暴露**只读、无数据**的 `/lan/ping`,未知路径一律 404;
· 将来接数据/文件时用一次性令牌 —— **不拿"同网段"当授权**
(那是隐蔽监控的形态,见方案 2.1 的合规红线)。
地址选择收敛在 `pickLanAddress` 一条纯函数里,因为**选错网卡的后果最难定位**
(表现是"服务看着开了、对面却连不上")。规则:排除回环 / 169.254 链路本地 /
IPv6 / 虚拟网卡(utun、docker、br-、veth、vmnet、tap…),**私有网段优先**。
没有可用内网地址时**返回 null 且不启动** —— 不退而求其次绑公网。
可测性上做了一处刻意取舍:入参收 `(网卡名, 地址列表)` 简单形状,
而不是直接吃 `NetworkInterface` —— **后者没有公开构造函数**,
吃了它就没法在测试里构造"VPN + Wi-Fi 同时存在"这类最容易选错的场景。
验证:
· 新增 12 条测试(9 条穷举地址筛选 + 3 条服务本身),客户端 346 → **358/358**
· 其中一条**真起服务 + 真发 HTTP 请求**(不是"调了 API 就算过"),
因为"绑了但对面连不上"正是本模块最容易出的问题;并断言未知路径 404
· **Mac 真机验证**:网卡 en0 192.168.0.85 / en7 192.168.0.12 → 选中前者;
服务起在 192.168.0.85:49504;/lan/ping → 200 {"ok":true,"v":1};未知路径 → 404
· `dart analyze lib test` 干净
接线:主壳 initState 启动、dispose 停止;启动失败**不影响主流程**
(局域网只是"更快",不是"必需"),只在看板自检页如实显示状态 ——
**"没开"和"开了但对面连不上"必须能分辨**。
下一步:查看端拿到地址后先试内网、失败回落云端;再做文件传输。
|
2026-09-18 22:29:11 +08:00 |
|
PMon Dev
|
49b76035d8
|
test(privacy): 收窄 WARN 噪声 —— 单字母域名不可能是真实邮箱
护栏的邮箱一条是 WARN(只列不判红),但规则太紧:本地部分限 1-3 字符,
于是测试夹具 `typed@x.com` / `other@y.com` 每轮都被列出来,
18 条噪声把真正的信号淹了。
判据其实是**域名**:`x.com` 这种单字母域名不可能是真实的企业或个人邮箱,
本地部分多长都不改变这一点。故放宽本地部分到 1-12 字符,域名仍限单字母。
(这与"护栏总报红就没人看"是同一类问题:噪声会让人对信号麻木。)
|
2026-09-18 22:22:21 +08:00 |
|
PMon Dev
|
a5b0d3f3f9
|
merge(main): 合入文件传输与局域网专项功能设计方案
# Conflicts:
# tests/privacy-scan.js
|
2026-09-18 22:20:41 +08:00 |
|
PMon Dev
|
52cc4bf4d6
|
docs: 文件传输与局域网专项功能技术方案
产品负责人提出两个需求:① 增加文件传输,不落服务端、端到端、支持超出服务器磁盘的大文件;
② 设备接入局域网时增加一组专项功能。本文给出可行性判定与具体设计。
**结论**:三条诉求都可行,但**必须先说清三条硬约束**,否则方案会打脸:
① **现有协议不能承载文件**。实测 `shared/protocol.js` 的边界:
字符串 ≤512 字节 / 单层键 ≤64 / 嵌套深度 1 —— 它是为小控制消息
(capabilities、keepalive)设计的,1MB 的分片连第一个约束都过不去。
→ 文件必须走**独立通道**,不能塞进 agent:metrics 那套。
(顺带确认:「对象一律丢弃」那段注释是**历史缺陷记录、已修**,
现在允许一层嵌套对象,capabilities 机制可用。)
② **比磁盘更该担心流量包**。腾讯云轻量按流量计费,服务端中继一个 10GB 文件
= 上行 10GB + 下行 10GB = 双倍。所以"不落盘"只是第一步,
**"不经过服务器"才是关键** —— 这两件事必须分开说。
③ **P2P 不是总能打通**(对称 NAT/CGNAT)。失败就得中继,而中继就是②里烧流量的东西。
这个取舍必须显式做出来并让用户知情,不能默默降级。
**文件传输三方案**:A 同网段直连(服务端零流量、100% 成功、零新依赖)
/ B 公网 P2P WebRTC(零流量但引入 flutter_webrtc 增重)/
C 服务端流式中继(唯一会烧流量的)。
推荐 **A 优先 → B 兜底 → C 默认关闭**,理由是收益/代价比而非技术偏好。
信令只需三拍,且**形状落在现有协议边界内,不动 protocol.js**。
**局域网专项五项**:① LAN 优先直连 ② LAN 高速采样 ③ LAN 大文件传输
④ 断公网可用(需采集端有界落盘)⑤ 链路自检(让用户看得见走了哪条路)。
建议顺序 ①②→⑤→③→④。
⚠️ **本文划了一条合规红线**:**同网段不能成为免授权理由**。
方案 2.1 的定位是"监控自己或经明确授权的设备、不提供绕过对方操作的隐蔽监控",
若做成"连上同一 Wi-Fi 就能看到",等于把物理接近当成授权依据 ——
那正是隐蔽监控的形态,与过审与杀软信誉直接冲突。
故:局域网只降低流量与延迟,**不降低授权门槛**;不做同网段自动发现入库。
另附风险清单(流量包、内置 HTTP 服务被扫、采集端落盘配额、
flutter_webrtc 增重、局域网误判不能只凭 IP)。
|
2026-09-18 22:20:20 +08:00 |
|
PMon Dev
|
99deda3980
|
test(eink): 探针脚本改为"先查谁占着设备",并记下 Neo2 无 ADB 通道
2026-09-17 为了连上 Neo2 绕了四轮弯路,根因**不在设备侧**:
Neo2 被 Parallels 直通进了虚拟机(`prl_vm_app` 持有它的 user client),
之后释放了又被 Microsoft Edge(Chromium WebUSB)认领。
而脚本当时只会说"未发现设备,请检查 USB 调试" —— 于是排查一直被引向
设备侧(开关、拔插、描述符),迟迟找不到主机侧的真凶。
改法:把"设备在总线上"与"根本没插上"**分成两条路**:
· 在总线上 → 打印它暴露的接口类别 + **谁持有它**(user client 占用者)
· 不在总线上 → 才是线/开关的问题
并明确写出 ADB 接口的判据(`255 / 66 / 1`)。
命令用的是**按设备递归取子树**(`ioreg -r -n "<名>"`),
不是全量 grep 拼字段 —— 后者会让不同节点的字段串在一起,
臣据此一度误判"Neo2 有 ADB 接口"。
⚠️ 同时记下一个**已确认的硬事实**:iReader Neo 2(SmartOS / Android 10)
在主机侧**无任何占用者**、设备侧 USB 调试开关反复确认已开的情况下,
仍只暴露一个名为 `MTP` 的 `255/255/0` 私有接口 ——
**该机型不产出 ADB 通道**,调试通道不可用。以后不要再在这上面花时间,
改用设备内「自检」页取数。
(脚本细节:`$NAME」` —— bash 会把紧跟其后的多字节字符字节并进变量名,
故一律写成 `${NAME}`。)
|
2026-09-18 00:13:34 +08:00 |
|
PMon Dev
|
408940b282
|
fix(eink): 看板全屏 —— 系统栏与主壳导航都要收起来(1.1.6)
真机反馈「开看板后底下的导航键没隐藏」。查下来是**两个成因,都要治**:
① **系统导航栏**:代码里**从未**调过 `setEnabledSystemUIMode` —— 一直显示。
修法:进看板 → `SystemUiMode.immersiveSticky`;退出 → `manual` + 全部 overlays。
· 用 `immersiveSticky` 而非 `immersive`:前者在用户误触屏幕边缘后**会自动重新隐藏**,
副屏是常驻的,不该被一次滑动永久唤出系统栏。
· 恢复用 `manual` + `SystemUiOverlay.values` 而非 `edgeToEdge`:后者要求 API 29+,
前者全版本可用,语义就是"把系统栏都还回来"。
② **应用自己的底部导航**(采集/查看两页签):主壳是 `IndexedStack` +
`bottomNavigationBar`,而后者是 Scaffold 的**独立区域** —— 看板渲染在 body 里,
它一无所知。修法:新增 `PmonEinkBoardMode`(`ValueNotifier<bool>`),
主壳用 `ValueListenableBuilder` 订阅,看板前台时不渲染导航。
· 用 ValueNotifier 而非像 `PmonEinkMode` 那样的静态 bool:这里必须**触发主壳重建**。
**判定只有一处**:`_syncBoardChrome()` 用同一个 `_eink && !_einkPaused` 同时驱动
系统栏显隐与主壳导航 —— 本项目已因"同一判据写两处"反复踩坑(指标清单、单位换算、
身份判据…),这里不再重演。
顺带修掉一个隐患:转屏校验原先用 `Future.delayed`(**不可取消**),页面销毁后
回调仍会醒来。已改成可取消的 `Timer` 并在 `dispose` 里取消。
(这是写测试时被 "Pending timers" 揪出来的 —— 测试的价值不只在断言。
`dispose` 里同时还原 `PmonEinkBoardMode` 与系统栏,免得离开这一页留下副作用。)
验证:
· 新增 2 条用例(`viewer_page_test` ⑨ 组)→ 客户端 344 → **346/346**
· **变异测试**:注释掉"通知主壳"那行 → 「进看板 → active 置真」**报红**;恢复即绿
· `dart analyze lib test` 干净
⚠️ 本提交是 **feature/eink 专属**,不同步主干:`ui/eink_mode.dart` 主干上不存在,
`main.dart` 的订阅逻辑也只在有水墨屏模式时才有意义。
(上一轮臣就是整文件复制到主干,把分支专属内容带了过去 → 主干编译不过。
这次明确不跨分支。)
设备事实卡补第七节,记录两个成因、取舍与遗留。
|
2026-09-17 22:44:44 +08:00 |
|
PMon Dev
|
81607cca5e
|
fix(eink): 系统不理会转屏请求 → 加"应用内旋转"兜底(1.1.5)
真机反馈「转屏功能无法使用」。排查:**不是代码没接上,是系统不理** ——
`SystemChrome.setPreferredOrientations` 发出去后窗口尺寸纹丝不动,
界面上当然"没反应"。这与设备事实卡里的「零传感器」自洽:
没有加速度计,SmartOS 层面就不做方向变化。
(接线本身是对的:按钮 → setState + 请求 + 落盘,manifest 也没有写死
`screenOrientation`、`configChanges` 也含 orientation|screenSize。)
⚠️ 由此得一条通用认识:**发了请求不等于生效**。能验证的只有窗口尺寸本身。
改法:
1. `_verifyOrientation()` —— 请求后回读窗口尺寸(900ms 宽裕),
宽高比没变成想要的那样就切到应用内旋转。
**不猜设备、不看厂商,只看事实。**
2. `_einkBoardShell()` —— 必要时套 `RotatedBox(quarterTurns: 1)`。
它的关键性质是**交换子节点的约束**(窗口 1072×1448 → 子节点拿到 1448×1072),
于是横屏布局正好按 1448×1072 排布、再旋转 90° 铺满物理屏 ——
**不缩放、字号无损**;命中测试也跟着旋转,按钮照常可点。
代价:系统 UI 不参与旋转,用户需把设备物理横置。
同时清掉系统 padding(旋转后它描述的是未旋转的窗口,语义已不对)。
3. 自检页新增「窗口(物理)」「布局(看板用)」「转屏方式」三行 ——
前两者**不相等**即证明在应用内旋转,一眼可验证。
对**支持系统转屏的设备(如 F50)零影响**:请求后尺寸确实变了 →
`_softRotate` 保持 false,行为与之前完全一致。
验证:
· eink_board_test 39 → 40 项,客户端 332 → **333/333**,analyze 干净
· 新用例「竖屏窗口里也排出 3 列」:断言物理窗口是 536×724(竖)
而看板列数是 3(横)—— **一竖一横并存**即证明约束真被交换
· ⚠️ 写这条测试时踩坑:不能沿用按屏幕 y 坐标分行的 `columnsOf()` ——
RotatedBox 之后 `getTopLeft` 返回的是**旋转后**的屏幕坐标,同一行会变成同一列。
**度量方法本身失效,别把"量不出来"当成"功能没实现"。**
设备事实卡(docs/eink-device-neo2.md)补第六节,记录该结论与验证方法。
|
2026-09-17 22:23:25 +08:00 |
|
PMon Dev
|
ba41b5ec5c
|
fix(account): 修正上一提交误把水墨屏内容带进主干
上一提交(登录态同步)在同步到主干时,臣用
`git checkout feature/eink -- client/lib/ui/account_card.dart`
把**整个文件**从分支复制了过来 —— 但两分支的这个文件**并不相同**
(实测差 5 行),于是把分支专属的两处带进了主干:
1. `import 'eink_mode.dart';` ← 主干根本没有这个文件
2. `CircularProgressIndicator` → `WaitIndicator`(水墨屏静止等待指示)
后果:主干 `dart analyze` 报 2 个 **error**(URI 不存在 / 类未定义),
即主干当时处于**编译不过**的状态,且已推送远端。
修法:用 `git checkout <上一提交>^ -- <文件>` 恢复主干原版,
再**只**插入 `didUpdateWidget` 一段(不含任何水墨屏内容)。
验证:
· `git diff` 对主干旧版**只有新增、无删除**
· `dart analyze lib test` = No issues found
· 客户端 286/286(282 + 4 条新同步测试)
⚠️ 教训:**跨分支复制文件前必须先 diff,不能假设两边一致**。
臣当时在命令行里顺手打了一句"(空 = 一致,可直接同步)",
但那只是**臣自己写的说明文字**,不是检测结果 —— diff 实际输出的是
`1 file changed, 2 insertions(+), 3 deletions(-)`,臣没看就往下走了。
**把假设写成结论,比没有结论更危险。**
|
2026-09-17 00:53:17 +08:00 |
|
PMon Dev
|
b19a8bf0a9
|
fix(account): 登录态在两个页面之间不同步 → 表现为"查看端和采集端各要登录一次"
真机反馈:在采集端登录后,切到查看端仍显示未登录,于是又登一次。
排查结论:**不是登录两次,而是登录态没同步**。令牌早就共享
(两端都写同一个 `AppConfig.accountToken`,且两端都订阅了 `ConfigStore.updates`),
整条链路本来是通的:
采集端登录 → onAuthChanged → ConfigStore.save
→ _publish 广播 → ViewerPage._onExternalConfig
→ sessionAffectingChanges(含 accountToken)为真
→ _restartWith → _startFromConfig → `_token = cfg.accountToken` ✅
断在**最后一环**:`AccountCard` 在 `initState` 里
`_token = widget.initialToken` 读了**一次**就不管了,且没有 `didUpdateWidget`。
而主壳是 IndexedStack,两页的卡片**都长期存活** —— 父级更新了 `initialToken`
也不会重跑 `initState`(同位置、无 key → State 被复用),
卡片内部仍是旧的空 token,照样渲染登录表单。用户看到的自然就是
"那边登了、这边还得再登一次"。
修法:给 `AccountCard` 补 `didUpdateWidget`,只在 `initialToken` 变化时同步;
登出(token 变空)时一并清掉 `_info`,否则会留着上个账号的档位与设备数。
边界(刻意保守):
· 邮箱**只补空、不覆盖** —— 只在输入框为空或仍等于旧值时才同步,
免得把用户正在敲的内容冲掉;
· token 未变时直接返回,不做无谓重建(避免自写盘自激)。
对照:`PairingCard` 全程直接用 `widget.accountToken`,所以没有这个问题 ——
同一应用里两种写法,错的那个恰恰是"看起来更规范"的那种(把入参缓存进 State)。
验证:
· 新增 4 条测试(`test/account_test.dart` ⑥ 组),客户端 328 → **332/332**
· **变异测试**:移除 `didUpdateWidget` → 「别处登录/登出后卡片要跟着变」两条**报红**;
恢复即绿 —— 证明测的就是这条修复本身
· `dart analyze lib test` 干净
|
2026-09-17 00:52:10 +08:00 |
|
PMon Dev
|
b5f33ef485
|
fix(account): 登录态在两个页面之间不同步 → 表现为"查看端和采集端各要登录一次"
真机反馈:在采集端登录后,切到查看端仍显示未登录,于是又登一次。
排查结论:**不是登录两次,而是登录态没同步**。令牌早就共享
(两端都写同一个 `AppConfig.accountToken`,且两端都订阅了 `ConfigStore.updates`),
整条链路本来是通的:
采集端登录 → onAuthChanged → ConfigStore.save
→ _publish 广播 → ViewerPage._onExternalConfig
→ sessionAffectingChanges(含 accountToken)为真
→ _restartWith → _startFromConfig → `_token = cfg.accountToken` ✅
断在**最后一环**:`AccountCard` 在 `initState` 里
`_token = widget.initialToken` 读了**一次**就不管了,且没有 `didUpdateWidget`。
而主壳是 IndexedStack,两页的卡片**都长期存活** —— 父级更新了 `initialToken`
也不会重跑 `initState`(同位置、无 key → State 被复用),
卡片内部仍是旧的空 token,照样渲染登录表单。用户看到的自然就是
"那边登了、这边还得再登一次"。
修法:给 `AccountCard` 补 `didUpdateWidget`,只在 `initialToken` 变化时同步;
登出(token 变空)时一并清掉 `_info`,否则会留着上个账号的档位与设备数。
边界(刻意保守):
· 邮箱**只补空、不覆盖** —— 只在输入框为空或仍等于旧值时才同步,
免得把用户正在敲的内容冲掉;
· token 未变时直接返回,不做无谓重建(避免自写盘自激)。
对照:`PairingCard` 全程直接用 `widget.accountToken`,所以没有这个问题 ——
同一应用里两种写法,错的那个恰恰是"看起来更规范"的那种(把入参缓存进 State)。
验证:
· 新增 4 条测试(`test/account_test.dart` ⑥ 组),客户端 328 → **332/332**
· **变异测试**:移除 `didUpdateWidget` → 「别处登录/登出后卡片要跟着变」两条**报红**;
恢复即绿 —— 证明测的就是这条修复本身
· `dart analyze lib test` 干净
|
2026-09-17 00:52:10 +08:00 |
|
PMon Dev
|
9e63636511
|
docs(eink): 用设备参数把版面算出来,确认 density 不影响决策
产品负责人指出"参数早就发过了,不该再等截图"。臣复核后确认:**参数确实够用**,
而且不必上机 —— 把两种 density 假设的逻辑尺寸喂给看板自己的代码
(widget 测试挂载),就能量出真机版面:
| 假设 | 方向 | 数值字号 | 列数 |
|---|---|---|---|
| A 572×772(若 300=densityDpi) | 竖 / 横 | 40.0 / 37.2 dp | 2 / 3 |
| B 536×724(若 300=PPI、density 2.0) | 竖 / 横 | 38.5 / 33.2 dp | 2 / 3 |
两种假设下**列数一致**、**数值字号 33~40dp**(标签的两倍以上)→ 版面合格。
⚠️ 关键澄清(避免把测算读成真机值):**widget 测试的字形是正方形的**
(每字宽 ≈ 字号),远宽于真机字体,故上表是**下限** ——
真机只会更大(长值基本能开到满档 40dp)。
结论:`densityDpi` 的精确值**不影响任何设计决策**,只用于事后核对。
设备卡里「如何确认」的措辞已从「待确认」改为「事后核对」,
并说明自检页的真正用途是内存占用与实际刷新计数。
(臣上一轮把"验证"说成了"前提",是要纠正的表述。)
|
2026-09-17 00:29:37 +08:00 |
|
PMon Dev
|
b19fbae68b
|
docs(eink): 按设备实测报告订正水墨屏目标机参数
设备报告(2026-09-16「电纸书助手」导出,实测非资料推测)显示此前的描述有两处不准,
且缺了一条**会影响设计判断**的关键事实:
1. **系统版本写错**:注释与文档此前写 `Android 11`,实为 **Android 10 (API 29)**。
顺手核过兼容性:Kotlin 里所有 `SDK_INT` 判定(>= O / >= TIRAMISU /
>= UPSIDE_DOWN_CAKE)都有守卫,29 上走安全分支;
`foregroundServiceType="dataSync"` 正是 API 29 引入的,在 29 上有效;
`POST_NOTIFICATIONS`(33) 被忽略;只用应用私有 filesDir,不涉分区存储。
2. **补上 CPU 与传感器**:MT8175B 四核 2GHz(aarch64,与 APK 的 arm64-v8a 匹配);
★ **传感器 0 个** —— 这条最要紧,见下。
★ 由「零传感器」推出的设计结论(已写进 docs/eink-device-neo2.md):
没有加速度计/陀螺仪 → 系统拿不到姿态 → **自动旋转在这台机器上等于不存在**。
所以横竖屏**只能由软件锁定**,看板里的「转屏」按钮**不是便利功能,
而是唯一的方向入口**,**不能省** —— 省掉它,用户在竖屏机器上永远看不到横屏版面。
现有实现(宿主用 SystemChrome 锁方向、看板只发指令)正好不依赖传感器,
经此确认是对的,不是过度设计。
另记一个仍待实测的数:逻辑尺寸 dp。报告里的「300」若指 densityDpi → 约 572×772dp;
若只是由 1072×1448@6″ 算出的 PPI(density 取常见 2.0)→ 约 536×724dp。
**两种假设下版面决策一致**(列数由宽高比决定:竖屏 2 列 / 横屏 3 列),
故不阻塞调版;确切值用看板「自检」页一屏截图即可定案。
|
2026-09-17 00:25:33 +08:00 |
|