From cbaae901617e60e1beceaf2c0a830f5b524591e2 Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 26 Sep 2026 05:59:50 +0000 Subject: [PATCH] fix(linux): make CUPS original-document capture authoritative --- Dockerfile.builder-linux | 1 + src-tauri/src/capture.rs | 109 ++++++++-------------------- src-tauri/src/lib.rs | 52 ++++++++++---- src-tauri/src/model.rs | 7 +- src-tauri/src/printers.rs | 147 +++++++++++++++++++++++++++++--------- src-tauri/src/state.rs | 12 +++- 6 files changed, 200 insertions(+), 128 deletions(-) diff --git a/Dockerfile.builder-linux b/Dockerfile.builder-linux index babe8eb..45f28a6 100644 --- a/Dockerfile.builder-linux +++ b/Dockerfile.builder-linux @@ -11,6 +11,7 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-ins libxdo-dev \ patchelf \ pkg-config \ + python3 \ librsvg2-dev \ wget \ && rm -rf /var/lib/apt/lists/* diff --git a/src-tauri/src/capture.rs b/src-tauri/src/capture.rs index 66ed932..37d036b 100644 --- a/src-tauri/src/capture.rs +++ b/src-tauri/src/capture.rs @@ -50,9 +50,13 @@ pub fn start(config: AppConfig, state: SharedState, app: AppHandle) -> Result Result Result Result, -) { +fn test_capture_sink_loop(listener: TcpListener, stop: Arc) { while !stop.load(Ordering::SeqCst) { match listener.accept() { Ok((stream, _)) => { - let state = state.clone(); - let app = app.clone(); - thread::spawn(move || handle_test_capture_stream(stream, state, app)); + thread::spawn(move || drain_test_capture_stream(stream)); } Err(error) if error.kind() == ErrorKind::WouldBlock => { thread::sleep(Duration::from_millis(80)); @@ -156,46 +157,16 @@ fn test_capture_sink_loop( } #[cfg(target_os = "linux")] -fn read_test_capture_stream(mut reader: R) -> std::io::Result> { - let mut bytes = Vec::new(); - let mut buffer = [0u8; 32 * 1024]; - - loop { - match reader.read(&mut buffer) { - Ok(0) => break, - Ok(read) => bytes.extend_from_slice(&buffer[..read]), - Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => break, - Err(error) => return Err(error), - } - } - - Ok(bytes) -} - -#[cfg(target_os = "linux")] -fn handle_test_capture_stream(mut stream: TcpStream, state: SharedState, app: AppHandle) { +fn drain_test_capture_stream(mut stream: TcpStream) { let _ = stream.set_read_timeout(Some(Duration::from_secs(2))); - - match read_test_capture_stream(&mut stream) { - Ok(bytes) if !bytes.is_empty() => process_payload( - &state, - &app, - "cups-test", - "FieldLens-Test-Capture".into(), - bytes, - false, - None, - ), - Ok(_) => {} - Err(error) => process_payload( - &state, - &app, - "cups-test", - "FieldLens-Test-Capture".into(), - vec![], - false, - Some(format!("test capture read failed: {error}")), - ), + let mut buffer = [0u8; 32 * 1024]; + loop { + match stream.read(&mut buffer) { + Ok(0) => break, + Ok(_) => {} + Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => break, + Err(_) => break, + } } } @@ -909,25 +880,3 @@ fn process_payload( }, ); } - - -#[cfg(test)] -mod self_test_regressions { - use super::*; - use std::io::Cursor; - - #[test] - fn self_test_stream_payload_is_preserved_for_capture_processing() { - let payload = b"%PDF-1.4\nFieldLens capture self-test\n%%EOF\n"; - let bytes = read_test_capture_stream(Cursor::new(payload)).expect("read self-test payload"); - assert_eq!(bytes, payload); - assert!(!bytes.is_empty()); - } - - #[test] - fn empty_self_test_stream_stays_empty() { - let bytes = read_test_capture_stream(Cursor::new(Vec::::new())) - .expect("read empty self-test payload"); - assert!(bytes.is_empty()); - } -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 43274de..cb696a5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,7 +10,7 @@ mod zpl; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use parking_lot::Mutex; use serde_json::Value; -use std::{fs, path::{Path, PathBuf}, time::SystemTime}; +use std::{fs, path::{Path, PathBuf}, time::{Duration, Instant, SystemTime}}; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; use tauri::{AppHandle, Manager, State}; @@ -264,17 +264,19 @@ fn get_debug_snapshot(state: State<'_, AppState>) -> Result Result { +fn run_capture_self_test(state: State<'_, AppState>) -> Result { #[cfg(target_os = "linux")] { - let path = std::env::temp_dir().join(format!( - "fieldlens-capture-self-test-{}.pdf", - uuid::Uuid::new_v4() - )); + if state.runtime.lock().is_none() { + return Err("Capture is not running. Start capture before running the self-test.".into()); + } + + let token = format!("fieldlens-self-test-{}", uuid::Uuid::new_v4()); + let path = std::env::temp_dir().join(format!("{token}.pdf")); std::fs::write(&path, build_self_test_pdf()).map_err(|error| error.to_string())?; let output = std::process::Command::new("lp") - .args(["-d", "FieldLens-Test-Capture"]) + .args(["-d", "FieldLens-Test-Capture", "-t", &token]) .arg(&path) .output() .map_err(|error| error.to_string()); @@ -288,16 +290,40 @@ fn run_capture_self_test() -> Result { )); } - let detail = String::from_utf8_lossy(&output.stdout).trim().to_string(); - return Ok(if detail.is_empty() { - "Self-test submitted to FieldLens Test Capture.".into() - } else { - detail - }); + let deadline = Instant::now() + Duration::from_secs(8); + loop { + if let Some(job) = state + .shared + .jobs + .lock() + .iter() + .find(|job| job.source == "cups-test" && job.label.contains(&token)) + .cloned() + { + if let Some(error) = job.error { + return Err(format!("Self-test capture failed: {error}")); + } + return Ok(format!( + "Self-test captured successfully ({} bytes).", + job.bytes + )); + } + + if Instant::now() >= deadline { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + + return Err( + "CUPS accepted the self-test job, but FieldLens did not observe the corresponding original-document capture within 8 seconds. Repair the FieldLens queues/filter and retry." + .into(), + ); } #[cfg(not(target_os = "linux"))] { + let _ = state; Err("Capture self-test is currently available on Linux only.".into()) } } diff --git a/src-tauri/src/model.rs b/src-tauri/src/model.rs index 0aa7777..b9f94c8 100644 --- a/src-tauri/src/model.rs +++ b/src-tauri/src/model.rs @@ -36,7 +36,12 @@ pub struct AppConfig { impl Default for AppConfig { fn default() -> Self { Self { - mode: CaptureMode::RawProxy, + mode: { + #[cfg(target_os = "linux")] + { CaptureMode::SpoolFolder } + #[cfg(not(target_os = "linux"))] + { CaptureMode::RawProxy } + }, listen_addr: "127.0.0.1:9100".into(), forward_addr: String::new(), forward_printer: String::new(), diff --git a/src-tauri/src/printers.rs b/src-tauri/src/printers.rs index ad4749b..eabeccd 100644 --- a/src-tauri/src/printers.rs +++ b/src-tauri/src/printers.rs @@ -39,7 +39,9 @@ import os import re import sys -CACHE = "/var/lib/fieldlens-print/capture"\nOWNER_UID = __FIELDLENS_OWNER_UID__\nOWNER_GID = __FIELDLENS_OWNER_GID__ +CACHE = "/var/lib/fieldlens-print/capture" +OWNER_UID = __FIELDLENS_OWNER_UID__ +OWNER_GID = __FIELDLENS_OWNER_GID__ def safe(value): value = re.sub(r"[^A-Za-z0-9._-]+", "-", value or "job").strip("-") @@ -118,6 +120,13 @@ finally: pass "#; +#[cfg(target_os = "linux")] +fn render_linux_capture_filter(uid: &str, gid: &str) -> String { + FIELDLENS_CUPS_FILTER + .replace("__FIELDLENS_OWNER_UID__", uid) + .replace("__FIELDLENS_OWNER_GID__", gid) +} + #[cfg(target_os = "linux")] const FIELDLENS_CUPS_REPAIR_HELPER: &str = r#"#!/usr/bin/python3 import os @@ -586,45 +595,90 @@ fn socket_uri_to_address(uri: &str) -> Option { } #[cfg(target_os = "linux")] -fn linux_virtual_printer_status() -> Result { - let printers = list_linux_printers()?; - let installed = printers - .iter() - .any(|printer| printer.name.eq_ignore_ascii_case("FieldLens-Print")); - - if !installed { - return Ok(VirtualPrinterStatus { - installed: false, - name: VIRTUAL_PRINTER_NAME.into(), - detail: "Not installed yet. Choose the physical destination printer, then create it.".into(), - }); - } - +fn linux_queue_ready(queue: &str) -> Result { let state = Command::new("lpstat") - .args(["-p", "FieldLens-Print"]) + .args(["-p", queue]) .output() - .context("query FieldLens Print CUPS state")?; + .with_context(|| format!("query {queue} CUPS state"))?; let accepting = Command::new("lpstat") - .args(["-a", "FieldLens-Print"]) + .args(["-a", queue]) .output() - .context("query FieldLens Print accepting state")?; + .with_context(|| format!("query {queue} accepting state"))?; let state_text = String::from_utf8_lossy(&state.stdout); let accepting_text = String::from_utf8_lossy(&accepting.stdout); - let enabled = state.status.success() - && !state_text.to_ascii_lowercase().contains("disabled"); - let accepts_jobs = accepting.status.success() - && accepting_text.to_ascii_lowercase().contains("accepting requests"); - let ready = enabled && accepts_jobs; + Ok( + state.status.success() + && !state_text.to_ascii_lowercase().contains("disabled") + && accepting.status.success() + && accepting_text.to_ascii_lowercase().contains("accepting requests"), + ) +} + +#[cfg(target_os = "linux")] +fn linux_capture_filter_health() -> Result<()> { + let filter_path = cups_filter_dir()?.join("fieldlens-capture"); + let metadata = fs::metadata(&filter_path) + .with_context(|| format!("FieldLens CUPS filter missing at {}", filter_path.display()))?; + if !metadata.is_file() { + return Err(anyhow!("FieldLens CUPS filter is not a regular file")); + } + + let check = Command::new("python3") + .arg("-c") + .arg("import pathlib,sys; p=sys.argv[1]; compile(pathlib.Path(p).read_text(), p, 'exec')") + .arg(&filter_path) + .output() + .context("validate FieldLens CUPS filter with python3")?; + if !check.status.success() { + return Err(anyhow!( + "FieldLens CUPS filter is invalid: {}", + String::from_utf8_lossy(&check.stderr).trim() + )); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn linux_virtual_printer_status() -> Result { + let printers = list_linux_printers()?; + let print_installed = printers + .iter() + .any(|printer| printer.name.eq_ignore_ascii_case("FieldLens-Print")); + let test_installed = printers + .iter() + .any(|printer| printer.name.eq_ignore_ascii_case(TEST_VIRTUAL_PRINTER_QUEUE)); + + if !print_installed || !test_installed { + return Ok(VirtualPrinterStatus { + installed: false, + name: VIRTUAL_PRINTER_NAME.into(), + detail: "FieldLens CUPS queues are incomplete. Click Create / repair printers.".into(), + }); + } + + let print_ready = linux_queue_ready("FieldLens-Print")?; + let test_ready = linux_queue_ready(TEST_VIRTUAL_PRINTER_QUEUE)?; + if !print_ready || !test_ready { + return Ok(VirtualPrinterStatus { + installed: false, + name: VIRTUAL_PRINTER_NAME.into(), + detail: "A FieldLens CUPS queue is disabled or not accepting jobs. Click Create / repair printers.".into(), + }); + } + + if let Err(error) = linux_capture_filter_health() { + return Ok(VirtualPrinterStatus { + installed: false, + name: VIRTUAL_PRINTER_NAME.into(), + detail: format!("Capture filter is broken: {error}. Click Create / repair printers."), + }); + } Ok(VirtualPrinterStatus { - installed: ready, + installed: true, name: VIRTUAL_PRINTER_NAME.into(), - detail: if ready { - "Ready. FieldLens Print sends to the physical printer; FieldLens Test Capture runs the same capture/render path but discards the rendered output.".into() - } else { - "FieldLens Print exists but CUPS has it disabled or not accepting jobs. Click Create / repair FieldLens Print.".into() - }, + detail: "Ready. Original documents are captured by the CUPS pre-filter before normal CUPS rendering; test jobs use the same capture path with rendered output discarded.".into(), }) } @@ -808,9 +862,7 @@ fn install_linux_virtual_printer(target_printer: &str) -> Result<()> { let uid = String::from_utf8_lossy(&uid.stdout).trim().to_string(); let gid = String::from_utf8_lossy(&gid.stdout).trim().to_string(); - let filter_script = FIELDLENS_CUPS_FILTER - .replace("__FIELDLENS_OWNER_UID__", &uid) - .replace("__FIELDLENS_OWNER_GID__", &gid); + let filter_script = render_linux_capture_filter(&uid, &gid); fs::write(&temp_filter, filter_script) .context("write FieldLens CUPS capture filter")?; fs::write(&temp_helper, FIELDLENS_CUPS_REPAIR_HELPER) @@ -860,3 +912,32 @@ fn remove_linux_virtual_printer() -> Result<()> { } Ok(()) } + + +#[cfg(all(test, target_os = "linux"))] +mod linux_filter_tests { + use super::*; + + #[test] + fn rendered_cups_filter_compiles_with_python() { + let path = std::env::temp_dir().join(format!( + "fieldlens-filter-test-{}.py", + uuid::Uuid::new_v4() + )); + let script = render_linux_capture_filter("1000", "1000"); + fs::write(&path, script).expect("write rendered CUPS filter"); + + let output = Command::new("python3") + .args(["-m", "py_compile"]) + .arg(&path) + .output() + .expect("run python3 -m py_compile"); + + let _ = fs::remove_file(&path); + assert!( + output.status.success(), + "rendered CUPS filter must compile: {}", + String::from_utf8_lossy(&output.stderr) + ); + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 5269acc..5271102 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -37,6 +37,11 @@ impl SharedState { fs::create_dir_all(&data_dir).context("create FieldLens Print data directory")?; let mut config = read_json::(&data_dir.join("config.json")).unwrap_or_default(); + #[cfg(target_os = "linux")] + { + config.mode = crate::model::CaptureMode::SpoolFolder; + config.forward_addr.clear(); + } if config.spool_dir.trim().is_empty() { config.spool_dir = data_dir.join("spool").to_string_lossy().into_owned(); } @@ -105,7 +110,12 @@ impl SharedState { self.persist_auth() } - pub fn replace_config(&self, next: AppConfig) -> Result { + pub fn replace_config(&self, mut next: AppConfig) -> Result { + #[cfg(target_os = "linux")] + { + next.mode = crate::model::CaptureMode::SpoolFolder; + next.forward_addr.clear(); + } fs::create_dir_all(&next.spool_dir).ok(); *self.config.lock() = next.clone(); self.persist_config()?;