fix(linux): make CUPS original-document capture authoritative
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-26 05:59:50 +00:00
parent 09d817f748
commit cbaae90161
6 changed files with 200 additions and 128 deletions

View File

@ -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/*

View File

@ -50,9 +50,13 @@ pub fn start(config: AppConfig, state: SharedState, app: AppHandle) -> Result<Ca
let stop = Arc::new(AtomicBool::new(false));
let mut handles = Vec::new();
#[cfg(target_os = "windows")]
if matches!(config.mode, CaptureMode::RawProxy | CaptureMode::Both) {
// Compatibility backend. This boundary is intentionally Windows-only
// so a native spool/document capture backend can replace localhost:9100
// without changing downstream job processing.
let listener = TcpListener::bind(&config.listen_addr)
.with_context(|| format!("bind RAW capture listener {}", config.listen_addr))?;
.with_context(|| format!("bind Windows RAW compatibility listener {}", config.listen_addr))?;
listener.set_nonblocking(true)?;
let worker_stop = stop.clone();
@ -98,6 +102,9 @@ pub fn start(config: AppConfig, state: SharedState, app: AppHandle) -> Result<Ca
{
for spool in [SYSTEM_CUPS_SPOOL_DIR, LEGACY_CUPS_SPOOL_DIR] {
let system_spool = PathBuf::from(spool);
if spool == LEGACY_CUPS_SPOOL_DIR && fs::read_dir(&system_spool).is_err() {
continue;
}
let worker_stop = stop.clone();
let worker_state = state.clone();
let worker_app = app.clone();
@ -113,16 +120,17 @@ pub fn start(config: AppConfig, state: SharedState, app: AppHandle) -> Result<Ca
}));
}
let sink = TcpListener::bind(TEST_CAPTURE_SINK_ADDR)
.with_context(|| format!("bind test-capture sink {TEST_CAPTURE_SINK_ADDR}"))?;
// The self-test queue uses the same CUPS pre-filter as production.
// Port 9101 only consumes the rendered output so no paper is produced.
// Failure to bind this optional sink must not stop production capture.
if let Ok(sink) = TcpListener::bind(TEST_CAPTURE_SINK_ADDR) {
sink.set_nonblocking(true)?;
let worker_stop = stop.clone();
let worker_state = state.clone();
let worker_app = app.clone();
handles.push(thread::spawn(move || {
test_capture_sink_loop(sink, worker_state, worker_app, worker_stop)
test_capture_sink_loop(sink, worker_stop)
}));
}
}
if handles.is_empty() {
return Err(anyhow!("no capture route enabled"));
@ -132,18 +140,11 @@ pub fn start(config: AppConfig, state: SharedState, app: AppHandle) -> Result<Ca
}
#[cfg(target_os = "linux")]
fn test_capture_sink_loop(
listener: TcpListener,
state: SharedState,
app: AppHandle,
stop: Arc<AtomicBool>,
) {
fn test_capture_sink_loop(listener: TcpListener, stop: Arc<AtomicBool>) {
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<R: Read>(mut reader: R) -> std::io::Result<Vec<u8>> {
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,
),
let mut buffer = [0u8; 32 * 1024];
loop {
match stream.read(&mut buffer) {
Ok(0) => break,
Ok(_) => {}
Err(error) => process_payload(
&state,
&app,
"cups-test",
"FieldLens-Test-Capture".into(),
vec![],
false,
Some(format!("test capture read failed: {error}")),
),
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::<u8>::new()))
.expect("read empty self-test payload");
assert!(bytes.is_empty());
}
}

View File

@ -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<DebugSnapshot, Strin
}
#[tauri::command]
fn run_capture_self_test() -> Result<String, String> {
fn run_capture_self_test(state: State<'_, AppState>) -> Result<String, String> {
#[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<String, String> {
));
}
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())
}
}

View File

@ -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(),

View File

@ -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<String> {
}
#[cfg(target_os = "linux")]
fn linux_virtual_printer_status() -> Result<VirtualPrinterStatus> {
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<bool> {
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<VirtualPrinterStatus> {
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)
);
}
}

View File

@ -37,6 +37,11 @@ impl SharedState {
fs::create_dir_all(&data_dir).context("create FieldLens Print data directory")?;
let mut config = read_json::<AppConfig>(&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<AppConfig> {
pub fn replace_config(&self, mut next: AppConfig) -> Result<AppConfig> {
#[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()?;