use std::{ fs, io::{ErrorKind, Read, Write}, net::{TcpListener, TcpStream}, path::PathBuf, sync::{ atomic::{AtomicBool, Ordering}, Arc, }, thread::{self, JoinHandle}, time::Duration, }; #[cfg(target_os = "linux")] use std::process::{Command, Stdio}; use anyhow::{anyhow, Context, Result}; use tauri::AppHandle; use uuid::Uuid; use crate::{ model::{AppConfig, CaptureMode, JobSummary}, pallet::parse_pallet_snapshot, state::SharedState, zpl::extract_barcodes, }; #[cfg(target_os = "linux")] const SYSTEM_CUPS_SPOOL_DIR: &str = "/var/lib/fieldlens-print/capture"; #[cfg(target_os = "linux")] const TEST_CAPTURE_SINK_ADDR: &str = "127.0.0.1:9101"; pub struct CaptureRuntime { stop: Arc, handles: Vec>, } impl CaptureRuntime { pub fn stop(mut self) { self.stop.store(true, Ordering::SeqCst); for handle in self.handles.drain(..) { let _ = handle.join(); } } } pub fn start(config: AppConfig, state: SharedState, app: AppHandle) -> Result { let stop = Arc::new(AtomicBool::new(false)); let mut handles = Vec::new(); if matches!(config.mode, CaptureMode::RawProxy | CaptureMode::Both) { let listener = TcpListener::bind(&config.listen_addr) .with_context(|| format!("bind RAW capture listener {}", config.listen_addr))?; listener.set_nonblocking(true)?; 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 || { raw_proxy_loop(listener, worker_config, worker_state, worker_app, worker_stop) })); } if matches!(config.mode, CaptureMode::SpoolFolder | CaptureMode::Both) { let spool_dir = PathBuf::from(&config.spool_dir); #[cfg(target_os = "linux")] let is_system_cups_spool = spool_dir == PathBuf::from(SYSTEM_CUPS_SPOOL_DIR); #[cfg(not(target_os = "linux"))] let is_system_cups_spool = false; if !is_system_cups_spool { fs::create_dir_all(&spool_dir) .with_context(|| format!("create spool capture directory {}", spool_dir.display()))?; 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, None, "spool-folder", false, ) })); } } #[cfg(target_os = "linux")] { let system_spool = PathBuf::from(SYSTEM_CUPS_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 || { cups_cache_folder_loop( system_spool, worker_config, worker_state, worker_app, worker_stop, ) })); let sink = TcpListener::bind(TEST_CAPTURE_SINK_ADDR) .with_context(|| format!("bind test-capture sink {TEST_CAPTURE_SINK_ADDR}"))?; sink.set_nonblocking(true)?; let worker_stop = stop.clone(); handles.push(thread::spawn(move || test_capture_sink_loop(sink, worker_stop))); } if handles.is_empty() { return Err(anyhow!("no capture route enabled")); } Ok(CaptureRuntime { stop, handles }) } #[cfg(target_os = "linux")] fn test_capture_sink_loop(listener: TcpListener, stop: Arc) { while !stop.load(Ordering::SeqCst) { match listener.accept() { Ok((stream, _)) => { thread::spawn(move || drain_test_capture_stream(stream)); } Err(error) if error.kind() == ErrorKind::WouldBlock => { thread::sleep(Duration::from_millis(80)); } Err(_) => { thread::sleep(Duration::from_millis(250)); } } } } #[cfg(target_os = "linux")] fn drain_test_capture_stream(mut stream: TcpStream) { let _ = stream.set_read_timeout(Some(Duration::from_secs(2))); 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, } } } fn raw_proxy_loop( listener: TcpListener, config: AppConfig, state: SharedState, app: AppHandle, stop: Arc, ) { while !stop.load(Ordering::SeqCst) { match listener.accept() { Ok((stream, peer)) => { let state = state.clone(); let app = app.clone(); let config = config.clone(); let label = peer.to_string(); thread::spawn(move || handle_raw_connection(stream, &config, &state, &app, label)); } Err(error) if error.kind() == ErrorKind::WouldBlock => { thread::sleep(Duration::from_millis(80)); } Err(error) => { let job = JobSummary { id: Uuid::new_v4().to_string(), source: "raw-proxy".into(), label: "listener".into(), bytes: 0, barcodes: vec![], pallet_print: None, captured_at: chrono::Utc::now().to_rfc3339(), forwarded: false, error: Some(format!("accept failed: {error}")), }; state.push_job(&app, job); thread::sleep(Duration::from_millis(500)); } } } } fn handle_raw_connection( mut source: TcpStream, config: &AppConfig, state: &SharedState, app: &AppHandle, label: String, ) { let _ = source.set_read_timeout(Some(Duration::from_millis(750))); let mut forward_error: Option = None; let mut destination = if config.forward_addr.trim().is_empty() { None } else { match config.forward_addr.parse() { Ok(addr) => match TcpStream::connect_timeout(&addr, Duration::from_secs(3)) { Ok(stream) => Some(stream), Err(error) => { forward_error = Some(format!("forward connect failed: {error}")); None } }, Err(error) => { forward_error = Some(format!("invalid forward address: {error}")); None } } }; let mut bytes = Vec::new(); let mut buffer = [0u8; 16 * 1024]; loop { match source.read(&mut buffer) { Ok(0) => break, Ok(read) => { let chunk = &buffer[..read]; bytes.extend_from_slice(chunk); if let Some(target) = destination.as_mut() { if let Err(error) = target.write_all(chunk) { forward_error = Some(format!("forward write failed: {error}")); destination = None; } } } Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => { if !bytes.is_empty() { break; } } Err(error) => { forward_error = Some(format!("capture read failed: {error}")); break; } } } if bytes.is_empty() && forward_error.is_none() { return; } let tcp_requested = !config.forward_addr.trim().is_empty(); let (forwarded, final_error) = if forward_error.is_some() { (false, forward_error) } else if tcp_requested { (true, None) } else if !config.forward_printer.trim().is_empty() { forward_to_system_printer(&config.forward_printer, &bytes) } else { (false, None) }; process_payload( state, app, "raw-proxy", label, bytes, forwarded, final_error, ); } #[cfg(target_os = "linux")] fn cups_cache_folder_loop( spool_dir: PathBuf, config: AppConfig, state: SharedState, app: AppHandle, stop: Arc, ) { while !stop.load(Ordering::SeqCst) && !spool_dir.is_dir() { thread::sleep(Duration::from_millis(500)); } if stop.load(Ordering::SeqCst) { return; } spool_folder_loop( config, state, app, stop, Some(spool_dir), "cups-original", true, ); } fn spool_folder_loop( config: AppConfig, state: SharedState, app: AppHandle, stop: Arc, spool_override: Option, source: &'static str, already_forwarded: bool, ) { 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) { let entries = match fs::read_dir(&spool_dir) { Ok(entries) => entries, Err(error) => { state.push_job( &app, JobSummary { id: Uuid::new_v4().to_string(), source: "spool-folder".into(), label: spool_dir.display().to_string(), bytes: 0, barcodes: vec![], pallet_print: None, captured_at: chrono::Utc::now().to_rfc3339(), forwarded: false, error: Some(format!("spool read failed: {error}")), }, ); thread::sleep(Duration::from_secs(1)); continue; } }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() || !is_supported_drop(&path) { continue; } let stable = entry .metadata() .ok() .and_then(|metadata| metadata.modified().ok()) .and_then(|modified| modified.elapsed().ok()) .map(|age| age >= Duration::from_millis(500)) .unwrap_or(false); if !stable { continue; } let label = path .file_name() .and_then(|value| value.to_str()) .unwrap_or("spool-job") .to_string(); let is_pdf = path .extension() .and_then(|value| value.to_str()) .map(|value| value.eq_ignore_ascii_case("pdf")) .unwrap_or(false); #[cfg(target_os = "linux")] if already_forwarded && is_pdf { process_captured_pdf( &path, &processed_dir, &label, &state, &app, &stop, ); continue; } match fs::read(&path) { Ok(bytes) => { let is_test_capture = already_forwarded && label.starts_with("test-"); let (forwarded, error) = if already_forwarded { (!is_test_capture, None) } else { forward_payload(&config, &bytes) }; process_payload( &state, &app, if is_test_capture { "cups-test" } else { source }, label.clone(), bytes, forwarded, error, ); let target = unique_processed_path(&processed_dir, &label); let _ = fs::rename(&path, target); } Err(error) => process_payload( &state, &app, source, label, vec![], false, Some(format!("spool file read failed: {error}")), ), } } thread::sleep(Duration::from_millis(350)); } } #[cfg(target_os = "linux")] fn process_captured_pdf( path: &std::path::Path, processed_dir: &std::path::Path, label: &str, state: &SharedState, app: &AppHandle, stop: &Arc, ) { let id = Uuid::new_v4().to_string(); let is_test_capture = label.starts_with("test-"); let source = if is_test_capture { "cups-test" } else { "cups-original" }; let forwarded = !is_test_capture; let bytes = fs::metadata(path) .map(|metadata| metadata.len().min(usize::MAX as u64) as usize) .unwrap_or(0); let target = unique_processed_path(processed_dir, label); if let Err(error) = fs::rename(path, &target) { state.push_job( app, JobSummary { id, source: source.into(), label: label.to_string(), bytes, barcodes: vec![], pallet_print: None, captured_at: chrono::Utc::now().to_rfc3339(), forwarded, error: Some(format!("archive captured PDF failed: {error}")), }, ); return; } let mut job = JobSummary { id: id.clone(), source: source.into(), label: label.to_string(), bytes, barcodes: vec![], pallet_print: None, captured_at: chrono::Utc::now().to_rfc3339(), forwarded, error: None, }; // Publish the job before any PDF rendering/decoding. A large document can // take time to inspect, but capture/print success should be visible // immediately and the UI must never wait for the whole document. state.push_job(app, job.clone()); let page_count = match pdf_page_count(&target) { Ok(count) => count, Err(error) => { job.error = Some(error); state.push_job(app, job); return; } }; for page in 1..=page_count { if stop.load(Ordering::SeqCst) { job.error = Some(format!( "PDF barcode decoding stopped after page {} of {}", page.saturating_sub(1), page_count )); state.push_job(app, job); return; } match decode_pdf_page(&target, page, &id) { Ok(found) => { let primary = found.first().cloned(); for barcode in found { if job.barcodes.iter().any(|existing| existing.format == barcode.format && existing.value == barcode.value ) { continue; } job.barcodes.push(barcode); } if job.pallet_print.is_none() { if let Some(barcode) = primary.or_else(|| job.barcodes.first().cloned()) { let text = extract_pdf_page_text(&target, page).unwrap_or_default(); let snapshot = parse_pallet_snapshot(&text, &barcode.value); let comparison = state.record_print_event( format!("{source}:{label}"), &id, &job.captured_at, snapshot, ); state.queue_print_scan( barcode.value, barcode.format, &id, label, &comparison, ); job.pallet_print = Some(comparison); } } // Upsert the same job after each page so barcode results can // appear incrementally without creating duplicate jobs. state.push_job(app, job.clone()); } Err(error) => { job.error = Some(format!( "PDF page {page}/{page_count} decode failed: {error}" )); state.push_job(app, job.clone()); // Page failures are non-fatal; keep processing subsequent pages. } } } } #[cfg(target_os = "linux")] fn pdf_page_count(path: &std::path::Path) -> Result { let output = Command::new("pdfinfo") .arg(path) .output() .map_err(|error| { if error.kind() == std::io::ErrorKind::NotFound { "PDF page decoding requires poppler-utils (pdfinfo/pdftoppm)".to_string() } else { format!("run pdfinfo: {error}") } })?; if !output.status.success() { return Err(format!( "pdfinfo failed: {}", String::from_utf8_lossy(&output.stderr).trim() )); } let stdout = String::from_utf8_lossy(&output.stdout); let pages = stdout .lines() .find_map(|line| { let (key, value) = line.split_once(':')?; if !key.trim().eq_ignore_ascii_case("pages") { return None; } value.trim().parse::().ok() }) .ok_or_else(|| "pdfinfo did not report a page count".to_string())?; if pages == 0 { return Err("captured PDF contains no pages".into()); } Ok(pages) } #[cfg(target_os = "linux")] fn extract_pdf_page_text(path: &std::path::Path, page: u32) -> Result { let output = Command::new("pdftotext") .args([ "-f", &page.to_string(), "-l", &page.to_string(), "-layout", ]) .arg(path) .arg("-") .output() .map_err(|error| { if error.kind() == std::io::ErrorKind::NotFound { "PDF text extraction requires poppler-utils (pdftotext)".to_string() } else { format!("run pdftotext: {error}") } })?; if !output.status.success() { return Err(format!( "pdftotext failed: {}", String::from_utf8_lossy(&output.stderr).trim() )); } Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } #[cfg(target_os = "linux")] fn decode_pdf_page( path: &std::path::Path, page: u32, job_id: &str, ) -> Result, String> { let prefix = std::env::temp_dir().join(format!( "fieldlens-{}-page-{}", job_id, page )); let image_path = prefix.with_extension("png"); let render = Command::new("pdftoppm") .args([ "-f", &page.to_string(), "-l", &page.to_string(), "-singlefile", "-png", "-r", "200", ]) .arg(path) .arg(&prefix) .output() .map_err(|error| { if error.kind() == std::io::ErrorKind::NotFound { "PDF page decoding requires poppler-utils (pdftoppm)".to_string() } else { format!("run pdftoppm: {error}") } })?; if !render.status.success() { let _ = fs::remove_file(&image_path); return Err(format!( "pdftoppm failed: {}", String::from_utf8_lossy(&render.stderr).trim() )); } let decoded = Command::new("zbarimg") .args(["--quiet", "--nodbus"]) .arg(&image_path) .output(); let _ = fs::remove_file(&image_path); let output = match decoded { Ok(output) => output, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { return Err("PDF barcode decoding requires zbar-tools (zbarimg)".into()) } Err(error) => return Err(format!("run zbarimg: {error}")), }; // zbarimg returns a non-zero status when no symbols are found. That is a // normal page result, not a print/capture failure. if output.stdout.is_empty() { return Ok(Vec::new()); } let stdout = String::from_utf8_lossy(&output.stdout); let mut found = Vec::new(); for line in stdout.lines() { let Some((format, value)) = line.split_once(':') else { continue; }; let value = value.trim(); if value.is_empty() { continue; } let format = match format.trim().to_ascii_uppercase().as_str() { "CODE-128" => "CODE_128", "CODE-39" => "CODE_39", "EAN-13" => "EAN_13", "EAN-8" => "EAN_8", "UPC-A" => "UPC_A", "UPC-E" => "UPC_E", "I2/5" => "ITF", "QR-CODE" => "QR_CODE", other => other, } .to_string(); let barcode = crate::model::DecodedBarcode { format, value: value.to_string(), }; if !found.iter().any(|existing: &crate::model::DecodedBarcode| existing.format == barcode.format && existing.value == barcode.value ) { found.push(barcode); } } Ok(found) } fn is_supported_drop(path: &std::path::Path) -> bool { matches!( path.extension() .and_then(|value| value.to_str()) .map(|value| value.to_ascii_lowercase()) .as_deref(), Some("zpl" | "raw" | "prn" | "spl" | "pdf" | "ps" | "png" | "jpg" | "jpeg") ) } fn unique_processed_path(dir: &std::path::Path, filename: &str) -> PathBuf { let safe = filename.replace('/', "_").replace('\\', "_"); dir.join(format!( "{}-{}-{}", chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ"), Uuid::new_v4(), safe )) } fn forward_payload(config: &AppConfig, bytes: &[u8]) -> (bool, Option) { if !config.forward_addr.trim().is_empty() { return forward_all(&config.forward_addr, bytes); } if !config.forward_printer.trim().is_empty() { return forward_to_system_printer(&config.forward_printer, bytes); } (false, None) } fn forward_all(address: &str, bytes: &[u8]) -> (bool, Option) { if address.trim().is_empty() { return (false, None); } let socket = match address.parse() { Ok(value) => value, Err(error) => return (false, Some(format!("invalid forward address: {error}"))), }; match TcpStream::connect_timeout(&socket, Duration::from_secs(3)) { Ok(mut stream) => match stream.write_all(bytes) { Ok(()) => (true, None), Err(error) => (false, Some(format!("forward write failed: {error}"))), }, Err(error) => (false, Some(format!("forward connect failed: {error}"))), } } #[cfg(target_os = "linux")] fn forward_to_system_printer(printer: &str, bytes: &[u8]) -> (bool, Option) { let mut child = match Command::new("lp") .args(["-d", printer, "-o", "raw", "-t", "FieldLens Print"]) .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::piped()) .spawn() { Ok(child) => child, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { return ( false, Some("CUPS client tools are missing; install cups-client".into()), ) } Err(error) => { return (false, Some(format!("forward to CUPS printer failed: {error}"))) } }; if let Some(mut stdin) = child.stdin.take() { if let Err(error) = stdin.write_all(bytes) { let _ = child.kill(); return (false, Some(format!("forward to CUPS printer failed: {error}"))); } } match child.wait_with_output() { Ok(output) if output.status.success() => (true, None), Ok(output) => ( false, Some(format!( "CUPS forward failed: {}", String::from_utf8_lossy(&output.stderr).trim() )), ), Err(error) => (false, Some(format!("CUPS forward failed: {error}"))), } } #[cfg(not(target_os = "linux"))] fn forward_to_system_printer(printer: &str, _bytes: &[u8]) -> (bool, Option) { ( false, Some(format!( "installed-printer RAW forwarding is not available for '{printer}' without a network RAW/TCP address" )), ) } fn process_payload( state: &SharedState, app: &AppHandle, source: &str, label: String, bytes: Vec, forwarded: bool, error: Option, ) { let id = Uuid::new_v4().to_string(); let (barcodes, parse_error) = match std::panic::catch_unwind(|| extract_barcodes(&bytes)) { Ok(found) => (found, None), Err(_) => ( Vec::new(), Some("barcode parser failed; print bytes were still captured and forwarded".to_string()), ), }; let error = error.or(parse_error); let captured_at = chrono::Utc::now().to_rfc3339(); let pallet_print = barcodes.first().map(|barcode| { let text = String::from_utf8_lossy(&bytes); let snapshot = parse_pallet_snapshot(&text, &barcode.value); let comparison = state.record_print_event( format!("{source}:{id}"), &id, &captured_at, snapshot, ); state.queue_print_scan( barcode.value.clone(), barcode.format.clone(), &id, &label, &comparison, ); comparison }); state.push_job( app, JobSummary { id, source: source.into(), label, bytes: bytes.len(), barcodes, pallet_print, captured_at, forwarded, error, }, ); }