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 指向标题)。
1616 lines
71 KiB
Dart
1616 lines
71 KiB
Dart
/// 墨观 客户端 —— 采集端控制台 + 查看端
|
||
///
|
||
/// 角色(方案 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),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|