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
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:
parent
09d817f748
commit
cbaae90161
@ -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/*
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()?;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user