Files
inkwatch/client/lib/host/resident_service.dart
T
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

449 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 常驻采集的**状态与动作** —— GUI 与 `pmon-install` CLI 之间的唯一接缝
///
/// ── 为什么 GUI 不自己做 launchctl / plist 那一套 ──────────────────────
/// 1. **物理限制**:macOS 沙箱 GUI 写不了 `~/Library/LaunchAgents/`,自启注册
/// 必须由非沙箱进程完成 —— 那个进程就是 `pmon-install`(见其文件头)。
/// 2. **单一真相源**:自启产物内容由 Rust 核心生成、落盘与启停由 CLI 执行。
/// GUI 若自己 stat 文件、自己 pgrep 进程,迟早与 CLI 说的不一致,
/// 用户会看到"卡片说在跑、CLI 说没起"这种两张嘴。
/// 故本文件只做三件事:**找到 CLI、跑它、把 `status --json` 解析成类型**。
/// 判据(四类:自启注册态 / 进程 / 配置 / 日志)只有一处,在
/// `bin/pmon_install.dart` 的 `statusSnapshot()` 里。
///
/// ── 为什么状态要解析成枚举而不是直接显示 JSON ────────────────────────
/// 界面需要"一句话 + 一个颜色 + 一个该做的动作"。把 JSON 直接铺到界面上,
/// 等于让 UI 层自己解释字段 —— 那就是第二份判据。这里做**唯一一次**解释,
/// 且做成纯函数([ResidentStatus.fromJson]),便于单测三态。
library;
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'autostart_label.dart';
import 'install_paths.dart';
/// CLI 执行器(可注入:测试不该真的去跑进程)
typedef ProcessRunner = Future<ProcessResult> Function(
String executable,
List<String> arguments, {
String? workingDirectory,
});
/// 常驻采集的三种可显示状态(+ 一种"问不出来")
enum ResidentState {
/// 采集进程在跑(自启已注册或手动起的都算)
running,
/// 装了但没起来:落位文件/自启项在,进程不在
installedNotRunning,
/// 没装:既没有自启项,也没落位文件
notInstalled,
/// 问不到状态(CLI 缺失、输出不是 JSON、执行失败)—— 界面必须如实说"不知道"
unknown,
}
/// `pmon-install status --json` 的类型化视图
class ResidentStatus {
const ResidentStatus({
required this.state,
this.pids = const [],
this.autostartRegistered = false,
this.autostartLoaded = false,
this.unitPath,
this.configExists = false,
this.configPath = '',
this.logPath = '',
this.logExists = false,
this.agentPath = '',
this.agentExists = false,
this.probeAvailable,
this.probeError = '',
this.serverUrl = '',
this.deviceId = '',
this.detail,
});
final ResidentState state;
final List<int> pids;
/// 自启项(plist/systemd unit/注册表项)是否已注册
final bool autostartRegistered;
/// 自启项是否已**加载**(launchd 已接管 / systemd 已 enable)
final bool autostartLoaded;
/// 自启产物路径(Windows 走注册表时为 null)
final String? unitPath;
final bool configExists;
final String configPath;
/// 采集端错误日志路径(launchd 的 `StandardErrorPath`)。
///
/// ⚠️ **这条事实目前在 Rust 与 Dart 各写了一份**:`rust-core/src/keepalive.rs`
/// 的 `macos_launchagent_plist` 把 `StandardErrorPath` 写成 `/tmp/<label>.err.log`,
/// `bin/pmon_install.dart` 的 `statusSnapshot()` 又自己拼一遍。今天两边**逐字
/// 相同**,但"两边碰巧一样"正是漂移的前兆 —— 改一处,status 就指向一个不存在
/// 的日志文件,而两边都不会报错。
///
/// 正解(未做,要动 Rust 结构 + FFI 出口):让 `AutoStartPlan` 多返回
/// `stderr_log_path`,Dart 侧**只读不算** —— 即改为读
/// `AutoStartPlan.stderr_log_path`,**勿再在本地拼 `/tmp/<label>.err.log`**。
final String logPath;
final bool logExists;
/// 落位的采集端可执行文件
final String agentPath;
final bool agentExists;
/// 采集端进程探针**是否可用**(`process.probe_available`)。
///
/// 三态而非布尔:`null` 表示这个 CLI 版本压根没报这个字段(老二进制/老快照)。
/// 只有明确 `false` 才允许把结论降级为 [ResidentState.unknown] ——
/// 老 CLI 没这个字段时若按"不可用"处理,会把所有老机器的正常状态打成"未知",
/// 那就是用另一种方式骗人。
final bool? probeAvailable;
/// 探针不可用的原因(`process.probe_error`),只在 [probeAvailable] 为 false 时有意义
final String probeError;
/// 采集端**实际读到的**网关地址(`config.server_url`,即安装器那份配置)
///
/// 为什么不让界面拿自己那份配置显示:macOS GUI 是**真沙箱**,它读写的
/// `ConfigStore` 在应用容器里;而常驻采集端由 launchd 拉起、读真实家目录。
/// 两份都活着、各说各话 —— 显示"采集端实际读到什么"才是真的。
final String serverUrl;
/// 采集端**实际读到的**设备标识(`config.device_id`)
final String deviceId;
/// 诊断说明(未知态时说明为什么问不出来)
final String? detail;
static const unknown = ResidentStatus(
state: ResidentState.unknown,
detail: '拿不到常驻采集状态',
);
/// 解析 `status --json` 的输出。
///
/// 容错原则:**字段缺了不能崩,也不能假装知道**。缺关键字段时退化为
/// [ResidentState.unknown] 并把原因写进 detail —— 界面宁可说"看不清",
/// 也不要猜一个"在跑/没跑"骗人。
static ResidentStatus fromJson(Map<String, dynamic> j) {
final process = _map(j['process']);
final autostart = _map(j['autostart']);
final config = _map(j['config']);
final log = _map(j['log']);
final installed = _map(j['installed']);
final rawPids = (process['pids'] as List?) ?? const [];
final pids = rawPids
.map((e) => e is num ? e.toInt() : int.tryParse('$e'))
.whereType<int>()
.toList();
// `probe_available` 是三态:缺字段(老 CLI)→ null,不看这个判据。
final rawProbe = process['probe_available'];
final probeAvailable = rawProbe is bool ? rawProbe : null;
final probeError = (process['probe_error'] as String?) ?? '';
final running = process['running'] == true || pids.isNotEmpty;
final registered = autostart['registered'] == true;
final loaded = autostart['loaded'] == true;
final agentExists = installed['agent_exists'] == true;
final ResidentState state;
// 探针不可用 ≠ 没在跑
String? resolutionDetail;
if (running) {
// 正面观察到的"在跑"永远优先:探针能给出 pid,就说明它至少是能用的
state = ResidentState.running;
} else if (probeAvailable == false) {
// ★「查不了」不是「没在跑」:本平台没有探针(Windows 暂无)时,
// `running` 只能是 false —— 若照旧往下判,会得出"已安装但没起来"这个
// **看起来很确定、实际没查过**的结论,卡片还会染成告警色。
// 本项目的规矩是"不知道"优于"错的答案",故降级为 unknown(中性灰)。
state = ResidentState.unknown;
resolutionDetail = '本平台无法判定采集端进程'
'(${probeError.isEmpty ? '缺少可用的进程探针' : probeError}),'
'这不等于"没在运行"';
} else if (registered || agentExists) {
state = ResidentState.installedNotRunning;
} else {
state = ResidentState.notInstalled;
}
return ResidentStatus(
state: state,
pids: pids,
autostartRegistered: registered,
autostartLoaded: loaded,
unitPath: autostart['unit_path'] as String?,
configExists: config['exists'] == true,
configPath: (config['path'] as String?) ?? '',
logPath: (log['path'] as String?) ?? '',
logExists: log['exists'] == true,
agentPath: (installed['agent_path'] as String?) ?? '',
agentExists: agentExists,
probeAvailable: probeAvailable,
probeError: probeError,
serverUrl: (config['server_url'] as String?) ?? '',
deviceId: (config['device_id'] as String?) ?? '',
detail: resolutionDetail,
);
}
static Map<String, dynamic> _map(Object? v) =>
v is Map ? Map<String, dynamic>.from(v) : const <String, dynamic>{};
}
/// 一次动作(安装/卸载)的结果。
///
/// 为什么带 `message` 而不是只给 bool:P0-6 已让 installer 返回非零并打印
/// **哪条命令失败**,失败时把那段原文回显给用户,比"安装失败"有用得多。
class ResidentActionResult {
const ResidentActionResult({
required this.ok,
required this.message,
this.exitCode = 0,
});
final bool ok;
final String message;
final int exitCode;
static const okNoOutput = ResidentActionResult(ok: true, message: '');
}
/// 卡片依赖的最小接口(测试用假实现替换,不必真跑进程)
abstract class ResidentOps {
Future<ResidentStatus> status();
Future<ResidentActionResult> install({
required String serverUrl,
required String deviceId,
});
Future<ResidentActionResult> uninstall();
}
/// 真实现:`Process.run` 调 `pmon-install` CLI
class ResidentService implements ResidentOps {
ResidentService({
ProcessRunner? runner,
String? cliPath,
String? cliSource,
String? projectDir,
// label 与安装器共用同一份(见 host/autostart_label.dart):漂移的后果是
// "明明装了、卡片却说未注册" —— 两边都正常,只是问的不是同一个自启项。
this.label = kDefaultAutostartLabel,
}) : _run = runner ?? _defaultRunner,
_cliPathOverride = cliPath,
_cliSourceOverride = cliSource,
_projectDirOverride = projectDir;
final ProcessRunner _run;
final String? _cliPathOverride;
final String? _cliSourceOverride;
final String? _projectDirOverride;
final String label;
static Future<ProcessResult> _defaultRunner(
String executable,
List<String> arguments, {
String? workingDirectory,
}) =>
Process.run(executable, arguments, workingDirectory: workingDirectory);
/// CLI 的落位(**从安装路径派生,不新写一份**)。
///
/// 安装器把可执行文件统一落在 [installBinDir](见 app_paths/install_paths),
/// 所以装好的 CLI 就在它旁边。
String get installedCliPath => p.join(installBinDir, _cliFileName);
String get _cliFileName => Platform.isWindows ? 'pmon-install.exe' : 'pmon-install';
/// 解析出"怎么调这个 CLI":`[exe, ...前置参数]`(**按优先级**返回全部候选)。
///
/// 三级回落(每一级都写清为什么存在,避免后人凭直觉删):
/// 1. 装好的 `pmon-install` —— 终端用户的实际形态;
/// 2. 开发树 `build/pmon-install` —— 开发机按 README 编译后的形态;
/// 3. `dart run bin/pmon_install.dart` —— 没编译也能用(开发/排障兜底),
/// 代价是慢(每次要 JIT 编译一遍)。
///
/// ⚠️ 为什么是"候选列表"而不是"第一个存在的就用"(真机实测驱动):
/// 开发机上 `build/pmon-install` 往往是**几天前编译的旧二进制**。实测它
/// 不支持 `status --json`,旧实现会打印一行**配置 JSON**:
/// `{"server_url":...,"device_id":...}`
/// 于是"取最后一个能解析的 JSON"会抓到那一行,解析成"未安装、路径全空" ——
/// 卡片**静默显示错误状态**,而不是"这个 CLI 太旧"。这正是"形状对、内容错"。
/// 故 [status] 会按候选顺序逐个试,**只接受形状正确的快照**(见 [looksLikeSnapshot])。
List<List<String>> resolveCliCandidates() {
if (_cliPathOverride != null) {
return [
[_cliPathOverride]
];
}
final proj = _projectDirOverride ?? Directory.current.path;
final out = <List<String>>[];
for (final c in [installedCliPath, p.join(proj, 'build', _cliFileName)]) {
if (File(c).existsSync()) out.add([c]);
}
final entry = p.join(proj, 'bin', 'pmon_install.dart');
if (File(entry).existsSync()) {
out.add([_cliSourceOverride ?? 'dart', 'run', entry]);
}
return out;
}
/// 第一个候选(安装/卸载用:那两个动作没有"形状不合规"这回事,
/// 失败原因原样回显就够;状态查询才需要逐个试)
List<String>? resolveCli() {
final all = resolveCliCandidates();
return all.isEmpty ? null : all.first;
}
@override
Future<ResidentStatus> status() async {
final candidates = resolveCliCandidates();
if (candidates.isEmpty) {
return const ResidentStatus(
state: ResidentState.unknown,
detail: '未找到 pmon-install(先执行 `dart compile exe bin/pmon_install.dart '
'-o build/pmon-install`,或直接使用已安装的版本)',
);
}
// 逐个候选试:**只接受形状正确的快照**。第一个不合规的(很可能是几天前
// 编译的旧二进制)不能直接把结果当真 —— 那会静默显示错误状态(见
// resolveCliCandidates 的说明)。全都不合规时,把最后一个的原因报出去。
ResidentStatus? rejected;
for (final cli in candidates) {
final st = await _statusOnce(cli);
if (st.state != ResidentState.unknown) return st;
rejected = st;
}
return rejected ??
const ResidentStatus(
state: ResidentState.unknown, detail: '拿不到常驻采集状态');
}
Future<ResidentStatus> _statusOnce(List<String> cli) async {
final r = await _run(cli.first, [...cli.skip(1), 'status', '--json', '--label', label]);
if (r.exitCode != 0) {
return ResidentStatus(
state: ResidentState.unknown,
detail: 'pmon-install status 失败(exit ${r.exitCode},${cli.first}):'
'${_tail(r.stderr)}',
);
}
final raw = (r.stdout as String).trim();
final decoded = _extractJson(raw);
if (decoded == null) {
return ResidentStatus(
state: ResidentState.unknown,
detail: '这个 pmon-install 不支持 `status --json`(${cli.first});'
'请重新编译:dart compile exe bin/pmon_install.dart -o build/pmon-install。'
'输出前 120 字:${raw.length > 120 ? raw.substring(0, 120) : raw}',
);
}
return ResidentStatus.fromJson(decoded);
}
/// 一份输出"像不像 status 快照"—— 形状校验,不是有 `{` 就算。
///
/// 真机血案:旧版 CLI 的 `status` 会打印一行**配置 JSON**
/// (`{"server_url":...,"device_id":...}`)。若只判"能解析成 Map",
/// 就会把配置当成状态快照 → 三个路径全空、state 落到"未安装",
/// 而卡片会一本正经地显示"尚未安装"(用户明明装过)。
/// 判据:必须同时有 `autostart` 与 `process` 两段(它们只出现在状态快照里)。
static bool looksLikeSnapshot(Map<String, dynamic> m) =>
m['autostart'] is Map && m['process'] is Map;
/// 从 CLI 的 stdout 里抠出**状态快照** JSON。
///
/// 两条真机教训(都实测过,别简化回去):
/// 1. 不能假设"最后一行以 `{` 开头":`dart run` 会把 `Running build hooks...`
/// **写在同一行**(`Running build hooks...{"label":"com.pmon..."}`);
/// 2. 不能"能解析成 Map 就收":旧版 CLI 的 `status` 会打印一行**配置 JSON**,
/// 那也会解析成功,但内容是错的(三个路径全空、state 落到"未安装")。
/// 故只接受 [looksLikeSnapshot] 认可的(含 `autostart` + `process` 两段)。
static Map<String, dynamic>? _extractJson(String raw) {
final lines = raw.split('\n');
for (var i = lines.length - 1; i >= 0; i--) {
final line = lines[i];
final start = line.indexOf('{');
if (start < 0) continue;
try {
final v = jsonDecode(line.substring(start));
if (v is Map<String, dynamic> && looksLikeSnapshot(v)) return v;
} catch (_) {
// 这一行不是完整 JSON,继续往前找
}
}
return null;
}
@override
Future<ResidentActionResult> install({
required String serverUrl,
required String deviceId,
}) =>
_action(['install'], extraArgs: [
if (serverUrl.isNotEmpty) ...['--server', serverUrl],
if (deviceId.isNotEmpty) ...['--device-id', deviceId],
]);
@override
Future<ResidentActionResult> uninstall() => _action(['uninstall']);
Future<ResidentActionResult> _action(
List<String> subcommand, {
List<String> extraArgs = const [],
}) async {
final cli = resolveCli();
if (cli == null) {
return const ResidentActionResult(
ok: false,
message: '未找到 pmon-install:先执行 '
'`dart compile exe bin/pmon_install.dart -o build/pmon-install`,'
'或把编译产物放到安装目录的 bin/ 下。',
);
}
final args = [...cli.skip(1), ...subcommand, '--label', label, ...extraArgs];
try {
final r = await _run(cli.first, args);
if (r.exitCode == 0) {
return ResidentActionResult(
ok: true,
message: _tail(r.stdout),
);
}
// 失败必须带**具体原因**:installer 会把"哪条命令失败"打到 stderr,
// 只回一句"安装失败"等于把最有用的一行丢掉。
final err = _tail(r.stderr);
final out = _tail(r.stdout);
return ResidentActionResult(
ok: false,
exitCode: r.exitCode,
message: err.isNotEmpty ? err : (out.isNotEmpty ? out : '命令失败,但没有输出'),
);
} catch (e) {
return ResidentActionResult(ok: false, message: '无法执行 $cli:$e');
}
}
/// 取输出的**最后几行**(安装器的失败原因通常在末尾,前面的都是过程日志)
static String _tail(Object? raw, {int lines = 4}) {
final s = (raw as String?)?.trim() ?? '';
if (s.isEmpty) return '';
final all = s.split('\n').map((l) => l.trimRight()).where((l) => l.isNotEmpty).toList();
return (all.length <= lines ? all : all.sublist(all.length - lines)).join('\n');
}
}