feat: 本地日志文件系统 + 修复双击打不开/向导完成无反应

用户实测两个问题(先装好驱动的机器):
1. 双击 App 图标不一定能打开——根因 single_instance:唤醒端口 38210 被
   残留占用(上次异常退出/TIME_WAIT)时,connect 无应答仍返回 false →
   main 直接 exit(0)。修复:端口被占但无应答判定为残留占用,照常启动
   (仅唤醒功能降级,FileLog 留痕);有应答才作为第二实例退出。
2. 向导第三步点"完成"没反应——根因 _finish 只有 finally 没有 catch:
   Autostart/UrlScheme(reg 三连)/FirstRun 任一抛异常被静默吞,Navigator.pop
   永不执行=对话框锁死。修复:每步独立 try-catch + 分步日志,成败都关闭
   向导,失败汇总 SnackBar 提示"详见运行日志"。

本地日志文件系统(用户要求:仔细记录、可发回分析、定时删除):
- lib/utils/file_log.dart:按天滚动 app-YYYYMMDD.log、单文件 5MB 轮转加
  序号、启动清理 7 天前旧文件(按文件名日期判定);全同步追加写(低频
  日志,保证异常退出前落盘);目录 mac ~/Library/Logs/zax-desktop-plugin/
  win %APPDATA%\zax-desktop-plugin\logs\(不引第三方依赖)
- AgentLog.write 挂文件输出(一处改动覆盖全部 22 个现有调用点);
  main.dart FileLog.init 提到最前,bootWarning/startError 全部落日志,
  启动横幅记录平台/exe 路径/参数
- pavo isolate 的 [pavo-diag] 诊断日志传日志路径(纯数据,isolate 安全)
  直接写文件——GUI 模式下 isolate stdout 本来就丢
- 插件页技术详情加"打开日志文件夹"按钮(Explorer/Finder 直达,用户打包
  app-*.log 发回分析)

19 个测试全过、analyze 零问题。
This commit is contained in:
dzc committed 2026-09-22 15:09:24 +08:00
1 parent bf0de9b8b5
commit 726210edcb
9 files changed
+302 -27

No files matched your search

+2
View File
@@ -4,6 +4,7 @@ import 'print/backend.dart';
import 'print/mock_backend.dart';
import 'server/http_server.dart';
import 'server/print_queue.dart';
import 'utils/file_log.dart';
/// 启动参数:--mock 模拟打印;--port=NNN / --port NNN 指定监听端口(默认 38200-38202 顺延)。
class AgentOptions {
@@ -44,6 +45,7 @@ class AgentLog {
}
// ignore: avoid_print
print(stamped);
FileLog.write(line); // 文件持久化(供现场排查,用户可取回分析)
_controller.add(stamped);
}
+13
View File
@@ -8,6 +8,7 @@ import 'setup/single_instance.dart';
import 'setup/url_scheme.dart';
import 'ui/app_shell.dart';
import 'ui/app_tray.dart';
import 'utils/file_log.dart';
/// 资安信插件助手(desktop-plugin):桌面插件集成软件。
/// 当前内置插件:饭卡打印机(card-printer);左侧菜单与插件页均由 lib/ui/routes.dart 驱动。
@@ -19,11 +20,18 @@ import 'ui/app_tray.dart';
Future<void> main(List<String> args) async {
WidgetsFlutterBinding.ensureInitialized();
// 文件日志最先初始化(此后的每一步现场都可追溯;目录不可用时自动禁用)
FileLog.init();
FileLog.write('==== 启动 ====');
FileLog.write(
'环境: ${Platform.operatingSystem} ${Platform.operatingSystemVersion} / exe: ${Platform.resolvedExecutable} / args: $args');
String? bootWarning;
try {
await windowManager.ensureInitialized();
} catch (e) {
bootWarning = '窗口管理初始化失败:$e';
FileLog.write(bootWarning);
}
// 单实例:zaxprint:// 唤起或重复双击会启动第二进程——通知常驻实例弹窗后自己退出
@@ -31,14 +39,17 @@ Future<void> main(List<String> args) async {
try {
isPrimary = await SingleInstance.acquire(
onWake: () async {
FileLog.write('收到唤醒(zaxprint:// 或重复双击):显示主窗口');
await windowManager.show();
await windowManager.focus();
},
);
} catch (e) {
bootWarning = '${bootWarning == null ? "" : "$bootWarning\n"}单实例检测失败:$e';
FileLog.write(bootWarning);
}
if (!isPrimary) {
FileLog.write('已有常驻实例(唤醒通知已发),本进程退出');
exit(0);
}
@@ -48,6 +59,7 @@ Future<void> main(List<String> args) async {
await agent.start();
} catch (e) {
startError = '$e';
agent.log.write('服务启动失败:$startError');
}
try {
@@ -63,6 +75,7 @@ Future<void> main(List<String> args) async {
agent.log.write('托盘初始化失败(不影响其他功能):$e');
}
agent.log.write('界面启动(日志目录:${FileLog.dir ?? "不可用"})');
runApp(DesktopPluginApp(agent: agent, startError: startError ?? bootWarning));
}
+13 -5
View File
@@ -5,6 +5,7 @@ import 'package:ffi/ffi.dart';
import '../../image/card_image.dart';
import '../../server/protocol.dart';
import '../../utils/file_log.dart';
import '../backend.dart';
import '../cups_backend.dart' show noopLog;
import 'pavo_ffi.dart';
@@ -29,7 +30,11 @@ class PavoPrintBackend implements PrintBackend {
String get name => 'pavo';
@override
Future<PrinterStatus> getStatus() => Isolate.run(pavoStatusSync);
Future<PrinterStatus> getStatus() {
// 日志文件路径以纯数据传入 isolate(isolate 内 stdout 在 GUI 模式下丢失)
final logPath = FileLog.currentPath;
return Isolate.run(() => pavoStatusSync(logPath));
}
@override
Future<void> printOneCard(PrintRequest request) {
@@ -42,21 +47,23 @@ class PavoPrintBackend implements PrintBackend {
final backColor = back?.color;
final backK = back?.k;
final copies = request.copies;
final logPath = FileLog.currentPath;
return Isolate.run(() => pavoPrintSync(
frontColor: frontColor,
frontK: frontK,
backColor: backColor,
backK: backK,
copies: copies,
logPath: logPath,
));
}
}
/// isolate 入口:查询打印机状态(顶层函数,不捕获任何实例状态)。
PrinterStatus pavoStatusSync() {
/// isolate 入口:查询打印机状态(顶层函数,入参均为纯数据)。
PrinterStatus pavoStatusSync(String? logPath) {
try {
final api = PavoApi();
final printers = PavoPrinter.enumUsbPrinters(api);
final printers = PavoPrinter.enumUsbPrinters(api, logPath: logPath);
if (printers.isEmpty) {
return PrinterStatus.offline('打印机未连接');
}
@@ -97,9 +104,10 @@ Future<void> pavoPrintSync({
String? backColor,
String? backK,
required int copies,
String? logPath,
}) async {
final api = PavoApi();
final printers = PavoPrinter.enumUsbPrinters(api);
final printers = PavoPrinter.enumUsbPrinters(api, logPath: logPath);
if (printers.isEmpty) {
throw StateError('未找到 USB 连接的 HiTi 证卡打印机');
}
+16 -7
View File
@@ -6,9 +6,10 @@ import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import '../../image/card_image.dart';
import '../../utils/file_log.dart';
import 'pavo_ffi.dart';
// Windows 枚举/直打链路的 [pavo-diag] 诊断日志(stdout,flutter run 控制台可见)
// Windows 枚举/直打链路的 [pavo-diag] 诊断日志(stdout + 本地日志文件)
// ignore_for_file: avoid_print
/// 携带 HiTi 状态码的打印异常(错误码进任务 status,文案进 statusText)。
@@ -28,15 +29,23 @@ class HitiException implements Exception {
/// PAVO_PrintOneCard(打印机名, 分层 BITMAP, 默认加热能量, 默认设置) → 轮询 CheckPrinterStatus 直到非忙碌。
/// 全部为同步 ffi 调用,必须在独立 isolate 中运行(见 PavoPrintBackend)。
class PavoPrinter {
/// 诊断日志:stdout(flutter run 控制台可见)+ 日志文件(isolate 内 stdout
/// 在 GUI 模式下丢失,文件是唯一可靠出口;路径由调用方以纯数据传入)。
static void diag(String? logPath, String line) {
print('[pavo-diag] $line');
if (logPath != null) {
FileLog.writeToPath(logPath, '[pavo-diag] $line');
}
}
/// 枚举 USB 连接的证卡打印机名。SDK 缓冲可能是 UTF-8 或 UTF-16,两种都尝试解析。
///
/// 含 [pavo-diag] 诊断日志(stdout):Windows 枚举问题排查用,
/// 输出两段式返回码/原始缓冲 hex/解码与拆分结果,flutter run 控制台可见。
static List<String> enumUsbPrinters(PavoApi api) {
/// [logPath] 为本地日志文件路径(纯数据,isolate 安全);null 时仅 stdout。
static List<String> enumUsbPrinters(PavoApi api, {String? logPath}) {
final sizePtr = calloc<Uint32>();
try {
var ret = api.enumUsbCardPrinters(nullptr, sizePtr);
print('[pavo-diag] enum#1 ret=0x${ret.toRadixString(16)} size=${sizePtr.value}');
diag(logPath, 'enum#1 ret=0x${ret.toRadixString(16)} size=${sizePtr.value}');
if (ret != 0 || sizePtr.value == 0) {
return const [];
}
@@ -50,7 +59,7 @@ class PavoPrinter {
.take(96)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(' ');
print('[pavo-diag] enum#2 ret=0x${ret.toRadixString(16)} size=$size raw[$hex]');
diag(logPath, 'enum#2 ret=0x${ret.toRadixString(16)} size=$size raw[$hex]');
if (ret != 0) {
return const [];
}
@@ -60,7 +69,7 @@ class PavoPrinter {
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
print('[pavo-diag] decoded="$decoded" => $list');
diag(logPath, 'decoded="$decoded" => $list');
return list;
} finally {
calloc.free(buf);
+16 -5
View File
@@ -2,6 +2,8 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import '../utils/file_log.dart';
/// 单实例 + 唤醒端口:应用只允许一个常驻实例。
///
/// zaxprint:// 唤起或用户再次双击图标时,OS 会启动第二个进程——第二实例连上
@@ -14,6 +16,10 @@ class SingleInstance {
///
/// 返回 true = 本实例是主实例(已开始监听唤醒,[onWake] 在收到唤醒时回调);
/// 返回 false = 已有常驻实例(已通知其显示窗口),调用方应当退出进程。
///
/// 唤醒端口被占用但无人应答(上次异常退出的残留/TIME_WAIT)时**照样返回
/// true 启动应用**——曾因误判为"已有实例"直接 exit(0),导致双击图标
/// 间歇性打不开;此时仅唤醒功能降级(FileLog 留痕),绝不阻断启动。
static Future<bool> acquire({void Function()? onWake}) async {
try {
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, wakePort);
@@ -28,10 +34,12 @@ class SingleInstance {
}, onDone: () {
socket.destroy();
});
}, onError: (_) {});
}, onError: (Object e) {
FileLog.write('唤醒监听异常(不影响服务):$e');
});
return true;
} on SocketException {
// 端口被占:已有常驻实例,通知它显示窗口
// 端口被占:可能是已有常驻实例,也可能是残留占用——先尝试唤醒握手
try {
final socket = await Socket.connect(
InternetAddress.loopbackIPv4,
@@ -41,10 +49,13 @@ class SingleInstance {
socket.add(utf8.encode('wake'));
await socket.flush();
await socket.close();
} catch (_) {
// 唤醒端口被非本应用进程占用等异常:放弃唤醒,按第二实例退出
FileLog.write('唤醒端口有应答(已有常驻实例),本进程作为第二实例退出');
return false; // 真有实例在听且已收到唤醒
} catch (e) {
// 无应答:残留占用。放弃唤醒监听但照常启动(唤醒功能本次降级)
FileLog.write('唤醒端口被占但无应答(判定为残留占用,唤醒功能本次降级):$e');
return true;
}
return false;
}
}
}
+37 -4
View File
@@ -1,9 +1,11 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import '../agent.dart';
import '../server/protocol.dart';
import '../utils/file_log.dart';
/// 饭卡打印机插件页:主状态一眼可懂,技术参数收进"技术详情"。
class CardPrinterPage extends StatefulWidget {
@@ -40,6 +42,21 @@ class _CardPrinterPageState extends State<CardPrinterPage> {
super.dispose();
}
/// 资源管理器/Finder 打开日志目录(用户取 app-*.log 发回分析)。
Future<void> _openLogFolder() async {
final dir = FileLog.dir;
if (dir == null) return;
try {
if (Platform.isWindows) {
await Process.start('explorer', [dir], mode: ProcessStartMode.detached);
} else if (Platform.isMacOS) {
await Process.start('open', [dir], mode: ProcessStartMode.detached);
}
} catch (e) {
_agent.log.write('打开日志文件夹失败:$e(目录:$dir)');
}
}
Future<void> _refreshStatus() async {
setState(() => _loading = true);
try {
@@ -418,10 +435,26 @@ class _CardPrinterPageState extends State<CardPrinterPage> {
('色带剩余张数', s != null && s.ribbon.remainCount >= 0 ? '${s.ribbon.remainCount} 张' : '—'),
]),
const SizedBox(height: 14),
Text('运行日志(最近 50 条)',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: scheme.onSurfaceVariant,
)),
Row(
children: [
Expanded(
child: Text('运行日志(最近 50 条)',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: scheme.onSurfaceVariant,
)),
),
if (FileLog.dir != null)
TextButton.icon(
onPressed: _openLogFolder,
icon: const Icon(Icons.folder_open, size: 14),
label: const Text('打开日志文件夹'),
style: TextButton.styleFrom(
textStyle: Theme.of(context).textTheme.bodySmall,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
),
),
],
),
const SizedBox(height: 6),
Container(
width: double.infinity,
+39 -6
View File
@@ -95,18 +95,51 @@ class _SetupWizardState extends State<SetupWizard> {
Future<void> _finish() async {
setState(() => _autostartBusy = true);
final failures = <String>[];
_agent.log.write('向导完成:开始收尾配置');
try {
if (_autostart && Autostart.isSupported) {
await Autostart.enable();
try {
_agent.log.write('向导完成:写入开机自启($_autostart)…');
await Autostart.enable();
_agent.log.write('向导完成:开机自启写入成功');
} catch (e) {
final msg = '开机自启设置失败:$e';
failures.add(msg);
_agent.log.write('向导完成:$msg');
}
}
try {
_agent.log.write('向导完成:注册 zaxprint:// 协议…');
await UrlScheme.ensureRegistered();
_agent.log.write('向导完成:协议注册成功');
} catch (e) {
final msg = '网页唤起协议注册失败:$e';
failures.add(msg);
_agent.log.write('向导完成:$msg');
}
try {
await FirstRun.complete();
_agent.log.write('向导完成:完成标记已写入');
} catch (e) {
final msg = '完成标记写入失败(下次启动会重新弹出向导):$e';
failures.add(msg);
_agent.log.write('向导完成:$msg');
}
await UrlScheme.ensureRegistered();
await FirstRun.complete();
} finally {
if (mounted) setState(() => _autostartBusy = false);
}
if (mounted) {
Navigator.of(context).pop();
widget.onFinished();
if (!mounted) return;
// 无论成败都关闭向导——完成动作失败不应把用户锁死在向导里
Navigator.of(context).pop();
widget.onFinished();
if (failures.isNotEmpty && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('部分配置未完成(详见运行日志):${failures.length} 项'),
duration: const Duration(seconds: 5),
),
);
}
}
+119
View File
@@ -0,0 +1,119 @@
import 'dart:io';
/// 本地文件日志:按天滚动(app-20260921.log)、单文件 5MB 轮转加序号、
/// 启动时清理 7 天前旧文件。供用户把日志文件发回做现场分析。
///
/// 全同步追加写(本应用日志低频,性能无碍;同步写保证异常退出前内容已落盘)。
/// 设计为静态工具而非对象:pavo 的 isolate 入口是顶层函数(不能捕获实例,
/// 见 pavo_backend 的 unsendable 教训),只把 [currentPath](纯数据)传进
/// isolate,isolate 内通过 [writeToPath] 直接追加。
class FileLog {
static const _keepDays = 7;
static const _maxBytes = 5 * 1024 * 1024;
static String? _dir;
static String? _currentPath;
/// 日志目录:mac ~/Library/Logs/zax-desktop-plugin/;win %APPDATA%\zax-desktop-plugin\logs\
/// 推导失败(无 HOME/APPDATA)时禁用文件日志,仅内存/stdout。
static String? defaultDir() {
final env = Platform.environment;
if (Platform.isMacOS) {
final home = env['HOME'];
return home == null ? null : '$home/Library/Logs/zax-desktop-plugin';
}
if (Platform.isWindows) {
final appData = env['APPDATA'];
return appData == null ? null : '$appData\\zax-desktop-plugin\\logs';
}
return null;
}
/// 初始化(main 最早调用):建目录、清理过期文件、定位当天日志文件。
/// [dir] 可注入目录(测试用),默认按平台推导。
static void init([String? dir]) {
final resolved = dir ?? defaultDir();
if (resolved == null) return;
try {
Directory(resolved).createSync(recursive: true);
_dir = resolved;
_cleanupOld();
_locateCurrent();
} catch (_) {
// 目录不可写等:文件日志禁用,不影响应用运行
_dir = null;
}
}
static String? get dir => _dir;
static String? get currentPath => _currentPath;
static String _dayStamp([DateTime? now]) {
final t = now ?? DateTime.now();
String p2(int v) => v.toString().padLeft(2, '0');
return '${t.year}${p2(t.month)}${p2(t.day)}';
}
static String _stampLine(String line) {
final now = DateTime.now();
String p2(int v) => v.toString().padLeft(2, '0');
return '${_dayStamp(now)} ${p2(now.hour)}:${p2(now.minute)}:${p2(now.second)} $line';
}
static void _locateCurrent() {
final base = '$_dir/app-${_dayStamp()}';
var path = '$base.log';
var seq = 1;
// 当天文件超限时轮转 app-20260921-1.log、-2.log…(文件可能尚不存在,先判存在)
while (File(path).existsSync() && File(path).lengthSync() >= _maxBytes) {
path = '$base-${seq++}.log';
}
_currentPath = path;
}
/// 追加一行到当前日志文件(带完整时间戳)。任何 IO 异常自吞——
/// 日志永远不能影响主流程。写入超限时轮转到下一个序号文件。
static void write(String line) {
final path = _currentPath;
if (path == null) return;
writeToPath(path, line);
try {
if (File(path).lengthSync() >= _maxBytes) {
_locateCurrent();
}
} catch (_) {}
}
/// 追加一行到指定日志文件(isolate 内使用:路径以纯数据传入)。
static void writeToPath(String path, String line) {
try {
File(path).writeAsStringSync('${_stampLine(line)}\n', mode: FileMode.append);
} catch (_) {}
}
/// 重置状态(测试用;运行期无需调用——静态路径常驻,多实例写同一文件为追加语义)。
static void dispose() {
_dir = null;
_currentPath = null;
}
/// 清理 7 天前的 app-*.log(按文件名中的日期判断,与按天滚动语义一致;
/// 名字里解析不出日期的文件跳过不动)。
static void _cleanupOld() {
final cutoff = DateTime.now().subtract(const Duration(days: _keepDays));
try {
for (final entity in Directory(_dir!).listSync()) {
if (entity is! File) continue;
final name = entity.uri.pathSegments.last;
final m = RegExp(r'^app-(\d{8})(?:-\d+)?\.log$').firstMatch(name);
if (m == null) continue;
final y = int.parse(m.group(1)!.substring(0, 4));
final mo = int.parse(m.group(1)!.substring(4, 6));
final d = int.parse(m.group(1)!.substring(6, 8));
if (DateTime(y, mo, d).isBefore(cutoff)) {
entity.deleteSync();
}
}
} catch (_) {}
}
}
+47
View File
@@ -0,0 +1,47 @@
import 'dart:io';
import 'package:desktop_plugin/utils/file_log.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
late Directory tmp;
setUp(() {
tmp = Directory.systemTemp.createTempSync('filelog-test');
FileLog.init(tmp.path);
});
tearDown(() {
FileLog.dispose();
try {
tmp.deleteSync(recursive: true);
} catch (_) {}
});
test('写入按天命名的日志文件,行带完整时间戳', () {
FileLog.write('hello 世界');
FileLog.write('second line');
final path = FileLog.currentPath!;
expect(path, contains('app-'));
expect(path, endsWith('.log'));
final text = File(path).readAsStringSync();
expect(text, contains('hello 世界'));
expect(text, contains('second line'));
// 完整时间戳:YYYYMMDD HH:MM:SS
expect(RegExp(r'^\d{8} \d{2}:\d{2}:\d{2} ', multiLine: true).hasMatch(text), isTrue);
});
test('清理 7 天前的旧日志,保留新日志', () {
final old = File('${tmp.path}/app-20200101.log')..writeAsStringSync('old');
expect(old.existsSync(), isTrue);
FileLog.init(tmp.path); // 重新 init 触发清理
expect(old.existsSync(), isFalse, reason: '7 天前的日志应被删除');
expect(FileLog.currentPath != null, isTrue, reason: '当天日志正常打开');
});
test('未 init(目录不可用)时 write 静默不抛', () {
FileLog.dispose();
// 不 init 直接 write:无句柄,应静默通过
FileLog.write('no sink');
});
}