From 05284b6d64bf5ec1311e86d9d9f7353562ba5c84 Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 24 Sep 2026 03:13:30 +0000 Subject: [PATCH] feat(linux): capture original CUPS PDF before driver rendering --- README.md | 2 +- package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/capture.rs | 52 ++++++++- src-tauri/src/printers.rs | 229 ++++++++++++++++++++++++++++++++------ src-tauri/tauri.conf.json | 5 +- src/main.ts | 2 +- 7 files changed, 247 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 3665be2..ed9766f 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,6 @@ FieldLens Print can create an OS printer queue that appears in normal applicatio Windows creates the virtual queue with the selected destination printer's existing driver and points it at `127.0.0.1:9100`, so the captured stream is already in the destination printer's language before FieldLens inspects and forwards it. -Linux creates a CUPS raw queue named `FieldLens-Print` with display name **FieldLens Print** and routes it to the same local RAW listener. +Linux creates a CUPS queue named `FieldLens-Print` using the selected physical printer's capabilities and device URI. A FieldLens CUPS PDF pre-filter copies the original submitted PDF into `/var/spool/fieldlens-print` before the physical printer driver renders it, then passes the PDF onward unchanged through the normal CUPS filter chain. FieldLens Print watches that spool for capture/decoding while CUPS continues printing normally. USB/shared queues may not expose a RAW/TCP address. In that case the installed-printer selector still supplies the driver for the virtual Windows queue, while the physical destination address must be entered manually. diff --git a/package.json b/package.json index 3c7f850..655de9e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fieldlens/print", - "version": "0.1.6", + "version": "0.1.7", "private": true, "type": "module", "scripts": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 836437a..216b3a2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fieldlens-print" -version = "0.1.6" +version = "0.1.7" description = "FieldLens print-stream barcode capture companion" authors = ["Arrowstack"] edition = "2021" diff --git a/src-tauri/src/capture.rs b/src-tauri/src/capture.rs index f6aeeec..dab640f 100644 --- a/src-tauri/src/capture.rs +++ b/src-tauri/src/capture.rs @@ -24,6 +24,9 @@ use crate::{ zpl::extract_barcodes, }; +#[cfg(target_os = "linux")] +const SYSTEM_CUPS_SPOOL_DIR: &str = "/var/spool/fieldlens-print"; + pub struct CaptureRuntime { stop: Arc, handles: Vec>, @@ -66,10 +69,40 @@ pub fn start(config: AppConfig, state: SharedState, app: AppHandle) -> Result, + spool_override: Option, + source: &'static str, + already_forwarded: bool, ) { - let spool_dir = PathBuf::from(&config.spool_dir); + let spool_dir = spool_override.unwrap_or_else(|| PathBuf::from(&config.spool_dir)); let processed_dir = spool_dir.join("processed"); let _ = fs::create_dir_all(&processed_dir); while !stop.load(Ordering::SeqCst) { @@ -251,11 +287,15 @@ fn spool_folder_loop( match fs::read(&path) { Ok(bytes) => { - let (forwarded, error) = forward_payload(&config, &bytes); + let (forwarded, error) = if already_forwarded { + (true, None) + } else { + forward_payload(&config, &bytes) + }; process_payload( &state, &app, - "spool-folder", + source, label.clone(), bytes, forwarded, @@ -268,7 +308,7 @@ fn spool_folder_loop( Err(error) => process_payload( &state, &app, - "spool-folder", + source, label, vec![], false, @@ -287,7 +327,7 @@ fn is_supported_drop(path: &std::path::Path) -> bool { .and_then(|value| value.to_str()) .map(|value| value.to_ascii_lowercase()) .as_deref(), - Some("zpl" | "raw" | "prn" | "spl") + Some("zpl" | "raw" | "prn" | "spl" | "pdf" | "ps" | "png" | "jpg" | "jpeg") ) } diff --git a/src-tauri/src/printers.rs b/src-tauri/src/printers.rs index 636ee3c..aadf413 100644 --- a/src-tauri/src/printers.rs +++ b/src-tauri/src/printers.rs @@ -1,9 +1,9 @@ use std::process::Command; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", target_os = "linux"))] use std::fs; #[cfg(target_os = "linux")] -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; @@ -13,6 +13,82 @@ use serde_json::Value; pub const VIRTUAL_PRINTER_NAME: &str = "FieldLens Print"; const WINDOWS_PORT_NAME: &str = "FieldLensPrint-9100"; +#[cfg(target_os = "linux")] +const FIELDLENS_CUPS_SPOOL_DIR: &str = "/var/spool/fieldlens-print"; +#[cfg(target_os = "linux")] +const FIELDLENS_CUPS_FILTER: &str = r#"#!/usr/bin/python3 +import os +import re +import sys +import uuid + +SPOOL = "/var/spool/fieldlens-print" + +def safe(value): + value = re.sub(r"[^A-Za-z0-9._-]+", "-", value or "job").strip("-") + return value[:80] or "job" + +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") +content_type = os.environ.get("CONTENT_TYPE", "application/pdf") +ext = { + "application/pdf": "pdf", + "application/postscript": "ps", + "image/png": "png", + "image/jpeg": "jpg", +}.get(content_type, "dat") +name = f"{job_id}-{title}-{uuid.uuid4().hex}.{ext}" +final_path = os.path.join(SPOOL, name) +part_path = final_path + ".part" + +source = None +capture = None +captured_ok = False +try: + if len(sys.argv) > 6 and sys.argv[6]: + source = open(sys.argv[6], "rb") + else: + source = sys.stdin.buffer + try: + capture = open(part_path, "wb") + except OSError: + capture = None + + while True: + chunk = source.read(65536) + if not chunk: + break + if capture is not None: + try: + capture.write(chunk) + except OSError: + try: + capture.close() + except OSError: + pass + capture = None + sys.stdout.buffer.write(chunk) + + sys.stdout.buffer.flush() + if capture is not None: + capture.flush() + capture.close() + capture = None + os.chmod(part_path, 0o644) + os.replace(part_path, final_path) + captured_ok = True +finally: + if source is not None and source is not sys.stdin.buffer: + source.close() + if capture is not None: + capture.close() + if not captured_ok: + try: + os.unlink(part_path) + except OSError: + pass +"#; + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SystemPrinter { @@ -344,7 +420,7 @@ fn linux_virtual_printer_status() -> Result { installed: ready, name: VIRTUAL_PRINTER_NAME.into(), detail: if ready { - "Ready and accepting jobs in the system print dialog.".into() + "Ready. Original PDF jobs are captured before the physical printer driver renders them.".into() } else { "FieldLens Print exists but CUPS has it disabled or not accepting jobs. Click Create / repair FieldLens Print.".into() }, @@ -370,48 +446,131 @@ fn run_cups_repair_command(program: &str, args: &[&str]) -> Result<()> { } #[cfg(target_os = "linux")] -fn install_linux_virtual_printer(target_printer: &str) -> Result<()> { - let printers = list_linux_printers()?; - if !printers.iter().any(|printer| printer.name == target_printer && !printer.is_virtual) { +fn cups_filter_dir() -> Result<&'static Path> { + for candidate in ["/usr/lib/cups/filter", "/usr/libexec/cups/filter"] { + let path = Path::new(candidate); + if path.is_dir() { + return Ok(path); + } + } + Err(anyhow!("CUPS filter directory was not found")) +} + +#[cfg(target_os = "linux")] +fn run_linux_admin(program: &str, args: &[String], context: &str) -> Result<()> { + let direct = Command::new(program).args(args).status(); + if matches!(direct, Ok(status) if status.success()) { + return Ok(()); + } + + let status = Command::new("pkexec") + .arg(program) + .args(args) + .status() + .with_context(|| format!("run elevated {context}"))?; + if !status.success() { + return Err(anyhow!("{context} failed; administrator approval may be required")); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn install_linux_capture_filter() -> Result<()> { + let filter_dir = cups_filter_dir()?; + let filter_path = filter_dir.join("fieldlens-capture"); + let temp_filter = std::env::temp_dir().join(format!( + "fieldlens-capture-{}.py", + uuid::Uuid::new_v4() + )); + fs::write(&temp_filter, FIELDLENS_CUPS_FILTER).context("write FieldLens CUPS pre-filter")?; + + let install_filter_args = vec![ + "-m".to_string(), + "0755".to_string(), + temp_filter.to_string_lossy().into_owned(), + filter_path.to_string_lossy().into_owned(), + ]; + let filter_result = run_linux_admin( + "install", + &install_filter_args, + "install FieldLens CUPS pre-filter", + ); + let _ = fs::remove_file(&temp_filter); + filter_result?; + + let spool_args = vec![ + "-d".to_string(), + "-m".to_string(), + "0777".to_string(), + FIELDLENS_CUPS_SPOOL_DIR.to_string(), + ]; + run_linux_admin("install", &spool_args, "create FieldLens CUPS capture spool")?; + Ok(()) +} + +#[cfg(target_os = "linux")] +fn build_fieldlens_ppd(target_printer: &str) -> Result { + let source = PathBuf::from("/etc/cups/ppd").join(format!("{target_printer}.ppd")); + if !source.exists() { return Err(anyhow!( - "selected printer '{target_printer}' is not installed or available in CUPS" + "CUPS PPD for '{target_printer}' was not found; FieldLens needs the installed printer definition to preserve browser print capabilities" )); } - let ppd_path = PathBuf::from("/etc/cups/ppd").join(format!("{target_printer}.ppd")); - let mut args = vec![ + let original = fs::read_to_string(&source) + .with_context(|| format!("read target printer PPD {}", source.display()))?; + let mut cleaned = original + .lines() + .filter(|line| !line.contains("fieldlens-capture")) + .collect::>() + .join("\n"); + cleaned.push('\n'); + + let prefilter = concat!( + "*% FieldLens original-document capture before printer rendering\n", + "*cupsPreFilter: \"application/pdf 0 fieldlens-capture\"\n" + ); + if let Some(index) = cleaned.rfind("*% End") { + cleaned.insert_str(index, prefilter); + } else { + cleaned.push_str(prefilter); + } + + let target = std::env::temp_dir().join(format!( + "fieldlens-{}.ppd", + uuid::Uuid::new_v4() + )); + fs::write(&target, cleaned).context("write FieldLens derived PPD")?; + Ok(target) +} + +#[cfg(target_os = "linux")] +fn install_linux_virtual_printer(target_printer: &str) -> Result<()> { + let printers = list_linux_printers()?; + let target = printers + .iter() + .find(|printer| printer.name == target_printer && !printer.is_virtual) + .ok_or_else(|| anyhow!( + "selected printer '{target_printer}' is not installed or available in CUPS" + ))?; + + install_linux_capture_filter()?; + let ppd_path = build_fieldlens_ppd(target_printer)?; + let args = vec![ "-p".to_string(), "FieldLens-Print".to_string(), "-E".to_string(), "-v".to_string(), - "socket://127.0.0.1:9100".to_string(), + target.port.clone(), + "-P".to_string(), + ppd_path.to_string_lossy().into_owned(), + "-D".to_string(), + VIRTUAL_PRINTER_NAME.to_string(), ]; - if ppd_path.exists() { - args.push("-P".to_string()); - args.push(ppd_path.to_string_lossy().into_owned()); - } else { - args.push("-m".to_string()); - args.push("raw".to_string()); - } - - args.push("-D".to_string()); - args.push(VIRTUAL_PRINTER_NAME.to_string()); - - let direct = Command::new("lpadmin").args(&args).status(); - if !matches!(direct, Ok(status) if status.success()) { - let status = Command::new("pkexec") - .arg("lpadmin") - .args(&args) - .status() - .context("run elevated CUPS printer setup")?; - - if !status.success() { - return Err(anyhow!( - "CUPS printer setup failed; administrator approval may be required" - )); - } - } + let setup_result = run_linux_admin("lpadmin", &args, "configure FieldLens CUPS printer"); + let _ = fs::remove_file(&ppd_path); + setup_result?; // lpadmin -E normally enables the queue, but explicitly repair both CUPS // state flags because Chromium disables Print when either is stale. diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2b83301..bcae026 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "FieldLens Print", - "version": "0.1.6", + "version": "0.1.7", "identifier": "dev.arrowstack.fieldlens.print", "build": { "beforeDevCommand": "npm run dev", @@ -38,7 +38,8 @@ "linux": { "deb": { "depends": [ - "cups-client" + "cups-client", + "python3" ], "recommends": [ "policykit-1" diff --git a/src/main.ts b/src/main.ts index 92d1616..f87d016 100644 --- a/src/main.ts +++ b/src/main.ts @@ -85,7 +85,7 @@ let config: AppConfig let jobs: Job[] = [] let status: Status let loginTimer: number | undefined -let currentVersion = '0.1.6' +let currentVersion = '0.1.7' let bundleType: BundleType | null = null let availableUpdate: Update | null = null let availableManualUpdate: ManualUpdate | null = null