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:
1 parent
bf0de9b8b5
commit
726210edcb
9 files changed
+302
-27
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 证卡打印机');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (_) {}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user