Admin 96939380b0
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
fix(windows): gate Linux PDF capture path
2026-09-25 22:23:20 +00:00

866 lines
26 KiB
Rust

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<AtomicBool>,
handles: Vec<JoinHandle<()>>,
}
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<CaptureRuntime> {
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<AtomicBool>) {
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<AtomicBool>,
) {
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<String> = 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<AtomicBool>,
) {
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<AtomicBool>,
spool_override: Option<PathBuf>,
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<AtomicBool>,
) {
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<u32, String> {
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::<u32>().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<String, String> {
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<Vec<crate::model::DecodedBarcode>, 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<String>) {
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<String>) {
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<String>) {
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<String>) {
(
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<u8>,
forwarded: bool,
error: Option<String>,
) {
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,
},
);
}