Files
desktop-plugin/lib/print/cups_backend.dart
T
dzc 689a07ca44 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 已注册
2026-09-21 12:03:27 +08:00

161 lines
6.0 KiB
Dart
Raw 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: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;
}