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

+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 (_) {}
}
}