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
866 lines
26 KiB
Rust
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,
|
|
},
|
|
);
|
|
}
|