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}, state::SharedState, zpl::extract_barcodes, }; #[cfg(target_os = "linux")] const SYSTEM_CUPS_SPOOL_DIR: &str = "/var/spool/fieldlens-print"; 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); 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); 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() { return Err(anyhow!("no capture route enabled")); } Ok(CaptureRuntime { stop, handles }) } 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![], 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, ); } 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![], 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(); match fs::read(&path) { Ok(bytes) => { let (forwarded, error) = if already_forwarded { (true, None) } else { forward_payload(&config, &bytes) }; process_payload( &state, &app, 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)); } } 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); for barcode in &barcodes { state.queue_scan( barcode.value.clone(), barcode.format.clone(), "print".into(), &id, &label, ); } state.push_job( app, JobSummary { id, source: source.into(), label, bytes: bytes.len(), barcodes, captured_at: chrono::Utc::now().to_rfc3339(), forwarded, error, }, ); }