mod capture; mod fieldlens; mod model; mod pallet; mod printers; mod state; mod updates; 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}; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; use tauri::{AppHandle, Manager, State}; use capture::CaptureRuntime; use model::{AppConfig, AuthStatus, CaptureStatus, DebugCheck, DebugSnapshot, JobSummary, LoginPoll, LoginStart, RecentCapture, SyncResult}; use printers::{SystemPrinter, VirtualPrinterStatus}; use state::SharedState; struct AppState { shared: SharedState, runtime: Mutex>, } #[tauri::command] fn get_config(state: State<'_, AppState>) -> AppConfig { state.shared.config.lock().clone() } #[tauri::command] fn save_config(config: AppConfig, state: State<'_, AppState>) -> Result { state .shared .replace_config(config) .map_err(|error| error.to_string()) } #[tauri::command] fn get_status(state: State<'_, AppState>) -> CaptureStatus { CaptureStatus { running: state.runtime.lock().is_some(), mode: state.shared.config.lock().mode.clone(), pending: state.shared.pending_count(), jobs: state.shared.jobs.lock().len(), } } #[tauri::command] fn recent_jobs(state: State<'_, AppState>) -> Vec { state.shared.jobs.lock().iter().cloned().collect() } #[tauri::command] fn start_capture(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> { let mut runtime = state.runtime.lock(); if runtime.is_some() { return Ok(()); } let config = state.shared.config.lock().clone(); let next = capture::start(config, state.shared.clone(), app).map_err(|error| error.to_string())?; *runtime = Some(next); Ok(()) } #[tauri::command] fn stop_capture(state: State<'_, AppState>) { if let Some(runtime) = state.runtime.lock().take() { runtime.stop(); } } #[tauri::command] fn list_system_printers() -> Result, String> { printers::list_system_printers().map_err(|error| error.to_string()) } #[tauri::command] fn get_virtual_printer_status() -> Result { printers::virtual_printer_status().map_err(|error| error.to_string()) } #[tauri::command] fn install_virtual_printer(target_printer: String) -> Result { printers::install_virtual_printer(&target_printer).map_err(|error| error.to_string()) } #[tauri::command] fn remove_virtual_printer() -> Result { printers::remove_virtual_printer().map_err(|error| error.to_string()) } #[tauri::command] fn begin_login(state: State<'_, AppState>) -> Result { fieldlens::begin_login(&state.shared).map_err(|error| error.to_string()) } #[tauri::command] fn poll_login(state: State<'_, AppState>) -> Result { fieldlens::poll_login(&state.shared).map_err(|error| error.to_string()) } #[tauri::command] fn get_auth_status(state: State<'_, AppState>) -> Result { fieldlens::auth_status(&state.shared).map_err(|error| error.to_string()) } #[tauri::command] fn logout(state: State<'_, AppState>) -> Result<(), String> { fieldlens::logout(&state.shared).map_err(|error| error.to_string()) } #[tauri::command] async fn install_linux_update() -> Result { tauri::async_runtime::spawn_blocking(updates::install_linux_update) .await .map_err(|error| error.to_string())? .map_err(|error| error.to_string()) } #[cfg(target_os = "linux")] fn build_self_test_pdf() -> Vec { let objects = [ "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n".to_string(), "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n".to_string(), "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n".to_string(), { let stream = "BT /F1 18 Tf 72 720 Td (FieldLens capture self-test) Tj ET\n"; format!("4 0 obj\n<< /Length {} >>\nstream\n{}endstream\nendobj\n", stream.len(), stream) }, "5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n".to_string(), ]; let mut pdf = b"%PDF-1.4\n".to_vec(); let mut offsets = Vec::new(); for object in &objects { offsets.push(pdf.len()); pdf.extend_from_slice(object.as_bytes()); } let xref = pdf.len(); pdf.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes()); pdf.extend_from_slice(b"0000000000 65535 f \n"); for offset in offsets { pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); } pdf.extend_from_slice( format!( "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n", objects.len() + 1, xref ) .as_bytes(), ); pdf } fn debug_command_check(name: &str) -> DebugCheck { #[cfg(target_os = "windows")] let result = { const CREATE_NO_WINDOW: u32 = 0x08000000; let mut command = std::process::Command::new("where.exe"); command.creation_flags(CREATE_NO_WINDOW); command.arg(name).output() }; #[cfg(not(target_os = "windows"))] let result = std::process::Command::new("sh") .args(["-lc", &format!("command -v {name}")]) .output(); match result { Ok(output) if output.status.success() => DebugCheck { name: name.into(), ok: true, detail: String::from_utf8_lossy(&output.stdout) .lines() .next() .unwrap_or("available") .trim() .to_string(), }, Ok(_) => DebugCheck { name: name.into(), ok: false, detail: "not found".into(), }, Err(error) => DebugCheck { name: name.into(), ok: false, detail: error.to_string(), }, } } #[tauri::command] fn get_debug_snapshot(state: State<'_, AppState>) -> Result { let config = state.shared.config.lock().clone(); let jobs = state.shared.jobs.lock(); let installed_printers = printers::list_system_printers() .map(|items| items.len()) .unwrap_or(0); let virtual_printer = printers::virtual_printer_status() .map(|value| value.detail) .unwrap_or_else(|error| format!("status error: {error}")); #[cfg(target_os = "linux")] let capture_storage = "/var/lib/fieldlens-print/capture".to_string(); #[cfg(not(target_os = "linux"))] let capture_storage = config.spool_dir.clone(); #[cfg(target_os = "linux")] let checks = vec![ debug_command_check("lp"), debug_command_check("pdfinfo"), debug_command_check("pdftoppm"), debug_command_check("zbarimg"), debug_command_check("pkexec"), ]; #[cfg(target_os = "windows")] let checks = vec![ debug_command_check("powershell.exe"), debug_command_check("rundll32.exe"), ]; #[cfg(not(any(target_os = "linux", target_os = "windows")))] let checks = Vec::new(); let recent_errors = jobs .iter() .filter_map(|job| { job.error .as_ref() .map(|error| format!("{}: {}", job.label, error)) }) .take(5) .collect(); Ok(DebugSnapshot { platform: std::env::consts::OS.into(), arch: std::env::consts::ARCH.into(), app_version: env!("CARGO_PKG_VERSION").into(), data_dir: state.shared.data_dir.to_string_lossy().into_owned(), capture_storage, capture_running: state.runtime.lock().is_some(), capture_mode: config.mode.clone(), pending: state.shared.pending_count(), jobs: jobs.len(), signed_in: state.shared.auth.lock().refresh_token.is_some(), forward_printer: config.forward_printer, listen_addr: config.listen_addr, forward_addr: config.forward_addr, client_id: config.client_id, virtual_printer, installed_printers, recent_errors, checks, }) } #[tauri::command] fn run_capture_self_test() -> Result { #[cfg(target_os = "linux")] { let path = std::env::temp_dir().join(format!( "fieldlens-capture-self-test-{}.pdf", uuid::Uuid::new_v4() )); 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"]) .arg(&path) .output() .map_err(|error| error.to_string()); let _ = std::fs::remove_file(&path); let output = output?; if !output.status.success() { return Err(format!( "CUPS self-test submission failed: {}", String::from_utf8_lossy(&output.stderr).trim() )); } 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 }); } #[cfg(not(target_os = "linux"))] { Err("Capture self-test is currently available on Linux only.".into()) } } #[tauri::command] fn sync_pending(state: State<'_, AppState>) -> Result { fieldlens::sync_pending(&state.shared).map_err(|error| error.to_string()) } #[tauri::command] fn load_ocr_profile(state: State<'_, AppState>) -> Result, String> { let path = state.shared.data_dir.join("ocr-profile.json"); if !path.is_file() { return Ok(None); } let bytes = fs::read(&path).map_err(|error| error.to_string())?; serde_json::from_slice(&bytes) .map(Some) .map_err(|error| format!("read OCR profile: {error}")) } #[tauri::command] fn save_ocr_profile(profile: Value, state: State<'_, AppState>) -> Result { if profile.get("schemaVersion").and_then(Value::as_u64) != Some(1) { return Err("unsupported OCR profile schema".into()); } let path = state.shared.data_dir.join("ocr-profile.json"); let temp = state.shared.data_dir.join("ocr-profile.json.tmp"); let bytes = serde_json::to_vec_pretty(&profile).map_err(|error| error.to_string())?; fs::write(&temp, bytes).map_err(|error| error.to_string())?; if path.exists() { fs::remove_file(&path).map_err(|error| error.to_string())?; } fs::rename(&temp, &path).map_err(|error| error.to_string())?; Ok(profile) } fn newest_supported_capture_in(dir: &Path) -> Option<(PathBuf, SystemTime)> { let entries = fs::read_dir(dir).ok()?; entries .flatten() .filter_map(|entry| { let path = entry.path(); if !path.is_file() { return None; } let extension = path.extension()?.to_str()?.to_ascii_lowercase(); if !matches!(extension.as_str(), "pdf" | "png" | "jpg" | "jpeg" | "webp") { return None; } let modified = entry.metadata().ok()?.modified().ok()?; Some((path, modified)) }) .max_by_key(|(_, modified)| *modified) } #[tauri::command] fn get_recent_capture(state: State<'_, AppState>) -> Result { let config = state.shared.config.lock().clone(); let mut directories = Vec::::new(); if !config.spool_dir.trim().is_empty() { let spool = PathBuf::from(&config.spool_dir); directories.push(spool.join("processed")); directories.push(spool); } directories.push(state.shared.data_dir.join("capture").join("processed")); #[cfg(target_os = "linux")] directories.push(PathBuf::from("/var/lib/fieldlens-print/capture/processed")); let latest = directories .iter() .filter_map(|dir| newest_supported_capture_in(dir)) .max_by_key(|(_, modified)| *modified) .ok_or_else(|| "no recent PDF or image capture was found".to_string())?; let metadata = fs::metadata(&latest.0).map_err(|error| error.to_string())?; if metadata.len() > 30 * 1024 * 1024 { return Err("recent capture is larger than the 30 MB refinement limit".into()); } let bytes = fs::read(&latest.0).map_err(|error| error.to_string())?; let extension = latest .0 .extension() .and_then(|value| value.to_str()) .unwrap_or("") .to_ascii_lowercase(); let mime = match extension.as_str() { "pdf" => "application/pdf", "png" => "image/png", "webp" => "image/webp", _ => "image/jpeg", } .to_string(); Ok(RecentCapture { name: latest .0 .file_name() .and_then(|value| value.to_str()) .unwrap_or("recent-capture") .to_string(), mime, data_base64: BASE64_STANDARD.encode(bytes), }) } pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .setup(|app| { let data_dir = app.path().app_data_dir()?; let shared = SharedState::load(data_dir) .map_err(|error| std::io::Error::other(error.to_string()))?; app.manage(AppState { shared, runtime: Mutex::new(None), }); Ok(()) }) .invoke_handler(tauri::generate_handler![ get_config, save_config, get_status, recent_jobs, start_capture, stop_capture, list_system_printers, get_virtual_printer_status, install_virtual_printer, remove_virtual_printer, get_debug_snapshot, run_capture_self_test, begin_login, poll_login, get_auth_status, logout, install_linux_update, sync_pending, load_ocr_profile, save_ocr_profile, get_recent_capture ]) .run(tauri::generate_context!()) .expect("error while running FieldLens Print"); }