fix(linux): use CUPS cache for pre-driver capture handoff
Some checks failed
Desktop Release / Bootstrap updater signing key (push) Has been cancelled
Desktop Release / Windows x64 NSIS (push) Has been cancelled
Desktop Release / Linux x64 DEB + AppImage (push) Has been cancelled
Desktop Release / Publish public desktop release (push) Has been cancelled

This commit is contained in:
Admin 2026-09-24 05:19:29 +00:00
parent b65204bffe
commit a65b79e886
6 changed files with 59 additions and 180 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "@fieldlens/print", "name": "@fieldlens/print",
"version": "0.1.10", "version": "0.1.11",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -1,6 +1,6 @@
[package] [package]
name = "fieldlens-print" name = "fieldlens-print"
version = "0.1.10" version = "0.1.11"
description = "FieldLens print-stream barcode capture companion" description = "FieldLens print-stream barcode capture companion"
authors = ["Arrowstack"] authors = ["Arrowstack"]
edition = "2021" edition = "2021"

View File

@ -28,10 +28,6 @@ use crate::{
const SYSTEM_CUPS_SPOOL_DIR: &str = "/var/cache/cups/fieldlens-print"; const SYSTEM_CUPS_SPOOL_DIR: &str = "/var/cache/cups/fieldlens-print";
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
const TEST_CAPTURE_SINK_ADDR: &str = "127.0.0.1:9101"; const TEST_CAPTURE_SINK_ADDR: &str = "127.0.0.1:9101";
#[cfg(target_os = "linux")]
const CUPS_ORIGINAL_CAPTURE_ADDR: &str = "127.0.0.1:9102";
#[cfg(target_os = "linux")]
const CUPS_TEST_CAPTURE_ADDR: &str = "127.0.0.1:9103";
pub struct CaptureRuntime { pub struct CaptureRuntime {
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
@ -90,46 +86,22 @@ pub fn start(config: AppConfig, state: SharedState, app: AppHandle) -> Result<Ca
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
let system_spool = PathBuf::from(SYSTEM_CUPS_SPOOL_DIR); let system_spool = PathBuf::from(SYSTEM_CUPS_SPOOL_DIR);
if system_spool.is_dir() && system_spool != PathBuf::from(&config.spool_dir) { if system_spool != PathBuf::from(&config.spool_dir) {
let worker_stop = stop.clone(); let worker_stop = stop.clone();
let worker_state = state.clone(); let worker_state = state.clone();
let worker_app = app.clone(); let worker_app = app.clone();
let worker_config = config.clone(); let worker_config = config.clone();
handles.push(thread::spawn(move || { handles.push(thread::spawn(move || {
spool_folder_loop( cups_cache_folder_loop(
system_spool,
worker_config, worker_config,
worker_state, worker_state,
worker_app, worker_app,
worker_stop, worker_stop,
Some(system_spool),
"cups-original",
true,
) )
})); }));
} }
for (address, source, forwarded) in [
(CUPS_ORIGINAL_CAPTURE_ADDR, "cups-original", true),
(CUPS_TEST_CAPTURE_ADDR, "cups-test", false),
] {
if let Ok(listener) = TcpListener::bind(address) {
let _ = listener.set_nonblocking(true);
let worker_stop = stop.clone();
let worker_state = state.clone();
let worker_app = app.clone();
handles.push(thread::spawn(move || {
cups_document_capture_loop(
listener,
worker_state,
worker_app,
worker_stop,
source,
forwarded,
)
}));
}
}
let sink = TcpListener::bind(TEST_CAPTURE_SINK_ADDR) let sink = TcpListener::bind(TEST_CAPTURE_SINK_ADDR)
.with_context(|| format!("bind test-capture sink {TEST_CAPTURE_SINK_ADDR}"))?; .with_context(|| format!("bind test-capture sink {TEST_CAPTURE_SINK_ADDR}"))?;
sink.set_nonblocking(true)?; sink.set_nonblocking(true)?;
@ -144,91 +116,6 @@ pub fn start(config: AppConfig, state: SharedState, app: AppHandle) -> Result<Ca
Ok(CaptureRuntime { stop, handles }) Ok(CaptureRuntime { stop, handles })
} }
#[cfg(target_os = "linux")]
fn cups_document_capture_loop(
listener: TcpListener,
state: SharedState,
app: AppHandle,
stop: Arc<AtomicBool>,
source: &'static str,
forwarded: bool,
) {
while !stop.load(Ordering::SeqCst) {
match listener.accept() {
Ok((stream, _)) => {
let worker_state = state.clone();
let worker_app = app.clone();
thread::spawn(move || {
handle_cups_document_connection(
stream,
worker_state,
worker_app,
source,
forwarded,
)
});
}
Err(error) if error.kind() == ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(80));
}
Err(_) => {
thread::sleep(Duration::from_millis(250));
}
}
}
}
#[cfg(target_os = "linux")]
fn handle_cups_document_connection(
mut stream: TcpStream,
state: SharedState,
app: AppHandle,
source: &'static str,
forwarded: bool,
) {
let _ = stream.set_read_timeout(Some(Duration::from_secs(4)));
let mut bytes = Vec::new();
let mut buffer = [0u8; 32 * 1024];
loop {
match stream.read(&mut buffer) {
Ok(0) => break,
Ok(read) => bytes.extend_from_slice(&buffer[..read]),
Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {
if !bytes.is_empty() {
break;
}
}
Err(error) => {
process_payload(
&state,
&app,
source,
"cups-live-capture".into(),
bytes,
forwarded,
Some(format!("CUPS live capture read failed: {error}")),
);
return;
}
}
}
if bytes.is_empty() {
return;
}
process_payload(
&state,
&app,
source,
format!("cups-live-{}.pdf", Uuid::new_v4()),
bytes,
forwarded,
None,
);
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn test_capture_sink_loop(listener: TcpListener, stop: Arc<AtomicBool>) { fn test_capture_sink_loop(listener: TcpListener, stop: Arc<AtomicBool>) {
while !stop.load(Ordering::SeqCst) { while !stop.load(Ordering::SeqCst) {
@ -378,6 +265,33 @@ fn handle_raw_connection(
); );
} }
#[cfg(target_os = "linux")]
fn cups_cache_folder_loop(
spool_dir: PathBuf,
config: AppConfig,
state: SharedState,
app: AppHandle,
stop: Arc<AtomicBool>,
) {
while !stop.load(Ordering::SeqCst) && !spool_dir.is_dir() {
thread::sleep(Duration::from_millis(500));
}
if stop.load(Ordering::SeqCst) {
return;
}
spool_folder_loop(
config,
state,
app,
stop,
Some(spool_dir),
"cups-original",
true,
);
}
fn spool_folder_loop( fn spool_folder_loop(
config: AppConfig, config: AppConfig,
state: SharedState, state: SharedState,

View File

@ -25,9 +25,7 @@ const FIELDLENS_CUPS_SPOOL_DIR: &str = "/var/cache/cups/fieldlens-print";
const FIELDLENS_CUPS_FILTER: &str = r#"#!/usr/bin/python3 const FIELDLENS_CUPS_FILTER: &str = r#"#!/usr/bin/python3
import os import os
import re import re
import socket
import sys import sys
import uuid
SPOOL = os.path.join(os.environ.get("CUPS_CACHEDIR", "/var/cache/cups"), "fieldlens-print") SPOOL = os.path.join(os.environ.get("CUPS_CACHEDIR", "/var/cache/cups"), "fieldlens-print")
@ -39,39 +37,33 @@ job_id = safe(sys.argv[1] if len(sys.argv) > 1 else "job")
title = safe(sys.argv[3] if len(sys.argv) > 3 else "print") title = safe(sys.argv[3] if len(sys.argv) > 3 else "print")
printer = safe(os.environ.get("PRINTER", "printer")) printer = safe(os.environ.get("PRINTER", "printer"))
content_type = os.environ.get("CONTENT_TYPE", "application/pdf") content_type = os.environ.get("CONTENT_TYPE", "application/pdf")
ext = { is_test = printer.lower() == "fieldlens-test-capture"
"application/pdf": "pdf", prefix = "test-" if is_test else "print-"
"application/postscript": "ps", ext = "pdf" if "pdf" in content_type else "dat"
"image/png": "png",
"image/jpeg": "jpg", # Use a deterministic name so multiple PDF pre-filter stages for the same
}.get(content_type, "dat") # CUPS job do not create duplicate FieldLens jobs.
prefix = "test-" if printer.lower() == "fieldlens-test-capture" else "print-" name = f"{prefix}{printer}-{job_id}-{title}.{ext}"
name = f"{prefix}{printer}-{job_id}-{title}-{uuid.uuid4().hex}.{ext}"
final_path = os.path.join(SPOOL, name) final_path = os.path.join(SPOOL, name)
part_path = final_path + ".part" part_path = final_path + ".part"
source = None source = None
live = None
capture = None capture = None
captured_ok = False captured_ok = False
live_ok = False
try: try:
if len(sys.argv) > 6 and sys.argv[6]: if len(sys.argv) > 6 and sys.argv[6]:
source = open(sys.argv[6], "rb") source = open(sys.argv[6], "rb")
else: else:
source = sys.stdin.buffer source = sys.stdin.buffer
live_port = 9103 if printer.lower() == "fieldlens-test-capture" else 9102 # If an earlier pre-filter stage already captured this job, only pass the
try: # bytes onward. Otherwise tee the pre-driver document into CUPS_CACHEDIR.
live = socket.create_connection(("127.0.0.1", live_port), timeout=0.35) if not os.path.exists(final_path):
live.settimeout(None)
except OSError:
live = None
try: try:
os.makedirs(SPOOL, exist_ok=True) os.makedirs(SPOOL, exist_ok=True)
capture = open(part_path, "wb") capture = open(part_path, "wb")
except OSError: except OSError as error:
print(f"WARNING: FieldLens capture cache unavailable: {error}", file=sys.stderr)
capture = None capture = None
while True: while True:
@ -79,20 +71,11 @@ try:
if not chunk: if not chunk:
break break
if live is not None:
try:
live.sendall(chunk)
except OSError:
try:
live.close()
except OSError:
pass
live = None
if capture is not None: if capture is not None:
try: try:
capture.write(chunk) capture.write(chunk)
except OSError: except OSError as error:
print(f"WARNING: FieldLens capture write failed: {error}", file=sys.stderr)
try: try:
capture.close() capture.close()
except OSError: except OSError:
@ -103,39 +86,20 @@ try:
sys.stdout.buffer.flush() sys.stdout.buffer.flush()
if live is not None:
try:
live.shutdown(socket.SHUT_WR)
except OSError:
pass
live.close()
live = None
live_ok = True
if capture is not None: if capture is not None:
capture.flush() capture.flush()
capture.close() capture.close()
capture = None capture = None
if live_ok:
try:
os.unlink(part_path)
except OSError:
pass
else:
os.chmod(part_path, 0o644) os.chmod(part_path, 0o644)
os.replace(part_path, final_path) os.replace(part_path, final_path)
captured_ok = True captured_ok = True
print(f"INFO: FieldLens captured {name}", file=sys.stderr)
finally: finally:
if source is not None and source is not sys.stdin.buffer: if source is not None and source is not sys.stdin.buffer:
source.close() source.close()
if live is not None:
try:
live.close()
except OSError:
pass
if capture is not None: if capture is not None:
capture.close() capture.close()
if not live_ok and not captured_ok: if not captured_ok:
try: try:
os.unlink(part_path) os.unlink(part_path)
except OSError: except OSError:
@ -620,7 +584,8 @@ fn build_fieldlens_ppd(target_printer: &str) -> Result<PathBuf> {
let prefilter = concat!( let prefilter = concat!(
"*% FieldLens original-document capture before printer rendering\n", "*% FieldLens original-document capture before printer rendering\n",
"*cupsPreFilter: \"application/pdf 0 fieldlens-capture\"\n" "*cupsPreFilter: \"application/pdf 0 fieldlens-capture\"\n",
"*cupsPreFilter: \"application/vnd.cups-pdf 0 fieldlens-capture\"\n"
); );
if let Some(index) = cleaned.rfind("*% End") { if let Some(index) = cleaned.rfind("*% End") {
cleaned.insert_str(index, prefilter); cleaned.insert_str(index, prefilter);

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "FieldLens Print", "productName": "FieldLens Print",
"version": "0.1.10", "version": "0.1.11",
"identifier": "dev.arrowstack.fieldlens.print", "identifier": "dev.arrowstack.fieldlens.print",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View File

@ -85,7 +85,7 @@ let config: AppConfig
let jobs: Job[] = [] let jobs: Job[] = []
let status: Status let status: Status
let loginTimer: number | undefined let loginTimer: number | undefined
let currentVersion = '0.1.10' let currentVersion = '0.1.11'
let bundleType: BundleType | null = null let bundleType: BundleType | null = null
let availableUpdate: Update | null = null let availableUpdate: Update | null = null
let availableManualUpdate: ManualUpdate | null = null let availableManualUpdate: ManualUpdate | null = null