feat(linux): capture original CUPS PDF before driver rendering
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 03:13:30 +00:00
parent 496192a12b
commit 05284b6d64
7 changed files with 247 additions and 47 deletions

View File

@ -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. 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. 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.

View File

@ -1,6 +1,6 @@
{ {
"name": "@fieldlens/print", "name": "@fieldlens/print",
"version": "0.1.6", "version": "0.1.7",
"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.6" version = "0.1.7"
description = "FieldLens print-stream barcode capture companion" description = "FieldLens print-stream barcode capture companion"
authors = ["Arrowstack"] authors = ["Arrowstack"]
edition = "2021" edition = "2021"

View File

@ -24,6 +24,9 @@ use crate::{
zpl::extract_barcodes, zpl::extract_barcodes,
}; };
#[cfg(target_os = "linux")]
const SYSTEM_CUPS_SPOOL_DIR: &str = "/var/spool/fieldlens-print";
pub struct CaptureRuntime { pub struct CaptureRuntime {
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
handles: Vec<JoinHandle<()>>, handles: Vec<JoinHandle<()>>,
@ -66,10 +69,40 @@ pub fn start(config: AppConfig, state: SharedState, app: AppHandle) -> Result<Ca
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(worker_config, worker_state, worker_app, worker_stop) spool_folder_loop(
worker_config,
worker_state,
worker_app,
worker_stop,
None,
"spool-folder",
false,
)
})); }));
} }
#[cfg(target_os = "linux")]
{
let system_spool = PathBuf::from(SYSTEM_CUPS_SPOOL_DIR);
if system_spool.is_dir() && system_spool != PathBuf::from(&config.spool_dir) {
let worker_stop = stop.clone();
let worker_state = state.clone();
let worker_app = app.clone();
let worker_config = config.clone();
handles.push(thread::spawn(move || {
spool_folder_loop(
worker_config,
worker_state,
worker_app,
worker_stop,
Some(system_spool),
"cups-original",
true,
)
}));
}
}
if handles.is_empty() { if handles.is_empty() {
return Err(anyhow!("no capture route enabled")); return Err(anyhow!("no capture route enabled"));
} }
@ -200,8 +233,11 @@ fn spool_folder_loop(
state: SharedState, state: SharedState,
app: AppHandle, app: AppHandle,
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
spool_override: Option<PathBuf>,
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 processed_dir = spool_dir.join("processed");
let _ = fs::create_dir_all(&processed_dir); let _ = fs::create_dir_all(&processed_dir);
while !stop.load(Ordering::SeqCst) { while !stop.load(Ordering::SeqCst) {
@ -251,11 +287,15 @@ fn spool_folder_loop(
match fs::read(&path) { match fs::read(&path) {
Ok(bytes) => { Ok(bytes) => {
let (forwarded, error) = forward_payload(&config, &bytes); let (forwarded, error) = if already_forwarded {
(true, None)
} else {
forward_payload(&config, &bytes)
};
process_payload( process_payload(
&state, &state,
&app, &app,
"spool-folder", source,
label.clone(), label.clone(),
bytes, bytes,
forwarded, forwarded,
@ -268,7 +308,7 @@ fn spool_folder_loop(
Err(error) => process_payload( Err(error) => process_payload(
&state, &state,
&app, &app,
"spool-folder", source,
label, label,
vec![], vec![],
false, false,
@ -287,7 +327,7 @@ fn is_supported_drop(path: &std::path::Path) -> bool {
.and_then(|value| value.to_str()) .and_then(|value| value.to_str())
.map(|value| value.to_ascii_lowercase()) .map(|value| value.to_ascii_lowercase())
.as_deref(), .as_deref(),
Some("zpl" | "raw" | "prn" | "spl") Some("zpl" | "raw" | "prn" | "spl" | "pdf" | "ps" | "png" | "jpg" | "jpeg")
) )
} }

View File

@ -1,9 +1,9 @@
use std::process::Command; use std::process::Command;
#[cfg(target_os = "windows")] #[cfg(any(target_os = "windows", target_os = "linux"))]
use std::fs; use std::fs;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use std::path::PathBuf; use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -13,6 +13,82 @@ use serde_json::Value;
pub const VIRTUAL_PRINTER_NAME: &str = "FieldLens Print"; pub const VIRTUAL_PRINTER_NAME: &str = "FieldLens Print";
const WINDOWS_PORT_NAME: &str = "FieldLensPrint-9100"; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SystemPrinter { pub struct SystemPrinter {
@ -344,7 +420,7 @@ fn linux_virtual_printer_status() -> Result<VirtualPrinterStatus> {
installed: ready, installed: ready,
name: VIRTUAL_PRINTER_NAME.into(), name: VIRTUAL_PRINTER_NAME.into(),
detail: if ready { 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 { } else {
"FieldLens Print exists but CUPS has it disabled or not accepting jobs. Click Create / repair FieldLens Print.".into() "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")] #[cfg(target_os = "linux")]
fn install_linux_virtual_printer(target_printer: &str) -> Result<()> { fn cups_filter_dir() -> Result<&'static Path> {
let printers = list_linux_printers()?; for candidate in ["/usr/lib/cups/filter", "/usr/libexec/cups/filter"] {
if !printers.iter().any(|printer| printer.name == target_printer && !printer.is_virtual) { 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<PathBuf> {
let source = PathBuf::from("/etc/cups/ppd").join(format!("{target_printer}.ppd"));
if !source.exists() {
return Err(anyhow!( 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 original = fs::read_to_string(&source)
let mut args = vec![ .with_context(|| format!("read target printer PPD {}", source.display()))?;
let mut cleaned = original
.lines()
.filter(|line| !line.contains("fieldlens-capture"))
.collect::<Vec<_>>()
.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(), "-p".to_string(),
"FieldLens-Print".to_string(), "FieldLens-Print".to_string(),
"-E".to_string(), "-E".to_string(),
"-v".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() { let setup_result = run_linux_admin("lpadmin", &args, "configure FieldLens CUPS printer");
args.push("-P".to_string()); let _ = fs::remove_file(&ppd_path);
args.push(ppd_path.to_string_lossy().into_owned()); setup_result?;
} 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"
));
}
}
// lpadmin -E normally enables the queue, but explicitly repair both CUPS // lpadmin -E normally enables the queue, but explicitly repair both CUPS
// state flags because Chromium disables Print when either is stale. // state flags because Chromium disables Print when either is stale.

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.6", "version": "0.1.7",
"identifier": "dev.arrowstack.fieldlens.print", "identifier": "dev.arrowstack.fieldlens.print",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@ -38,7 +38,8 @@
"linux": { "linux": {
"deb": { "deb": {
"depends": [ "depends": [
"cups-client" "cups-client",
"python3"
], ],
"recommends": [ "recommends": [
"policykit-1" "policykit-1"

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.6' let currentVersion = '0.1.7'
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