Files
dzc 726210edcb 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 零问题。
2026-09-22 15:09:24 +08:00

320 lines
12 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:convert';
import 'dart:ffi';
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 + 本地日志文件)
// ignore_for_file: avoid_print
/// 携带 HiTi 状态码的打印异常(错误码进任务 status,文案进 statusText)。
class HitiException implements Exception {
HitiException(this.statusCode, this.message);
final int statusCode;
final String message;
@override
String toString() => message;
}
/// HiTi CS-2xx 打印机高层操作:枚举、状态、设备信息、单卡直打。
///
/// PavoApi 直打链路(对齐 SDK DemoCSharp buttonDirectPrint 与 C# Hiti/PavoPrinter.cs):
/// 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,两种都尝试解析。
///
/// [logPath] 为本地日志文件路径(纯数据,isolate 安全);null 时仅 stdout。
static List<String> enumUsbPrinters(PavoApi api, {String? logPath}) {
final sizePtr = calloc<Uint32>();
try {
var ret = api.enumUsbCardPrinters(nullptr, sizePtr);
diag(logPath, 'enum#1 ret=0x${ret.toRadixString(16)} size=${sizePtr.value}');
if (ret != 0 || sizePtr.value == 0) {
return const [];
}
final buf = calloc<Uint8>(sizePtr.value);
try {
final size = sizePtr.value;
sizePtr.value = size;
ret = api.enumUsbCardPrinters(buf, sizePtr);
final bytes = buf.asTypedList(size);
final hex = bytes
.take(96)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(' ');
diag(logPath, 'enum#2 ret=0x${ret.toRadixString(16)} size=$size raw[$hex]');
if (ret != 0) {
return const [];
}
final decoded = decodeAuto(bytes);
final list = decoded
.split(RegExp('[\x00\n\r,]'))
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
diag(logPath, 'decoded="$decoded" => $list');
return list;
} finally {
calloc.free(buf);
}
} finally {
calloc.free(sizePtr);
}
}
/// 按可打印字符占比自动判断 UTF-8 / UTF-16 编码。
///
/// 实测(Win11 无驱动裸机):枚举缓冲返回 UTF-16LE 的设备路径
/// (\\?\usb#vid_xxxx&pid_xxxx#serial#{guid})。ASCII 的 UTF-16LE 呈现
/// "奇数位字节全零"特征——printableRatio 会把 \0 算作可打印,导致 UTF-8
/// 误判通过、路径被 \0 拆成单字符碎片(后续 API 拿 '\' 单字符全部 0x80)。
/// 故先用奇数位全零特征做 UTF-16LE 判定,再回退比例启发。
static String decodeAuto(Uint8List buf) {
if (buf.length >= 8) {
var oddAllZero = true;
for (var i = 1; i < buf.length; i += 2) {
if (buf[i] != 0) {
oddAllZero = false;
break;
}
}
if (oddAllZero) {
final codes = <int>[];
for (var i = 0; i + 1 < buf.length; i += 2) {
codes.add(buf[i] | (buf[i + 1] << 8));
}
return String.fromCharCodes(codes);
}
}
final utf8Text = utf8.decode(buf, allowMalformed: true);
if (printableRatio(utf8Text) >= 0.9) {
return utf8Text;
}
final codes = <int>[];
for (var i = 0; i + 1 < buf.length; i += 2) {
codes.add(buf[i] | (buf[i + 1] << 8));
}
final utf16Text = String.fromCharCodes(codes);
return printableRatio(utf16Text) >= 0.9 ? utf16Text : utf8Text;
}
static double printableRatio(String s) {
if (s.isEmpty) return 0;
var ok = 0;
for (final c in s.codeUnits) {
if ((c >= 0x20 && c <= 0x7e) || (c >= 0x4e00 && c <= 0x9fff) || c == 0) ok++;
}
return ok / s.length;
}
/// 查询设备状态码,0 = 就绪;API 调用失败时返回的就是错误码本身。
static int getStatus(PavoApi api, Pointer<Utf16> printer) {
final statusPtr = calloc<Uint32>();
try {
final ret = api.checkPrinterStatus(printer, statusPtr);
return ret != 0 ? ret : statusPtr.value;
} finally {
calloc.free(statusPtr);
}
}
/// 读字符串型设备信息(序列号/型号/固件,UTF-16LE)。
static String getDeviceInfoText(PavoApi api, Pointer<Utf16> printer, int infoType) {
final buf = calloc<Uint8>(512);
final lenPtr = calloc<Uint32>()..value = 512;
try {
final ret = api.getDeviceInfo(printer, infoType, buf, lenPtr);
if (ret != 0 || lenPtr.value == 0) {
return '';
}
final n = lenPtr.value;
final codes = <int>[];
for (var i = 0; i + 1 < n; i += 2) {
final c = buf[i] | (buf[i + 1] << 8);
if (c != 0) codes.add(c);
}
return String.fromCharCodes(codes);
} finally {
calloc.free(buf);
calloc.free(lenPtr);
}
}
/// 色带信息:(类型码, 剩余张数)。
static (int, int) getRibbonInfo(PavoApi api, Pointer<Utf16> printer) {
final buf = calloc<Uint8>(64);
final lenPtr = calloc<Uint32>()..value = 64;
try {
final ret = api.getDeviceInfo(printer, PavoApi.devinfoRibbonInfo, buf, lenPtr);
if (ret != 0 || lenPtr.value < 8) {
return (PavoApi.ribbonMissing, 0);
}
final v = buf.asTypedList(8);
final type = v[0] | (v[1] << 8) | (v[2] << 16) | (v[3] << 24);
final count = v[4] | (v[5] << 8) | (v[6] << 16) | (v[7] << 24);
return (type, count);
} finally {
calloc.free(buf);
calloc.free(lenPtr);
}
}
static String ribbonTypeName(int type) => switch (type) {
PavoApi.ribbonTypeYmcko => 'YMCKO',
PavoApi.ribbonTypeK => 'Resin K',
PavoApi.ribbonTypeKo => 'KO',
PavoApi.ribbonTypeYmckok => 'YMCKOK',
PavoApi.ribbonTypeHalfYmcko => '1/2 YMCKO',
PavoApi.ribbonTypeYmckfo => 'YMCKFO',
PavoApi.ribbonMissing => '未安装',
_ => '未知($type)',
};
/// 常见状态码中文文案,与前端翻译表一致。
static String statusTextOf(int status) => switch (status) {
0 => '就绪',
PavoApi.dsPrinting => '打印机正在打印',
PavoApi.dsProcessingData => '正在处理打印数据',
PavoApi.dsSendingData => '正在向打印机发送数据',
PavoApi.dsWriteFail => '向打印机发送命令失败',
PavoApi.dsReadFail => '打印机响应失败',
PavoApi.dsOffline => '打印机已断开连接或已关机',
PavoApi.dsCardJam1 => '卡片通道卡纸,请开盖取出卡片',
PavoApi.dsCardJam2 => '翻卡器处卡纸,请开盖取出卡片',
PavoApi.dsCardJam3 => '出卡槽卡纸,请取出卡片',
PavoApi.dsCmdSeqError => '命令序列错误,请重启打印机',
PavoApi.dsCoverOpen => '打印机盖未关闭',
PavoApi.dsCardOut => '缺卡或进卡失败,请补充卡片',
PavoApi.dsCardLow => '卡量不足,请及时补充卡片',
PavoApi.dsCardThickWrong => '卡片厚度选择器未正确安装',
PavoApi.dsBusy => '设备忙碌,请稍候',
PavoApi.dsRibbonMissing => '色带未安装',
PavoApi.dsOutOfRibbon => '色带已用尽,请更换色带',
PavoApi.dsUnsupportRibbon => '不支持的色带类型',
PavoApi.dsUnknownRibbon => '无法识别色带',
PavoApi.dsNoFlipperModule => '翻卡模块未安装',
PavoApi.locked => '打印机已被锁定,请先解锁',
_ => '设备状态异常 0x${status.toRadixString(16).padLeft(8, '0').toUpperCase()}',
};
/// 打印一张卡。frontBgr 为彩色层(BGR),frontK 可选(树脂黑文字层)。
/// isLandscape 由正面图纵横比得出:横向 2 / 纵向 1(SDK byOrientation)。
/// copies 大于 1 时循环提交(直打接口无份数参数)。
static Future<void> printOneCard(
PavoApi api,
Pointer<Utf16> printer, {
required Uint8List frontBgr,
Uint8List? frontK,
Uint8List? backBgr,
Uint8List? backK,
required bool isLandscape,
required int copies,
}) async {
final width = isLandscape ? CardImage.landscapeWidth : CardImage.landscapeHeight;
final height = isLandscape ? CardImage.landscapeHeight : CardImage.landscapeWidth;
final n = copies < 1 ? 1 : copies;
for (var i = 0; i < n; i++) {
final holder = LayerHolder();
final para = calloc<HitiCardPrintParameter>();
final heat = calloc<HitiHeatingEnergy>();
final settings = calloc<HitiSettings>();
try {
para.ref
..dwSize = sizeOf<HitiCardPrintParameter>()
..byOrientation = isLandscape ? 2 : 1
..byCardThickness = 1 // 0.5mm 标准 PVC 卡
..lpFrontBGR = holder.pin(frontBgr, 24, width, height)
..lpFrontK = frontK == null ? nullptr : holder.pin(frontK, 8, width, height)
..lpBackBGR = backBgr == null ? nullptr : holder.pin(backBgr, 24, width, height)
..lpBackK = backK == null ? nullptr : holder.pin(backK, 8, width, height);
// heat/settings 由 calloc 零初始化,等价 C# 全 0(驱动默认能量/设置)
final ret = api.printOneCard(printer, para, heat, settings);
if (ret != 0) {
throw HitiException(ret, 'PAVO_PrintOneCard 返回错误 0x${_hex8(ret)}');
}
// 对齐 C# Demo(using 块内轮询):等固件处理数据后轮询,直到打印机脱离忙碌/打印态;
// 像素内存在轮询完成后才释放(finally)
await Future<void>.delayed(const Duration(seconds: 3));
final status = await waitIdle(api, printer);
if (status != 0) {
throw HitiException(status, '打印中断,设备状态 0x${_hex8(status)}');
}
} finally {
holder.dispose();
calloc.free(para);
calloc.free(heat);
calloc.free(settings);
}
}
}
/// 轮询直到非 BUSY/PRINTING/PROCESSING/SENDING;超时 3 分钟抛错。
static Future<int> waitIdle(PavoApi api, Pointer<Utf16> printer) async {
final deadline = DateTime.now().add(const Duration(minutes: 3));
while (DateTime.now().isBefore(deadline)) {
final status = getStatus(api, printer);
if (status != PavoApi.dsBusy &&
status != PavoApi.dsPrinting &&
status != PavoApi.dsProcessingData &&
status != PavoApi.dsSendingData) {
return status;
}
await Future<void>.delayed(const Duration(seconds: 1));
}
throw TimeoutException('等待打印机完成超时(3 分钟)');
}
static String _hex8(int v) => v.toRadixString(16).padLeft(8, '0').toUpperCase();
}
/// 打印期间持有非托管像素内存,dispose 统一释放(对齐 C# LayerHolder)。
class LayerHolder {
final _allocations = <Pointer>[];
/// 把像素缓冲包成 GDI BITMAP 结构并拷贝像素到非托管内存,返回 BITMAP 指针。
Pointer<Uint8> pin(Uint8List pixels, int bitsPerPixel, int width, int height) {
final widthBytes = width * (bitsPerPixel ~/ 8);
final bits = malloc<Uint8>(pixels.length);
bits.asTypedList(pixels.length).setAll(0, pixels);
final bmp = malloc<GdiBitmap>();
bmp.ref
..bmType = 0
..bmWidth = width
..bmHeight = height
..bmWidthBytes = widthBytes
..bmPlanes = 1
..bmBitsPixel = bitsPerPixel
..bmBits = bits;
_allocations
..add(bits)
..add(bmp);
return bmp.cast();
}
void dispose() {
for (final p in _allocations) {
malloc.free(p);
}
_allocations.clear();
}
}