Files
inkwatch/client/lib/main.dart
T
PMon Dev 1d76c3b8ac
Design System Guard / guard (push) Failing after 7m11s
feat(design-system): 合入 V2 线并统一命名为 Inkwatch,补齐深色主题与状态符号图标化
本提交收口本轮四件事。三者改动在同一批文件里交织(改名触及 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

1616 lines
71 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.
/// 墨观 客户端 —— 采集端控制台 + 查看端
///
/// 角色(方案 3.2 表 3 / 方案 3.1):任一设备上的应用**既可上报本机数据,
/// 也可订阅查看已授权的其他设备**。故本应用有两个角色页:
/// · 采集端(本文件):配置入口、状态可视化(方案 13.3 三态)、日志
/// · 查看端(`ui/viewer_page.dart`):设备切换器、实时仪表盘、配对
/// 采集/重连/保活策略一律不在 UI 层,全部来自 Rust 核心。
library;
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'host/agent_lock.dart';
import 'host/app_config.dart';
import 'host/app_paths.dart';
import 'host/boot_report.dart';
import 'host/device_id.dart';
import 'host/link.dart';
import 'host/metric_format.dart';
import 'host/inkwatch_core.dart';
import 'host/resident_service.dart';
import 'host/startup_log.dart';
import 'platform/android_foreground.dart';
import 'platform/device_model.dart';
import 'platform/frame_jank_log.dart';
import 'host/account_api.dart';
import 'host/lan_service.dart';
import 'ui/account_card.dart';
import 'ui/eink_mode.dart';
import 'ui/metric_catalog.dart';
import 'ui/pairing_card.dart';
import 'ui/resident_card.dart';
import 'ui/skeleton.dart';
import 'ui/theme.dart';
import 'ui/transfer_card.dart';
import 'ui/viewer_page.dart';
/// 启动入口
///
/// ⚠️ **必须在 runApp 之前**注入 Android 的应用私有目录。
///
/// 真实事故(安卓实机,本轮):注入原本发生在采集页的 `_restore()` 里,而查看页
/// 首次打开时也会读配置 —— 一旦查看页抢在注入之前读,`ConfigStore` 就落到
/// `Directory.systemTemp`(HOME 在 Android 上不可靠)这个**非持久**兜底路径,
/// 于是:本地账号标识每次都重新生成、已登录的令牌丢失(用户表现为"又要重新登录")。
/// 两个页面各读一次配置,谁先谁后不固定,所以是概率性复现 —— 这类 bug 只在真机上出现。
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
if (AndroidForeground.isAndroid) {
ConfigStore.setPlatformDir(await AndroidForeground.filesDir());
}
// 启动事件日志的落盘目录:与配置同目录("数据目录在哪"的真相源仍只在 ConfigStore)。
//
// 为什么放在 runApp 之前:之后任何一步(含查看页首次读配置)都可能出问题,
// 而这一刻之前的事件本来也没什么可记的;早于 runApp 注入才能覆盖整条启动路径。
StartupLog.dir = ConfigStore.dir;
StartupLog.event('boot', {
'platform': Platform.operatingSystem,
'dir': ConfigStore.dir,
});
// 掉帧诊断(默认关;`--dart-define=INKWATCH_LOG_JANK=true` 打开,见 platform/frame_jank_log.dart)
installJankLogger();
// 主题偏好是显式的应用内设置(不跟随系统):runApp 前读一次,
// 避免首屏先闪一下浅色再跳到用户上次选的深色。ConfigStore.load()
// 自带兜底、不抛异常,故不存在"卡在启动"的路径。
final initialCfg = await ConfigStore.load();
appThemeMode.value = themeModeFromConfig(initialCfg.themeMode);
runApp(InkwatchApp());
}
/// 应用内**显式**主题偏好 —— 不跟随系统亮度。
///
/// 与 Web 侧「`html[data-iw-theme]` 显式控制、不写 prefers-color-scheme」一致:
/// 这里不采用系统跟随模式(不取 ThemeMode 的 system 值)。默认 light,
/// 切换后由 [setAppThemeMode] 落盘到 `AppConfig.themeMode`
/// (零依赖 JSON 配置,不引入 shared_preferences)。
final ValueNotifier<ThemeMode> appThemeMode =
ValueNotifier<ThemeMode>(ThemeMode.light);
/// 把配置里的 `'light' | 'dark'` 换算成 [ThemeMode](不采用系统跟随模式)。
ThemeMode themeModeFromConfig(String v) =>
v == 'dark' ? ThemeMode.dark : ThemeMode.light;
/// 切换深浅并持久化。先读回磁盘最新值再 copyWith —— 避免把别的页刚写的字段
/// 整份抹掉(与采集页 `_persist` / 查看页同一纪律)。
Future<void> setAppThemeMode(ThemeMode mode) async {
appThemeMode.value = mode;
try {
final onDisk = await ConfigStore.load();
await ConfigStore.save(onDisk.copyWith(
themeMode: mode == ThemeMode.dark ? 'dark' : 'light',
));
} catch (_) {
// 存不下不该让"本次切换"失效
}
}
class InkwatchApp extends StatelessWidget {
const InkwatchApp({super.key});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<ThemeMode>(
valueListenable: appThemeMode,
builder: (context, mode, _) => MaterialApp(
title: '墨观',
debugShowCheckedModeBanner: false,
// 主题必须**只有一处装配**(ui/theme.dart):
// 否则系统控件(输入框/菜单/对话框/SnackBar)与我们自绘的卡片会各是一套色与圆角,
// 表现为"弹窗像另一个应用"。旧版这里是就地 new 了一个只有 seedColor 的 ThemeData。
theme: buildInkwatchTheme(),
darkTheme: buildInkwatchDarkTheme(),
// 由应用内显式偏好驱动,不采用系统跟随模式。
themeMode: mode,
home: HomeShell(),
),
);
}
}
/// 双角色外壳:采集 / 查看 两个页签
///
/// 为什么用 IndexedStack 而不是"切页签就重建":采集页的状态里挂着正在运行的
/// 采集 agent(含 Rust 句柄与 socket)。若切到查看页就把它重建,**采集会中断**
/// —— 用户只是看一眼别人的设备,不该把自己的采集弄停。
/// 代价是两个页签同时存活(查看页的连接会持续保持),故查看页**懒创建**:
/// 只有用户真的切过去才建立连接。
///
/// 配置的所有权:两个页各写自己负责的字段(见 `_persist` 与查看页的 same 纪律),
/// 避免"一边保存把另一边刚写的字段抹掉"这类丢失更新。
class HomeShell extends StatefulWidget {
const HomeShell({super.key});
@override
State<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> {
InkwatchPalette get _p => InkwatchPalette.of(context);
int _tab = 0;
bool _viewerEverOpened = false;
@override
void initState() {
super.initState();
// 局域网底座:在本机内网地址上起一个**只对内网**的小服务,
// 供"指标走内网"与"大文件端到端传输"共用。
//
// 启动失败**不影响主流程** —— 局域网只是"更快、不烧流量",
// 不是"必需":失败时照旧走云端通道。所以这里不 await、不报错弹窗,
// 只在自检页里如实显示状态("没开"和"开了对面连不上"必须能分辨)。
unawaited(LanService.instance.start());
}
@override
void dispose() {
unawaited(LanService.instance.stop());
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _tab,
children: [
ControlPanelPage(),
// 懒创建:未进过查看页就不建立 socket(省电、省连接数)
_viewerEverOpened
? ViewerPage()
: SizedBox.shrink(),
],
),
// ★ 看板全屏时要**收起主壳自己的底部导航**。
//
// 为什么主壳必须订阅、而不能靠"看板自己盖住":看板渲染在 `IndexedStack`
// 的 body 里,而 `bottomNavigationBar` 是 Scaffold 的**独立区域** ——
// body 里发生什么它一无所知。不订阅的话,副屏底下会一直挂着
// 「采集 / 查看」两个页签(真机反馈"底下的导航键没有隐藏")。
bottomNavigationBar: ValueListenableBuilder<bool>(
valueListenable: InkwatchEinkBoardMode.active,
builder: (context, boardMode, _) => boardMode
? SizedBox.shrink()
: DecoratedBox(
decoration: BoxDecoration(
border: Border(top: BorderSide(color: _p.line)),
),
child: NavigationBar(
selectedIndex: _tab,
onDestinationSelected: (i) => setState(() {
_tab = i;
if (i == 1) _viewerEverOpened = true;
}),
destinations: const [
NavigationDestination(
icon: Icon(Icons.speed_outlined),
selectedIcon: Icon(Icons.speed),
label: '采集',
),
NavigationDestination(
icon: Icon(Icons.visibility_outlined),
selectedIcon: Icon(Icons.visibility),
label: '查看',
),
],
),
),
),
);
}
}
class ControlPanelPage extends StatefulWidget {
const ControlPanelPage({super.key});
@override
State<ControlPanelPage> createState() => _ControlPanelPageState();
}
class _ControlPanelPageState extends State<ControlPanelPage>
with WidgetsBindingObserver {
InkwatchPalette get _p => InkwatchPalette.of(context);
InkwatchTextStyles get _tx => InkwatchTextStyles.of(_p);
late final TextEditingController _urlCtrl;
late final TextEditingController _deviceIdCtrl;
ClientAgent? _agent;
/// 常驻采集的状态/动作(P0-5)。只做"调 inkwatch-install CLI + 显示结果",
/// 判据在 CLI 那一侧(单一真相源),本类不自己查进程/查文件。
final ResidentService _resident = ResidentService();
/// 上一次 `_start()` 失败的原因(成功或从未跑过为 null)。
///
/// 为什么要区分「起不来(抛了异常)」与「根本没起(静默未生效)」:
/// 前者至少有一条 SnackBar 与一行 print,后者什么都留不下 ——
/// 2026-09-17 macOS 实机上遇到的正是后者(进程活着、服务端 0 帧)。
String? _lastStartError;
/// 启动后自动开始采集(常驻采集端重启后应自恢复)。持久化在应用容器配置里。
bool _autoStart = true;
/// 正在启动采集(从点"开始"到 agent 起来之间)。
/// 为什么需要它:启动要等平台通道(前台服务/通知权限)+ Rust 初始化 + socket 建连,
/// 这期间若还显示配置面板,用户会以为"要点什么";显示骨架 + 一句说明才是诚实的等待态。
bool _starting = false;
/// 冷启动的第一小段:还在读本机配置。
///
/// 为什么单独一个状态:要跑一次平台通道(取应用私有目录的设备标识)+ 读盘之后,
/// 才知道用户上次是"自动开始"还是"停在配置页" —— 在这之前显示哪一屏都是猜。
/// 若直接显示配置面板,冷启动会**先闪一下表单再跳走**,用户读到的同样是
/// "是不是要我填什么"(产品负责人反馈的"刚进来体验差"就是这一类)。
///
/// 它一定会在 `ConfigStore.load()` 之后被清掉 —— 该函数自带兜底、**不抛异常**
/// (见 `host/app_config.dart` 的 `load()`),故不存在"卡在骨架屏出不来"的路径。
bool _restoring = true;
/// 加载配置时自动修正过的项(见 `host/config_migration.dart`)。
/// 非空时在仪表盘顶部显示告知卡 —— 改了用户的本地数据就必须说。
List<String> _configFixes = const [];
/// 本机稳定硬件指纹(Android = `ANDROID_ID` 完整值;桌面端 = 主机名)。
///
/// 用途只有一件:让服务端在**认领**时认出"这还是同一台机",
/// 从而把它历史上用过的旧 device_id 的记录并进来 ——
/// 否则同一台手机在设备列表里会出现两条(真事故,见 `host/device_id.dart`)。
/// ⚠️ 不是凭据:可猜、不参与鉴权,只用于归并候选匹配。
String _hardwareId = '';
AppConfig _cfg = AppConfig();
/// 已登录令牌与邮箱(与配置同步;配对卡要据此判断"本机归属")
String _token = '';
String _accountEmail = '';
/// 账号 REST(认领/我的设备)。按配置的业务地址自建,与查看页同一份实现。
AccountApi? _accountsApi;
AccountApi _ensureAccountsApi() {
_accountsApi ??= AccountApi(
businessUrl: _cfg.businessUrl.isEmpty
? deriveBusinessUrl(_cfg.serverUrl)
: _cfg.businessUrl,
);
return _accountsApi!;
}
/// 从磁盘重读账号字段 —— 因为登录入口可能在另一个页签(查看页)用过。
/// 之所以要这一个动作:两页各持一份内存配置,跨页签不会自动同步;
/// 与其猜,不如给用户一个明确的"刷新",并在归属行里写清怎么用。
Future<void> _reloadAccountFromDisk() async {
try {
final cfg = await ConfigStore.load();
if (!mounted) return;
setState(() {
_token = cfg.accountToken;
_accountEmail = cfg.accountEmail;
_accountsApi = null; // 业务地址可能也变了,下次重建
});
} catch (_) {}
}
/// 认领成功后:为这台设备签发设备令牌并注入采集端。
///
/// 为什么紧跟认领做:设备一有归属,`device_id` 就会出现在查看端列表里(别人看得见)。
/// 不带令牌的 hello 谁都能发 —— 拿你的 id 发几帧就能往你的历史里灌假数据。
/// 签发令牌后采集端会立即重连并带上它,从此只有这台机器能代表这个 id 上报。
Future<void> _issueDeviceTokenFor(String deviceId) async {
if (_token.isEmpty) return;
final r = await _ensureAccountsApi()
.issueDeviceToken(deviceId: deviceId, token: _token);
if (!mounted) return;
if (!r.ok) {
// 失败要明说:否则用户以为"已经防冒用了",实际还在裸奔
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('设备令牌签发失败:${r.message ?? "未知原因"}')),
);
return;
}
try {
final onDisk = await ConfigStore.load();
await ConfigStore.save(onDisk.copyWith(deviceToken: r.deviceToken));
} catch (_) {}
_agent?.setDeviceToken(r.deviceToken);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已签发设备令牌,采集端已重连:此 id 只能由本机上报')),
);
}
/// 本页登录/登出后的处理(只改本页负责的字段 + 落盘)
Future<void> _onAuthChanged(String? token, String? email) async {
setState(() {
_token = token ?? '';
_accountEmail = email ?? '';
});
try {
final onDisk = await ConfigStore.load();
await ConfigStore.save(onDisk.copyWith(
accountToken: _token,
accountEmail: _accountEmail,
));
} catch (_) {}
}
/// Android:后台服务/权限状态与 ROM 引导文案(文案来自 Rust 核心,不在 Dart 侧重写)
ForegroundState _fg = ForegroundState();
List<String> _androidGuidance = const [];
/// 指标值 → 显示文本的**唯一出口**(含速率迟滞、量级滞回、时长换算)。
///
/// **上下行各持自己的状态**(`MetricFormatter` 按 key 分管),两个方向速率
/// 常差很多,共用一个单位会互相拖拽(下行 39Mb/s 把上行也钉在 Mb/s)。
/// 状态放在 State 里而非 build 内,否则每次重建都会重置迟滞计数。
final MetricFormatter _fmt = MetricFormatter();
// 宿主层是纯 Dart(不依赖 Flutter),状态以 Stream 外发;
// GUI 侧只需包一层 ValueNotifier 就能接进 ValueListenableBuilder。
final ValueNotifier<AgentStatus> _statusVn =
ValueNotifier(AgentStatus());
final ValueNotifier<List<String>> _logVn = ValueNotifier(const []);
final List<StreamSubscription<dynamic>> _subs = [];
@override
void initState() {
super.initState();
_urlCtrl = TextEditingController(text: kDefaultServerUrl);
// 默认用主机名做设备标识,便于在服务端一眼认出是哪台机器
_deviceIdCtrl = TextEditingController(text: Platform.localHostname);
WidgetsBinding.instance.addObserver(this);
// 订阅"配置变更":查看页也可以登录/登出(它与本页共写一份 config.json),
// 没有这个订阅,本页的账号卡会一直显示启动时的旧状态(真机实测过同一现象)。
_cfgSub = ConfigStore.updates.listen(_onExternalConfig);
_restore();
}
StreamSubscription<AppConfig>? _cfgSub;
/// 别处(查看页)改了配置 → 只同步本页**显示用**的账号字段。
///
/// 刻意不重读服务器地址去改输入框:用户可能正在输入,且正在采集时换地址必须由
/// 用户显式点"停止/开始"来承担后果(这里是采集页,动的是正在跑的链路)。
/// 账号字段是纯显示,跟着变没有副作用。
void _onExternalConfig(AppConfig cfg) {
if (!mounted) return;
if (cfg.accountToken == _token && cfg.accountEmail == _accountEmail) return;
setState(() {
_token = cfg.accountToken;
_accountEmail = cfg.accountEmail;
});
}
/// 上报用的**人类可读设备名** —— 取机型(`F50` / `MacBook Pro · M3 Max`)。
///
/// 为什么不直接用 `Platform.localHostname`:Android 上它返回 `localhost`
/// (查看端列表里所有安卓机都叫这个,完全认不出是哪台);macOS 上返回
/// `用户名 + 主机名`,长且含隐私。机型才是人认设备的方式。
///
/// 取不到机型时**退回主机名** —— 宁可长,也不编一个假机型(与服务端
/// "读不到就报 null、不报 0"是同一条诚实边界)。取法见 `platform/device_model.dart`。
Future<String> _displayDeviceName() async {
final model = await resolveDeviceModel();
return model.isEmpty ? Platform.localHostname : model;
}
/// 默认设备标识。**只在配置里还没有 device_id 时调用一次**。
///
/// Android 上**不能**用 `Platform.localHostname`(返回 "localhost",会让所有安卓设备
/// 撞成同一个 device_id,而 device_id 是设备/授权/指标全挂在它上面的主键)。
/// 改用 `ANDROID_ID`。
///
/// ⚠️ **绝不要把机型名掺进来**(曾经是 `android-<机型slug>-<后6位>`):
/// device_id 是主键,机型是显示名,后者本来就该能改 ——
/// 两者混在一起时,"修一下机型显示"这个动作会把主键改掉,
/// 服务端于是把它当成一台新设备,已有授权与历史全断。
/// 生产数据实证见 `host/device_id.dart` 的文件头。
///
/// ⚠️ 也**不要**对已落盘的 device_id 做任何"重新推导":再改一次等于再断一次授权。
/// `_restore()` 的既有语义(配置里有值就直接用)就是这条保证。
Future<String> _defaultDeviceId() async {
if (!AndroidForeground.isAndroid) return Platform.localHostname;
return deriveDeviceId(hardwareId: await AndroidForeground.androidId());
}
/// 本机稳定硬件指纹(上报给服务端做归并,见 `_hardwareId` 的注释)。
///
/// 与 `_defaultDeviceId()` 用的是同一个硬件来源,但**取全长、不做派生**:
/// 派生规则将来还可能变(已经变过一次:曾掺机型名),而指纹必须永远不变。
/// 取不到就返回空串 —— 宁可失去归并能力,也不编一个假指纹
/// (与"读不到就报 null、不报 0"是同一条诚实边界)。
Future<String> _resolveHardwareId() async {
if (!AndroidForeground.isAndroid) return Platform.localHostname;
try {
return await AndroidForeground.androidId();
} catch (_) {
return '';
}
}
/// 保存配置 —— **只写本页负责的字段**。
///
/// 查看页会把 `account_id`(配对身份)写进同一个文件。若这里直接整份覆盖,
/// 用户先在查看页配对成功、再回采集页点「开始采集」,配对身份就被抹掉,
/// 之前配好的授权全部作废 —— 且现象极难复现(要按特定顺序点)。
/// 故先读回磁盘上的最新值,再合并本页负责的四个字段。
Future<void> _persist() async {
try {
final onDisk = await ConfigStore.load();
_cfg = onDisk.copyWith(
serverUrl: _cfg.serverUrl,
deviceId: _cfg.deviceId,
autoStart: _cfg.autoStart,
samplingIntervalMs: _cfg.samplingIntervalMs,
samplingIntervalManual: _cfg.samplingIntervalManual,
);
await ConfigStore.save(_cfg);
} catch (_) {
// 配置存不下不该让采集停摆(沙箱权限/磁盘问题 → 本次有效)
}
}
/// 读取上次配置;若开着「自动开始」且已有设备标识,则直接开跑
Future<void> _restore() async {
// ⚠️ 这里**不再**做 `setPlatformDir`:它已在 `main()` 里于 `runApp` 之前完成。
// 查看页首次打开也会读配置,两页谁先谁后不固定(见 `main()` 的文件头注释)。
//
// ⚠️ 这里**也不做** `_loadAndroidGuidance()`:那要跑多次平台通道,
// 压在启动路径上只会推迟首屏,而它与"能不能采"无关。
// 它唯一的调用点在 `_start()` 成功后的首帧回调里。
// (曾两处都调 → 白跑一遍平台通道,且与首帧回调那句注释自相矛盾;
// 由 `test/startup_path_test.dart` 第三段钉住"启动路径上不得出现它"。)
final cfg = await ConfigStore.load();
if (!mounted) return;
// 配置里的非法值已在 `ConfigStore.load()` 里被纠正并写回(唯一入口)。
// 这里只负责**告诉用户**改了什么 —— 悄悄改用户的本地数据是不可接受的。
final fixes = ConfigStore.lastFixes;
if (fixes.isNotEmpty) {
// 同时留一份到 logcat:真机排障时不必让用户读屏(release 包也能看)
// ignore: avoid_print
print('[inkwatch] 本地配置已修正 ${fixes.length} 处: ${fixes.join(' | ')}');
}
setState(() {
_cfg = cfg;
_configFixes = fixes;
_autoStart = cfg.autoStart;
// 读完盘才知道用户上次是否开着"自动开始",到这一刻 `_restoring` 才该结束。
_restoring = false;
// 上次开着自动开始 → 紧接着就要 `_start()`(中间只差取一次设备标识)。
// 先把 `_starting` 立起来,否则中间这一小段会**先闪一下配置表单**再跳走。
if (cfg.autoStart && _agent == null) _starting = true;
_token = cfg.accountToken;
_accountEmail = cfg.accountEmail;
if (cfg.serverUrl.isNotEmpty) _urlCtrl.text = cfg.serverUrl;
});
// 设备标识可能是异步取(Android 走平台通道),故在 setState 之外处理
final devId = cfg.deviceId.isNotEmpty ? cfg.deviceId : await _defaultDeviceId();
final hwId = await _resolveHardwareId();
if (!mounted) return;
setState(() {
_deviceIdCtrl.text = devId;
_hardwareId = hwId;
});
// 启动诊断口(默认关,`--dart-define=INKWATCH_LOG_BOOT=true` 才输出)。
//
// 用途:**覆盖安装残留**类故障的唯一取证口 —— release 包不可 `adb run-as`,
// 读不到私有目录里的 `config.json` / `viewer_cache.json`,只能让应用自己报。
// 详见 `host/boot_report.dart` 的文件头。
//
// ⚠️ 必须 `unawaited` 且放在这里(不是启动路径开头):它只打日志,
// 绝不能堵在首屏或 `_start()` 前面 —— 这一课刚在 `_loadAndroidGuidance` 上学过。
unawaited(reportBoot(cfg, dir: ConfigStore.dir, deviceIdInUse: devId));
// 启动事件留痕(默认落盘,见 host/startup_log.dart 的文件头)
StartupLog.event('restore_done', {
'server_url': cfg.serverUrl,
'device_id': cfg.deviceId,
'auto_start': cfg.autoStart,
'interval_ms': cfg.samplingIntervalMs,
'interval_manual': cfg.samplingIntervalManual,
'config_fixes': fixes.length,
});
// 默认即"开箱即采":deviceId 有兜底,故无需等用户填过才自动开始
if (cfg.autoStart) {
await _start();
}
// ★ 「开着自动开始却没起来」必须留痕 + 告知。
//
// 2026-09-17 macOS 实测踩到:进程活着、窗口正常、界面停在配置面板,
// 服务端 0 帧 —— 而当事人只会说"没有数据",拿不出任何线索。
// 这里把这种**静默未生效**变成一条可事后翻的事件 + 一条界面提示。
// `_lastStartError != null` 时说明 `_start()` 已自行弹过「启动失败」,不再重复打扰。
if (cfg.autoStart && _agent == null && !_starting && mounted) {
StartupLog.event('auto_start_no_effect', {
'start_error': _lastStartError ?? '(无异常抛出)',
});
if (_lastStartError == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('自动开始采集没有生效:请点「开始采集」重试,'
'或查看应用数据目录下的 startup_log.jsonl'),
duration: Duration(seconds: 10),
),
);
}
}
}
/// Android 自启/保活引导文案 + 权限状态。
/// 文案由 Rust 核心生成(`runtime_guidance`),避免在 Dart 侧再写一份。
Future<void> _loadAndroidGuidance() async {
try {
final plan = InkwatchCore.instance.planAutostart(
exePath: '', // Android 的自启是构建期清单声明,不需要可执行文件路径
label: 'com.inkwatch.inkwatch_client',
);
final guidance =
(plan['runtime_guidance'] as List?)?.cast<String>() ?? const <String>[];
final fg = await AndroidForeground.query();
if (!mounted) return;
setState(() {
_androidGuidance = guidance;
_fg = fg;
});
} catch (_) {
// 引导文案取不到不影响采集
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
unawaited(_cfgSub?.cancel());
for (final s in _subs) {
s.cancel();
}
_agent?.stop();
_statusVn.dispose();
_logVn.dispose();
_urlCtrl.dispose();
_deviceIdCtrl.dispose();
super.dispose();
}
void _bindStreams(ClientAgent agent) {
for (final s in _subs) {
s.cancel();
}
_subs
..clear()
..add(agent.statusStream.listen((s) {
// 每来一个采样推进一次单位状态(显式版/量级滞回/速率 EMA)。
// 必须在这里(而不是 build 里):build 一次采样可能跑多次,
// 会把稳定期计数翻倍,导致单位提前切换。
final f = s.lastFrame;
if (f != null) _fmt.updateAll(f);
_statusVn.value = s;
}))
..add(agent.logStream.listen((l) {
_logVn.value = l;
// Android 上没有终端,日志必须走 logcat 才能远程排查
// (Flutter 的 print 在 Android 会落到 I/flutter 标签下)
if (l.isNotEmpty && (Platform.isAndroid)) {
// Android 没有终端,日志必须落到 logcat 才能远程排查(Flutter 的 print
// 在 Android 上进入 I/flutter 标签)。此处**有意**使用 print。
// ignore: avoid_print
print('[inkwatch] ${l.first}');
}
}));
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// 回到前台视为"网络可能恢复" → 通知状态机主动重连(方案 13.2 第二层)
if (state == AppLifecycleState.resumed) {
_agent?.onAppResumed();
// 顺带重读账号字段:登录入口可能在另一个页签用过(两页各持一份内存配置)
unawaited(_reloadAccountFromDisk());
}
}
/// 「是不是已经有常驻采集在跑」—— 返回要告诉用户的那句话(`null` = 可以启动)。
///
/// ── 为什么要两层(真机形态决定的,不是防御性代码)────────────────
/// 锁只对**同形态**有效:`agentLockPath` = `ConfigStore.dir/agent.lock`,而
/// `ConfigStore.dir` 由 `HOME` 决定。macOS 沙箱 GUI 的 `HOME` 是应用容器,
/// agent 由 launchd 拉起、`HOME` 是真实家目录 —— **两个进程锁的是两个不同
/// 文件**,`isHeldByOther()` 在沙箱 GUI 里恒为 false。于是"agent 明明在跑、
/// 用户又点了一次开始采集"必然双写。故 macOS 补一层进程探测。
///
/// ── 为什么 agent 路径取自 CLI 而不是本地推导 ───────────────────
/// 沙箱 GUI 里 `installBinDirFor(macOS, homeDir)` 算出的 `homeDir` 就是容器
/// 路径,拼出来的是"容器/…/bin/inkwatch-agent",用它 pgrep 永远匹配不到(更糟:
/// 会得到一个看起来确定的 `notRunning`)。CLI 是非沙箱进程,`status --json`
/// 报的 `installed.agent_path` 才是 launchd 实际启动的那条。
///
/// ── 拿不到快照时为什么放行 ────────────────────────────────────
/// `status()` 只在"连 CLI 都找不到"(打包后的 .app 且从未装过)时拿不到快照,
/// 此时 `agentPath` 为空、无从探测。若这也拦,等于让**没装过常驻采集的
/// 机器彻底无法采集** —— 那是确定的永久性回归,换来挡住一个很偏的边界
/// (装过之后又把 CLI 删了)。故放行并留一条启动日志,不静默。
Future<String?> _guiCollectorBlockReason() async {
final lockHeld = await AgentLock.isHeldByOther();
var presence = AgentPresence.notApplicable;
if (currentPlatform == InkwatchPlatform.macOS && !lockHeld) {
// 锁已经命中就不必再花一次 CLI 往返;两层只要有一层说"有人在跑"就够
final st = await _resident.status();
if (st.agentPath.trim().isEmpty) {
StartupLog.event('resident_probe_skipped', {
'reason': 'status 快照不可用(agent_path 为空)',
'state': st.state.name,
'detail': st.detail ?? '',
});
} else {
presence = await probeInstalledAgent(
platform: currentPlatform,
agentPathFromCli: st.agentPath,
);
}
}
final reason = guiCollectorBlockReason(lockHeld: lockHeld, presence: presence);
if (reason != null) {
StartupLog.event('skip_agent_daemon_running', {
'via': lockHeld ? 'lock' : 'probe:${presence.name}',
});
}
return reason;
}
Future<void> _start() async {
if (mounted) {
setState(() => _starting = true);
}
_lastStartError = null;
try {
// ── 常驻采集已在后台跑 → 本界面只做查看,不自建采集(A+C 方案的 C)──
// 两个进程同时采集会双写同一 device_id:各自 seq 从 1 独立递增,
// 服务端的 seq 去重窗口把两条流混在一起判重 → 丢帧或重复帧,且不报错。
//
// 判据是**两层**(见 host/agent_lock.dart):
// ① 文件锁 —— 管"同形态"(两个 GUI / 两个 agent);
// ② 进程探测 —— **仅 macOS**,管"沙箱 GUI ↔ 非沙箱 agent"这种跨形态:
// 两家 `HOME` 不同 → 锁的是两个不同文件 → ①在沙箱 GUI 里恒为 false。
//
// 只判断桌面端:Android 上不存在常驻 agent 形态,且其平台目录要靠
// 启动早期注入才有意义,没必要在移动端做这次检查。
if (!AndroidForeground.isAndroid) {
final blockReason = await _guiCollectorBlockReason();
if (blockReason != null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(blockReason),
duration: Duration(seconds: 6),
),
);
}
// _starting 由 finally 统一置回 false
return;
}
}
// Android:先拿到前台服务(常驻通知 + 进程保活),再起采集
// 顺序不能反 —— 没有前台服务时系统随时可能回收进程,采集会断
if (AndroidForeground.isAndroid) {
await AndroidForeground.requestNotificationPermission();
await AndroidForeground.start();
}
final agent = ClientAgent(
serverUrl: _urlCtrl.text.trim(),
deviceId: _deviceIdCtrl.text.trim(),
deviceName: await _displayDeviceName(),
// 稳定硬件指纹:服务端据此在认领时把"同一台机的旧 device_id 记录"并进来
hardwareId: _hardwareId,
// Android 上内核计数器被 SELinux 拒,改由平台公开 API 喂计数
netCountersProvider:
AndroidForeground.isAndroid ? AndroidForeground.trafficStats : null,
// 已认领并签发过令牌的机器,每次启动都带着它 hello
deviceToken: _cfg.deviceToken.isEmpty ? null : _cfg.deviceToken,
// 采样排进空闲队列:同步 FFI 采集实测 6–10ms,滑动时不该跟手势抢帧
// (详见 link.dart 的 scheduleIdle 注释)
scheduleIdle: (task) => SchedulerBinding.instance.scheduleTask<void>(() {
task();
return null;
}, Priority.idle),
);
await agent.start();
// 若上次是人工设定的间隔,启动后立刻应用(否则会先用默认 1 秒跑,
// 且服务端下发还会把它钉回 1 秒 —— 用户会以为"我的设置丢了")
if (_cfg.samplingIntervalManual && _cfg.samplingIntervalMs != 1000) {
agent.setSamplingInterval(_cfg.samplingIntervalMs, manual: true);
}
_bindStreams(agent);
setState(() => _agent = agent);
StartupLog.event('started', {
'server_url': _urlCtrl.text.trim(),
'device_id': _deviceIdCtrl.text.trim(),
'interval_ms': _cfg.samplingIntervalMs,
});
// 记录本次配置,供下次启动自恢复
_cfg = _cfg.copyWith(
serverUrl: _urlCtrl.text.trim(),
deviceId: _deviceIdCtrl.text.trim(),
autoStart: _autoStart,
);
await _persist();
// Android 保活引导是**非关键**信息(一项都不影响采集本身),
// 放到首帧之后再取:它要跑多次平台通道,压在启动路径上只会让首屏更晚出现
if (AndroidForeground.isAndroid) {
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_loadAndroidGuidance());
});
}
} catch (e, st) {
// 启动失败必须留痕:Android 上只有 logcat 能看到,否则就是"点了没反应"
// ignore: avoid_print
print('[inkwatch] 启动失败: $e\n$st');
// 同时落盘一份:GUI 形态(macOS .app 经 open 启动)的 stdout 根本拿不到,
// 这条 print 等于没打。见 host/startup_log.dart 的文件头。
_lastStartError = '$e';
StartupLog.event('start_failed', {'error': '$e', 'stack': '$st'});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('启动失败:$e'), duration: Duration(seconds: 6)),
);
}
} finally {
if (mounted && _agent == null) setState(() => _starting = false);
}
}
Future<void> _stop() async {
await _agent?.stop();
// 复位显示状态:下次开始采集从 kb/s、基准量级起步(避免沿用上次的档位)
_fmt.reset();
// Android:停止采集时一并停掉前台服务,避免"通知还在但没有采集"的误导状态
if (AndroidForeground.isAndroid) await AndroidForeground.stop();
setState(() => _agent = null);
}
@override
Widget build(BuildContext context) {
final core = InkwatchCore.instance;
// 用 Scaffold.appBar 而不是自绘深色容器:状态栏内边距由框架负责,
// 不再出现"标题被系统时间压住"(旧自绘 header 的真机缺陷)。
return Scaffold(
appBar: _appBar(core),
body: _agent != null
? _dashboard(_agent!)
: (_restoring
? _bootingView()
: (_starting ? _startingView() : _configPanel())),
);
}
// ────────────────────────── 顶栏(浅色极简)──────────────────────────
//
// 改版说明:原来是自绘的**深色渐变容器**,两个问题:
// ① 顶部没吃 SafeArea 内边距 → 标题被系统状态栏的时间/图标压住(真机上肉眼可见);
// ② 拿 header 当"信息墙",标题、平台、口号、三个能力 pill、两个按钮全塞在深色块里,
// 首屏三分之一被它吃掉,而真正要看的指标与折线被挤到下面。
// 现在:标题与平台交给 AppBar(自动处理状态栏内边距),其余信息按重要性重新分配到卡片里;
// 口号与能力项降级成一行 caption —— 它们对用户是"看过一次就够"的信息。
PreferredSizeWidget _appBar(InkwatchCore core) {
return AppBar(
titleSpacing: InkwatchSpace.lg,
title: Text('墨观 采集端'),
actions: [
// 主题切换(最小入口):应用内显式偏好,不跟随系统。
// 图标随当前 brightness 取太阳/月亮;setAppThemeMode 会落盘到 AppConfig。
IconButton(
tooltip: '切换深色 / 浅色',
icon: Icon(Theme.of(context).brightness == Brightness.dark
? Icons.light_mode_outlined
: Icons.dark_mode_outlined),
onPressed: () => unawaited(setAppThemeMode(
Theme.of(context).brightness == Brightness.dark
? ThemeMode.light
: ThemeMode.dark,
)),
),
Padding(
padding: EdgeInsets.only(right: InkwatchSpace.lg),
child: Center(child: _badge(core.platformName)),
),
],
);
}
/// 中性小徽标(平台、计数)—— 替代原来的白色半透明 pill
Widget _badge(String text) => Container(
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: _p.surfaceMuted,
borderRadius: BorderRadius.circular(InkwatchRadius.pill),
),
child: Text(text,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: _p.textSecondary)),
);
/// 能力一行 caption(原先是深色块里的三个 pill + 一句口号)。
/// 只保留**用户能对应到行为**的三项;"采集核心独立于界面 / 断线按 seq 补传"
/// 属实现细节,留在 README 里,不占首屏。
Widget _capabilityLine(InkwatchCore core) => Text(
'指标 ${core.metricKeys().length} 项'
' · 后台常驻${core.canRunInBackground ? "支持" : "不支持"}'
' · 守护检查 ${core.guardianCheckIntervalMs ~/ 1000}s',
style: _tx.caption,
);
/// 目标地址显示成 `主机:端口`(默认端口不写端口号)。
/// 状态卡只需要"报给谁",带 scheme 的完整 URL 又长又刺眼。
String _shortTarget(String url) {
final u = Uri.tryParse(url);
if (u == null || u.host.isEmpty) return url;
final def = u.scheme == 'https' ? 443 : 80;
return u.hasPort && u.port != def ? '${u.host}:${u.port}' : u.host;
}
/// 骨架屏的**唯一形状**:说明 + 与真实网格同列同高的卡片 + 一块大卡。
///
/// 三处加载态(读配置 / 启动采集 / 采第一帧)共用它,避免"三个加载态长得不一样"
/// —— 那样用户会把"形状变了"误读成"出事了"。
Widget _skeletonScreen(String hint) {
return ListView(
padding: EdgeInsets.all(InkwatchSpace.lg),
children: [
LoadingHint(text: hint),
SizedBox(height: InkwatchSpace.lg),
SkeletonMetricGrid(cells: 4),
SizedBox(height: InkwatchSpace.lg),
SkeletonBox(height: 90, radius: InkwatchRadius.md),
],
);
}
/// 启动中的骨架 —— 用户看到"形状已就位、正在填内容",而不是一片空白或灰字
Widget _startingView() =>
_skeletonScreen('正在启动采集(连接网关、初始化采集核心)…');
/// 冷启动读配置中的骨架(此时还不知道该显示面板还是仪表盘,故不显示表单)
Widget _bootingView() => _skeletonScreen('正在读取本机配置…');
// ────────────────────────── 配置 ──────────────────────────
Widget _configPanel() {
return Center(
child: SingleChildScrollView(
padding: EdgeInsets.all(24),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 460),
child: Card(
elevation: 2,
child: Padding(
padding: EdgeInsets.all(22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('连接到网关',
style:
TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
SizedBox(height: 18),
TextField(
controller: _urlCtrl,
decoration: InputDecoration(
labelText: '网关地址',
helperText: '本机 Docker 全栈填 http://127.0.0.1:3000;经 nginx 填 http://localhost',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.dns_outlined),
),
),
SizedBox(height: 16),
TextField(
controller: _deviceIdCtrl,
decoration: InputDecoration(
labelText: '设备标识',
helperText: '作为 device_id 上报,服务端据此建立设备记录',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.devices_other_outlined),
),
),
SizedBox(height: 18),
// ── 沙箱导致的"两份配置"必须在这里就说清(诚实标注,非修好)──
// macOS GUI 是真沙箱:它自己读写的配置在应用容器里;而「常驻采集」
// 是 launchd 拉起的非沙箱进程,读真实家目录那份。用户在下面两项里
// 改地址,只有界面内采集会跟着变,常驻采集不会 —— 不说清的话,
// 症状是"改了网关地址、后台那台还在往旧地址发",两边都不报错。
if (currentPlatform != InkwatchPlatform.android) ...[
Text(
'上面两项只对「界面内采集」生效。常驻采集是另一个进程,'
'读的是安装器那份配置 —— 装好后在「常驻采集」卡里能看到它'
'实际读到的网关地址与设备标识。',
style: TextStyle(
fontSize: 11.5, color: _p.textSecondary),
),
SizedBox(height: 18),
],
SwitchListTile(
value: _autoStart,
onChanged: (v) {
setState(() {
_autoStart = v;
_cfg = _cfg.copyWith(autoStart: v);
});
unawaited(_persist());
},
contentPadding: EdgeInsets.zero,
title: Text('启动后自动开始采集'),
subtitle: Text('常驻采集端重启后应自恢复;关闭则每次手动点开始'),
),
SizedBox(height: 10),
if (AndroidForeground.isAndroid) _androidKeepaliveCard(),
FilledButton.icon(
onPressed: _start,
icon: Icon(Icons.play_arrow_rounded),
label: Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('开始采集并上报'),
),
),
SizedBox(height: 12),
Text(
'本客户端只采集系统性能指标(CPU / 内存 / 磁盘 / 网络 / 运行时长),'
'不上报屏幕内容、按键或文件数据。',
style: TextStyle(fontSize: 11.5, color: _p.textSecondary),
),
],
),
),
),
),
),
);
}
// ────────────────────────── Android 保活引导 ──────────────────────────
/// 如实告知 Android 的后台限制,并提供一键引导入口。
///
/// 为什么必须明说:Android 与桌面端不同 —— 系统会限制后台运行,国内 ROM
/// 还普遍拦截第三方自启。虚假承诺"装了就一定常驻"会在真实用户机上打脸,
/// 故此处把限制与需要用户配合的动作直接摆出来(方案 8.4 诚实边界)。
Widget _androidKeepaliveCard() {
final needNotif = !_fg.notificationGranted;
final needBattery = !_fg.ignoringBatteryOptimizations;
return Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: _p.surfaceMuted,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: _p.line),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
needNotif || needBattery
? Icons.warning_amber_rounded
: Icons.verified_outlined,
size: 16,
color: needNotif || needBattery
? _p.suspect
: _p.online,
),
SizedBox(width: 6),
Expanded(
child: Text(
needNotif || needBattery ? '需放行才能稳定后台采集' : '后台采集已就绪',
style: TextStyle(
fontSize: 12.5, fontWeight: FontWeight.w600),
),
),
],
),
SizedBox(height: 8),
if (needBattery)
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () async {
await AndroidForeground.requestIgnoreBatteryOptimizations();
await Future<void>.delayed(Duration(seconds: 1));
final st = await AndroidForeground.query();
if (mounted) setState(() => _fg = st);
},
icon: Icon(Icons.battery_saver, size: 15),
label: Text('加入电池优化白名单', style: TextStyle(fontSize: 12)),
style: TextButton.styleFrom(padding: EdgeInsets.zero),
),
),
for (final g in _androidGuidance)
Padding(
padding: EdgeInsets.only(top: 3),
child: Text('· $g',
style: TextStyle(fontSize: 11, color: _p.textSecondary)),
),
],
),
);
}
/// 不可得指标的说明 —— 把"—"讲清楚,别让用户以为是 Bug。
///
/// 起因:用户看到 CPU / 上下行显示"—",第一反应是"是不是没给权限"。
/// 实际原因分三类,必须区分开说(方案 8.4 诚实边界):
/// ① 平台系统级限制(**无权限可开**)—— Android 8.0+ 禁止普通应用读 /proc/stat
/// ② 本版本未接入采集 —— GPU / 温度 / 磁盘 IO(V1.5 路径)
/// ③ 其它 —— 兜底提示
Widget _unavailableNote() {
final caps = InkwatchCore.instance.capabilities();
if (caps.isEmpty) return SizedBox.shrink();
final missing = caps.entries.where((e) => e.value == false).map((e) => e.key).toSet();
if (missing.isEmpty) return SizedBox.shrink();
// 标签与"为什么不可得"都取自共享显示目录(ui/metric_catalog.dart)。
// 曾在此处硬编码两张「字段名 → 中文名」映射表来分组,等于第五份指标清单
// —— 改一个指标名要动五处,必漏。现在只按目录的原因分类挑出本机缺失项。
String labelsOf(MetricUnavailable kind) => kMetricDisplayCatalog
.where((s) => s.unavailable == kind && missing.contains(s.key))
.map((s) => s.label)
.join('、');
final byPlatform = labelsOf(MetricUnavailable.platformBlocked);
final byVersion = labelsOf(MetricUnavailable.notCollectedYet);
return Container(
margin: EdgeInsets.only(bottom: 14),
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: _p.surfaceMuted,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: _p.line),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info_outline, size: 15, color: _p.textTertiary),
SizedBox(width: 6),
Text('显示「—」的指标',
style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.w600)),
],
),
if (byPlatform.isNotEmpty) ...[
SizedBox(height: 6),
Text('· $byPlatform:本机系统不向普通应用开放。'
'Android 8.0 起为防旁路攻击禁止读取 /proc/stat 等内核文件,'
'任何权限都无法开启(root 设备不受此限)。',
style: TextStyle(fontSize: 11, color: _p.textSecondary)),
],
if (byVersion.isNotEmpty) ...[
SizedBox(height: 6),
Text('· $byVersion:本版本尚未接入采集(规划在 V1.5)。',
style: TextStyle(fontSize: 11, color: _p.textSecondary)),
],
SizedBox(height: 6),
Text('· 上下行速率:单位自动切换(kb/s ↔ Mb/s),'
'数值为短窗平滑值以抑制瞬时抖动;上报到服务端的仍是原始瞬时值。',
style: TextStyle(fontSize: 11, color: _p.textSecondary)),
],
),
);
}
// ────────────────────────── 仪表盘 ──────────────────────────
/// 本地配置被自动修正时的**告知卡**。
///
/// 为什么不静默改:`config.json` 是上一个版本写下的,加载时我们会纠正非法值
/// (见 `host/config_migration.dart`)。但"悄悄改掉用户的本地数据"是不可接受的
/// —— 用户会发现自己设过的频率变了、或者身份要求重新登录,却不知道为什么。
/// 所以每一处改动都摆在这里,说清"改了什么、为什么"。
///
/// 只在真的发生了修正时出现,可一键关掉(关掉只是收起提示,改动本身已生效)。
Widget _configFixNotice() {
return Card(
child: Padding(
padding: EdgeInsets.all(InkwatchSpace.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.build_outlined, size: 18, color: _p.suspect),
SizedBox(width: 8),
Text('本地配置已自动修正 ${_configFixes.length} 处',
style: _tx.title),
Spacer(),
IconButton(
tooltip: '收起',
icon: Icon(Icons.close, size: 18),
onPressed: () => setState(() => _configFixes = const []),
),
],
),
SizedBox(height: 4),
// 只陈述"改了什么",不吓唬用户:这些都是旧版本留下的非法值
..._configFixes.map((s) => Padding(
padding: EdgeInsets.only(bottom: 2),
child: Text('· $s', style: _tx.caption),
)),
],
),
),
);
}
Widget _dashboard(ClientAgent agent) {
final core = InkwatchCore.instance;
return ValueListenableBuilder<AgentStatus>(
valueListenable: _statusVn,
builder: (context, st, _) {
final f = st.lastFrame ?? const <String, dynamic>{};
return ListView(
padding: EdgeInsets.all(InkwatchSpace.lg),
children: [
if (_configFixes.isNotEmpty) ...[
_configFixNotice(),
SizedBox(height: InkwatchSpace.lg),
],
_statusRow(st, core),
SizedBox(height: InkwatchSpace.lg),
// 主角:实时指标与折线(首屏就要看到"在采什么")
_metricsGrid(f),
SizedBox(height: InkwatchSpace.lg),
_samplingCard(st),
SizedBox(height: 14),
// 常驻采集(P0-5):桌面端才有意义 —— 关掉窗口继续采,靠的是
// 系统自启托管的独立进程。Android 的常驻是前台服务,语义不同,
// 故整张卡在移动端不出现(见 canRunInBackground)。
if (core.canRunInBackground && !AndroidForeground.isAndroid) ...[
ResidentCard(
ops: _resident,
serverUrl: _urlCtrl.text.trim().isEmpty
? _cfg.serverUrl
: _urlCtrl.text.trim(),
deviceId: _deviceIdCtrl.text.trim().isEmpty
? _cfg.deviceId
: _deviceIdCtrl.text.trim(),
// 常驻进程起来后**停掉 GUI 自己的采集**:两个进程同时上报会把
// 两条 seq 流混进同一 device_id,服务端按会话去重判不清 →
// 丢帧或重复帧,且不报错(与 _start 里 AgentLock 那条同源)。
onResidentStarted: () => unawaited(_stop()),
),
SizedBox(height: 14),
],
// 账号:登录后本机可"认领"到自己名下 → 自己的查看端免配对可见
AccountCard(
api: _ensureAccountsApi(),
initialEmail: _accountEmail,
initialToken: _token,
onAuthChanged: (t, e) => unawaited(_onAuthChanged(t, e)),
),
SizedBox(height: 14),
// 方案 6.3 的「被查看设备生成并展示配对码 / 随时解除授权」两步都在本页
// (查看端只负责输入码)。业务地址留空时按网关地址推导,与查看页同规则。
PairingCard(
businessUrl: _cfg.businessUrl.isEmpty
? deriveBusinessUrl(_cfg.serverUrl)
: _cfg.businessUrl,
deviceId: _deviceIdCtrl.text.trim().isEmpty
? _cfg.deviceId
: _deviceIdCtrl.text.trim(),
accountsApi: _ensureAccountsApi(),
accountToken: _token,
accountEmail: _accountEmail,
hardwareId: _hardwareId,
onClaimed: (id) => unawaited(_issueDeviceTokenFor(id)),
),
SizedBox(height: InkwatchSpace.lg),
// 设备间传文件:发出 / 接收两个区。与账号、配对同级 ——
// 它同样是"这台机器与我的其它机器之间"的事,不是采样参数。
// 把采集端会话接进来 → 免去手工粘贴领取码(不通时自动退回粘贴)。
FileTransferCard(
publishOffer: (token, name, size, {relay}) =>
_agent?.publishOffer(
token: token, name: name, size: size, relay: relay) ??
false,
incomingOffers: _agent?.incomingOffers,
),
SizedBox(height: InkwatchSpace.lg),
// 解释性内容后置:首屏留给"在采什么","为什么是 —"放到下面
_unavailableNote(),
SizedBox(height: InkwatchSpace.lg),
_counters(st),
SizedBox(height: InkwatchSpace.lg),
_logPanel(agent),
SizedBox(height: 20),
],
);
},
);
}
/// 状态灯三态(方案 13.3:绿在线 / 黄心跳超时疑似离线 / 灰离线)
Widget _statusRow(AgentStatus st, InkwatchCore core) {
final (color, text) = switch (st.linkState) {
LinkState.online => (_p.online, '在线'),
LinkState.suspect => (_p.suspect, '疑似离线(心跳超时)'),
LinkState.offline => (_p.offline, '离线'),
};
// 一直连不上时给出可操作提示。
// 起因:用户装到手机上看到"离线 + 待补传一堆 + 已上报 0",但界面没说该改什么 ——
// 实际是手机与 Mac 不在同一局域网,地址不通。
final showHint = !st.connected && st.lastError != null && st.reconnectCount > 0;
return Card(
child: Padding(
padding: EdgeInsets.all(InkwatchSpace.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 状态行:状态灯 + 结论 + 操作按钮(按钮从旧深色 header 搬到这里 ——
// 它本来就是"对当前状态的操作",跟状态放一起才符合直觉)
Row(
children: [
_statusDot(color),
SizedBox(width: InkwatchSpace.md),
Expanded(
child: Text(text,
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: color)),
),
TextButton.icon(
onPressed: () => _agent?.forceReconnect(),
icon: Icon(Icons.refresh, size: 16),
label: Text('强制重连'),
),
SizedBox(width: InkwatchSpace.xs),
TextButton.icon(
onPressed: _stop,
icon: Icon(Icons.stop_circle_outlined,
size: 16, color: _p.danger),
label: Text('停止采集',
style: TextStyle(color: _p.danger)),
),
],
),
SizedBox(height: InkwatchSpace.sm),
// 只留"报给谁"这一件用户会去核对的事;device_id 在配置面板的
// 「设备标识」输入框里本来就有,状态卡再显示一遍是重复(信息密度)。
_kvLine('目标', _shortTarget(st.serverUrl)),
SizedBox(height: InkwatchSpace.sm),
_capabilityLine(core),
if (showHint) ...[
SizedBox(height: InkwatchSpace.md),
Container(
padding: EdgeInsets.all(InkwatchSpace.md),
decoration:
InkwatchCard.notice(_p.suspect, _p.suspectSoft),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.tips_and_updates_outlined,
size: 15, color: _p.suspect),
SizedBox(width: InkwatchSpace.sm),
Expanded(
child: Text('连不上网关(已重试 ${st.reconnectCount} 次)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: _p.textPrimary)),
),
],
),
SizedBox(height: InkwatchSpace.sm),
Text(st.lastError!,
style: TextStyle(
fontSize: 12, color: _p.textSecondary)),
SizedBox(height: InkwatchSpace.sm),
Text(
'排查:\n'
'· 手机需与服务器在同一局域网;在服务器上执行 '
'`ipconfig getifaddr en0`(macOS)取得真实 IP,\n'
' 点「停止采集」后在上方「网关地址」改成 http://<该IP>:3000\n'
'· 或用 USB 连接后执行 adb reverse tcp:3000 tcp:3000,'
'地址填 http://127.0.0.1:3000',
style: _tx.caption,
),
],
),
),
],
],
),
),
);
}
/// 状态灯(唯一实现)—— 直径与颜色都走令牌,别再各写一个 Container
Widget _statusDot(Color color) => Container(
width: 12,
height: 12,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
);
/// 「键 值」一行(设备/目标这类事实信息)——值用等宽可读色,键用中性灰
Widget _kvLine(String label, String value) => Padding(
padding: EdgeInsets.only(top: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 34,
child: Text(label, style: _tx.caption),
),
Expanded(
child: Text(value,
style: TextStyle(
fontSize: 12.5, color: _p.textSecondary),
overflow: TextOverflow.ellipsis),
),
],
),
);
/// 采集频率卡(方案 5.2「云端可下发采集频率」+ 本机可调)
///
/// 界面上必须说清两件事,否则用户会以为"改了没用":
/// · 当前**生效**的间隔是哪个(服务端也可能下发过)
/// · 手动设定后**不会被服务端下发覆盖**(否则重连一次就被打回默认值)
Widget _samplingCard(AgentStatus st) {
const options = <(int, String)>[
(500, '0.5 秒'), (1000, '1 秒'), (2000, '2 秒'), (5000, '5 秒'),
];
final current = st.samplingIntervalMs;
final manual = _agent?.manualInterval ?? _cfg.samplingIntervalManual;
return Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.timer_outlined, size: 18),
SizedBox(width: 8),
Text('采集频率',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
Spacer(),
Text('当前 ${(current / 1000).toStringAsFixed(current % 1000 == 0 ? 0 : 1)} 秒/帧',
style: TextStyle(fontSize: 12, color: _p.textSecondary)),
],
),
SizedBox(height: 8),
Wrap(
spacing: 8,
children: options
.map((o) => ChoiceChip(
label: Text(o.$2),
selected: current == o.$1,
onSelected: (_) => unawaited(_setSampling(o.$1)),
))
.toList(),
),
SizedBox(height: 6),
Text(
manual
? '已按你的设定采集(服务端下发的频率不再覆盖,直到你改回来)。'
: '当前用服务端下发的频率。任选一个即以本机设定为准。',
style: TextStyle(fontSize: 11, color: _p.textTertiary, height: 1.4),
),
],
),
),
);
}
/// 改采样间隔:立即生效 + 只写本页负责的配置字段
Future<void> _setSampling(int ms) async {
_agent?.setSamplingInterval(ms, manual: true);
setState(() => _cfg = _cfg.copyWith(
samplingIntervalMs: ms,
samplingIntervalManual: true,
));
await _persist();
}
/// 主卡片取值:**只转调唯一出口**(`host/metric_format.dart`)。
///
/// 本方法刻意不判断任何"这个指标该怎么算" —— 之前这里留了完整的一份实现、
/// 查看页留了半份,导致同一个指标在两页显示成不同单位(真机截图 `84710 MB`)。
/// 每帧推进一次显示状态的职责在状态流监听里,不在这里(build 会跑多次)。
String _formatForCard(MetricDisplaySpec s, dynamic x) =>
_fmt.format(s.key, s, x);
Widget _metricsGrid(Map<String, dynamic> f) {
// 首帧未到:显示与真实网格**同列数同尺寸**的骨架,而不是一排灰色的"—"。
// 产品负责人反馈"刚进入是灰色的、体验很差"——要等的部分就用"正在填"的样子呈现,
// 且形状一致可避免数据到达时整页重排(那又是一次"跳")。
if (f.isEmpty) {
final cells = kMetricDisplayCatalog.where((s) => s.primary).length;
return Padding(
padding: EdgeInsets.only(bottom: InkwatchSpace.sm),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LoadingHint(text: '正在采集第一帧…'),
SizedBox(height: InkwatchSpace.md),
SkeletonMetricGrid(cells: cells),
],
),
);
}
// 卡片清单来自**共享显示目录**(ui/metric_catalog.dart):本页不再自带
// 标签/单位/图标,也不再重复列举字段名 —— 查看页用的是同一份。
// 之所以必须共用:两个页面各写一套标签,改一处漏一处,用户会看到
// 同一指标在两个页面叫不同名字(本仓库已因"同一份清单写两处"翻车四次)。
// 目录里 primary=true 的就是本页首屏主卡片(其余指标在查看页完整展示)。
final cards = <(String, String, String, IconData)>[
for (final s in kMetricDisplayCatalog.where((s) => s.primary))
(s.label, _formatForCard(s, f[s.key]), s.key, metricIconOf(s.key)),
];
return LayoutBuilder(builder: (context, c) {
// 阈值按"卡片放得下两位数带单位"来定:实测手机逻辑宽度约 309dp,
// 原先 480 的阈值会让手机永远退化成单列(一屏只看到两张卡,信息密度过低)
final cols = c.maxWidth > 620
? 3
: (c.maxWidth > 300 ? 2 : 1);
return GridView.count(
crossAxisCount: cols,
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
mainAxisSpacing: InkwatchSpace.md,
crossAxisSpacing: InkwatchSpace.md,
// 同上:固定高度,别用宽高比(窄屏会算矮导致文字被裁)
mainAxisExtent: cols == 1 ? 66 : 84,
children: [
for (final (label, value, key, icon) in cards)
Container(
padding: EdgeInsets.symmetric(
horizontal: InkwatchSpace.lg, vertical: InkwatchSpace.md),
decoration: InkwatchCard.standard(palette: _p),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Icon(icon, size: 15, color: _p.textTertiary),
SizedBox(width: InkwatchSpace.sm - 2),
Expanded(
child: Text(label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: _p.textSecondary),
overflow: TextOverflow.ellipsis),
),
],
),
SizedBox(height: InkwatchSpace.sm),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
value,
style: _tx.metric.copyWith(
fontSize: 20,
color: f[key] == null
? _p.textTertiary
: _p.textPrimary,
),
),
),
],
),
),
],
);
});
}
/// 统计:**一行 caption**,而不是五个大数字块。
///
/// 理由(产品负责人反馈"信息过于密集"):这五项里只有"待补传/超窗丢失/重连"是用户
/// 需要**警觉**的,其他是背景数字。五个等重的大数字块会让人以为每项都要盯——
/// 实际上正常时它们全是 0 或常量。现在:正常项压成一行灰字,
/// **异常项才着色并显形**(0 不显示),要把注意力留给真正出事的那次。
Widget _counters(AgentStatus st) {
final parts = <TextSpan>[
TextSpan(text: '已上报 ${st.sentFrames} 帧'),
TextSpan(
text: ' · 采样 ${(st.samplingIntervalMs / 1000).toStringAsFixed(st.samplingIntervalMs % 1000 == 0 ? 0 : 1)} 秒/帧'),
];
if (st.pendingFrames > 0) {
parts.add(TextSpan(
text: ' · 待补传 ${st.pendingFrames}',
style: TextStyle(
color: _p.suspect, fontWeight: FontWeight.w600)));
}
if (st.droppedFrames > 0) {
parts.add(TextSpan(
text: ' · 超窗丢失 ${st.droppedFrames}',
style: TextStyle(
color: _p.danger, fontWeight: FontWeight.w600)));
}
if (st.reconnectCount > 0) {
parts.add(TextSpan(
text: ' · 重连 ${st.reconnectCount}',
style: TextStyle(color: _p.suspect)));
}
return Text.rich(
TextSpan(children: parts),
style: _tx.caption,
);
}
Widget _logPanel(ClientAgent agent) {
return Card(
child: Padding(
padding: EdgeInsets.fromLTRB(14, 12, 14, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('运行日志',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
SizedBox(height: 8),
SizedBox(
height: 190,
child: ValueListenableBuilder<List<String>>(
valueListenable: _logVn,
builder: (context, lines, _) {
if (lines.isEmpty) {
return Text('(暂无)',
style:
TextStyle(fontSize: 12, color: _p.textTertiary));
}
// ⚠️ 这里是**嵌套在页面 ListView 里的**日志列表。
// 必须 `NeverScrollableScrollPhysics`:否则手指落在日志上拖动时,
// 内层列表吃掉手势、外层页面纹丝不动 —— 用户读到的就是"卡住了"。
// `reverse: true` 让最新一行贴在底部(日志的自然阅读方向),
// 只渲染尾部若干行,避免长列表参与布局。
return ListView.builder(
reverse: true,
physics: NeverScrollableScrollPhysics(),
itemCount: lines.length,
itemBuilder: (context, i) => Padding(
padding: EdgeInsets.symmetric(vertical: 2),
child: Text(
lines[lines.length - 1 - i],
style: TextStyle(
fontSize: 11.5,
fontFamily: 'Menlo',
color: _p.textSecondary),
),
),
);
},
),
),
],
),
),
);
}
}