diff --git a/lib/agent.dart b/lib/agent.dart index 40ada3d..58059bc 100644 --- a/lib/agent.dart +++ b/lib/agent.dart @@ -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); } diff --git a/lib/main.dart b/lib/main.dart index 651d438..23171e0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 main(List 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 main(List 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 main(List args) async { await agent.start(); } catch (e) { startError = '$e'; + agent.log.write('服务启动失败:$startError'); } try { @@ -63,6 +75,7 @@ Future main(List args) async { agent.log.write('托盘初始化失败(不影响其他功能):$e'); } + agent.log.write('界面启动(日志目录:${FileLog.dir ?? "不可用"})'); runApp(DesktopPluginApp(agent: agent, startError: startError ?? bootWarning)); } diff --git a/lib/print/pavo/pavo_backend.dart b/lib/print/pavo/pavo_backend.dart index deb8285..3c3fd14 100644 --- a/lib/print/pavo/pavo_backend.dart +++ b/lib/print/pavo/pavo_backend.dart @@ -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 getStatus() => Isolate.run(pavoStatusSync); + Future getStatus() { + // 日志文件路径以纯数据传入 isolate(isolate 内 stdout 在 GUI 模式下丢失) + final logPath = FileLog.currentPath; + return Isolate.run(() => pavoStatusSync(logPath)); + } @override Future 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 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 证卡打印机'); } diff --git a/lib/print/pavo/pavo_printer.dart b/lib/print/pavo/pavo_printer.dart index b39f007..13feb2c 100644 --- a/lib/print/pavo/pavo_printer.dart +++ b/lib/print/pavo/pavo_printer.dart @@ -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 enumUsbPrinters(PavoApi api) { + /// [logPath] 为本地日志文件路径(纯数据,isolate 安全);null 时仅 stdout。 + static List enumUsbPrinters(PavoApi api, {String? logPath}) { final sizePtr = calloc(); 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); diff --git a/lib/setup/single_instance.dart b/lib/setup/single_instance.dart index 6f05270..0db8c37 100644 --- a/lib/setup/single_instance.dart +++ b/lib/setup/single_instance.dart @@ -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 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; } } } diff --git a/lib/ui/card_printer_page.dart b/lib/ui/card_printer_page.dart index 9e9a6d0..4a3e1ed 100644 --- a/lib/ui/card_printer_page.dart +++ b/lib/ui/card_printer_page.dart @@ -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 { super.dispose(); } + /// 资源管理器/Finder 打开日志目录(用户取 app-*.log 发回分析)。 + Future _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 _refreshStatus() async { setState(() => _loading = true); try { @@ -418,10 +435,26 @@ class _CardPrinterPageState extends State { ('色带剩余张数', 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, diff --git a/lib/ui/setup_wizard.dart b/lib/ui/setup_wizard.dart index cc849df..f204c79 100644 --- a/lib/ui/setup_wizard.dart +++ b/lib/ui/setup_wizard.dart @@ -95,18 +95,51 @@ class _SetupWizardState extends State { Future _finish() async { setState(() => _autostartBusy = true); + final failures = []; + _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), + ), + ); } } diff --git a/lib/utils/file_log.dart b/lib/utils/file_log.dart new file mode 100644 index 0000000..8145c9a --- /dev/null +++ b/lib/utils/file_log.dart @@ -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 (_) {} + } +} diff --git a/test/file_log_test.dart b/test/file_log_test.dart new file mode 100644 index 0000000..6f27460 --- /dev/null +++ b/test/file_log_test.dart @@ -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'); + }); +}