- 协议与 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 已注册
161 lines
6.0 KiB
Dart
161 lines
6.0 KiB
Dart
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;
|
||
}
|