feat: 饭卡打印助手 Flutter 版(Phase 1/2:协议服务器 + macOS CUPS 真机打印)

- 协议与 C# 版 card-printer-agent 逐字段对齐(38200-38202 顺延、CORS *、串行任务队列、30 分钟保留)
- macOS CUPS 后端:lpoptions 队列发现 + lp 参数列表提交(LC_ALL=C 防本地化粘连坑)
- --mock 模拟模式 + bin/agent.dart headless 入口 + 测试卡生成工具
- 真机验收:HiTi CS-200e status 就绪 + 真实出卡;macOS 通用二进制构建通过
- App Sandbox 已关闭(服务需绑回环端口并调用 lp 子进程);zaxprint URL scheme 已注册
This commit is contained in:
dzc committed 2026-09-21 12:03:27 +08:00
commit 689a07ca44
46 files changed
+2986

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
import 'dart:io';
import '../server/protocol.dart';
import 'cups_backend.dart';
/// 打印后端抽象:Windows 走 HiTi PavoApi 直打(分层 BITMAP);macOS 走系统 CUPS 队列。
abstract class PrintBackend {
/// 后端名:pavo / cups / mock(暴露给日志)
String get name;
Future<PrinterStatus> getStatus();
Future<void> printOneCard(PrintRequest request);
}
/// manifest 里的后端名按操作系统报,与是否 mock 无关(对齐 C# 版行为)。
String osBackendName() => Platform.isWindows ? 'pavo' : 'cups';
/// 创建当前操作系统的真实打印后端(非 mock)。
PrintBackend createOsBackend({void Function(String line)? onLog}) {
if (Platform.isWindows) {
// Phase 3:PavoApi ffi 直打(待 Windows 实机联调)
throw UnsupportedError('Windows PavoApi 后端尚未实现(Phase 3)');
}
return CupsPrintBackend(onLog: onLog);
}
+160
View File
@@ -0,0 +1,160 @@
import 'dart:convert';
import 'dart:io';
import '../server/protocol.dart';
import '../utils/command_runner.dart';
import 'backend.dart';
/// 默认空日志(构造初始化列表里不能写 `(_) {}` 块体闭包,会被当成构造体花括号)。
void noopLog(String line) {}
/// macOS 打印后端:HiTi 官方驱动(HiTiCardTypeMacDrivers pkg)安装后打印机成为系统
/// CUPS 队列,直接把前端渲染好的 PNG 交给队列(lp 命令),驱动负责光栅化与色带分色。
///
/// 说明:CUPS 路径无树脂 K 层直控与色带余量读取,由驱动默认分色策略处理;
/// 单面机(CS-200e)的背面按第二个独立作业顺序提交。
class CupsPrintBackend implements PrintBackend {
CupsPrintBackend({void Function(String line)? onLog}) : _log = onLog ?? noopLog;
final void Function(String line) _log;
int _tempSeq = 0;
@override
String get name => 'cups';
/// 打印队列信息(来自 lpoptions,字段纯 ASCII 不受系统语言本地化影响)。
static const _noQueueHint =
'未添加打印机:① 安装 HiTi Mac 驱动(HiTiCardTypeMacDrivers dmg 内的 pkg)→ ② USB 连接打印机 → ③ 系统设置→打印机与扫描仪 确认出现 HiTi 打印机后点刷新';
/// 发现打印队列。
///
/// macOS 会把 lpstat 输出本地化(中文系统"打印机xx正在接受请求"无空格粘连,LC_ALL=C 压不住),
/// 因此首选 lpoptions:printer-uri-supported=ipp://localhost/printers/<队列名> 纯 ASCII;
/// printer-state 为标准 IPP 状态(3 空闲 / 4 打印中 / 5 停止)。
/// 兜底:lpstat -v 的中英双正则(用于(x)的设备 / device for (x):)。
Future<List<_QueueInfo>> _discoverQueues() async {
final result = <_QueueInfo>[];
try {
final lpopt = await runCommand('lpoptions', const [], tolerateExit: true);
final uri = RegExp(r'printer-uri-supported=\S*/printers/(\S+)').firstMatch(lpopt);
if (uri != null) {
final name = uri.group(1)!;
final state = RegExp(r'printer-state=(\d)').firstMatch(lpopt);
final model = RegExp(r"printer-make-and-model='([^']*)'").firstMatch(lpopt);
result.add(_QueueInfo(
name,
model?.group(1) ?? '',
state != null ? int.parse(state.group(1)!) : 3,
));
}
} on CommandException {
// lpoptions 不可用时走 lpstat -v 兜底
}
if (result.isNotEmpty) {
return result;
}
final vout = await runCommand('lpstat', const ['-v'], tolerateExit: true);
for (final raw in vout.split('\n')) {
final line = raw.trim();
if (line.isEmpty) continue;
// 英文 "device for NAME: ..." / 中文 "用于NAME的设备:..."
final m = RegExp(r'device for (\S+):|用于(\S+)的设备').firstMatch(line);
if (m != null) {
final name = m.group(1) ?? m.group(2) ?? '';
if (name.isNotEmpty && !result.any((q) => q.name == name)) {
result.add(_QueueInfo(name, '', 3));
}
}
}
return result;
}
_QueueInfo? _pickQueue(List<_QueueInfo> queues) {
if (queues.isEmpty) return null;
return queues.where((q) => q.name.toLowerCase().contains('hiti')).firstOrNull ??
queues.where((q) => RegExp(r'CS[-_ ]?\d').hasMatch(q.name)).firstOrNull ??
queues.first;
}
@override
Future<PrinterStatus> getStatus() async {
try {
final queue = _pickQueue(await _discoverQueues());
if (queue == null) {
return PrinterStatus.offline(_noQueueHint);
}
// IPP printer-state:3 空闲 / 4 打印中 / 5 停止
final (status, statusText) = switch (queue.printerState) {
4 => (0x00000002, '打印机正在打印'),
5 => (0x00000080, '打印机已停止:请在 系统设置→打印机与扫描仪 里检查(缺纸/卡纸/暂停)'),
_ => (0, '就绪'),
};
return PrinterStatus(
online: queue.printerState != 5,
model: queue.model.isEmpty ? queue.name : queue.model,
connection: 'USB',
ribbon: RibbonInfo(),
status: status,
statusText: statusText,
);
} catch (e) {
return PrinterStatus.offline('状态读取失败:$e');
}
}
@override
Future<void> printOneCard(PrintRequest request) async {
final queue = _pickQueue(await _discoverQueues());
if (queue == null) {
throw StateError('未找到系统打印队列,请先安装 HiTi Mac 驱动并添加打印机');
}
final frontFile = await _writeTempPng(request.front!.color);
String? backFile;
try {
final back = request.back;
if (back != null && back.color.isNotEmpty) {
backFile = await _writeTempPng(back.color);
}
final copies = request.copies < 1 ? 1 : request.copies;
for (var i = 0; i < copies; i++) {
await runCommand('lp', ['-d', queue.name, frontFile]);
if (backFile != null) {
await runCommand('lp', ['-d', queue.name, backFile]);
}
}
_log('已提交到 CUPS 队列 ${queue.name}(份数 $copies${backFile != null ? ',含背面' : ''})');
} finally {
await _deleteQuiet(frontFile);
await _deleteQuiet(backFile);
}
}
/// data URL PNG 落临时文件供 lp 提交。
Future<String> _writeTempPng(String dataUrl) async {
final comma = dataUrl.indexOf(',');
final base64Part = comma >= 0 ? dataUrl.substring(comma + 1) : dataUrl;
final bytes = base64.decode(base64Part.replaceAll(RegExp(r'\s'), ''));
final path =
'${Directory.systemTemp.path}/card-${DateTime.now().microsecondsSinceEpoch}-${_tempSeq++}.png';
await File(path).writeAsBytes(bytes);
return path;
}
Future<void> _deleteQuiet(String? path) async {
if (path == null) return;
try {
await File(path).delete();
} catch (_) {}
}
}
class _QueueInfo {
const _QueueInfo(this.name, this.model, this.printerState);
final String name;
final String model;
/// IPP printer-state:3 空闲 / 4 打印中 / 5 停止
final int printerState;
}
+32
View File
@@ -0,0 +1,32 @@
import '../server/protocol.dart';
import 'backend.dart';
import 'cups_backend.dart' show noopLog;
/// mock 后端:无打印机模拟全流程(打印 2.5 秒完成),供联调协议。
class MockBackend implements PrintBackend {
MockBackend({void Function(String line)? onLog}) : _log = onLog ?? noopLog;
final void Function(String line) _log;
@override
String get name => 'mock';
@override
Future<PrinterStatus> getStatus() async => PrinterStatus(
online: true,
model: 'HiTi CS-200e',
firmware: 'v1.03',
connection: 'USB',
ribbon: RibbonInfo(type: 'YMCKO', remain: 63, remainCount: 63),
cardLow: false,
cardLeft: 87,
status: 0,
statusText: '就绪',
);
@override
Future<void> printOneCard(PrintRequest request) async {
_log('[mock] 模拟打印任务(份数 ${request.copies})');
await Future<void>.delayed(const Duration(milliseconds: 2500));
}
}