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
451 lines
14 KiB
Rust
451 lines
14 KiB
Rust
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<Option<CaptureRuntime>>,
|
|
}
|
|
|
|
#[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<AppConfig, String> {
|
|
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<JobSummary> {
|
|
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<Vec<SystemPrinter>, String> {
|
|
printers::list_system_printers().map_err(|error| error.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn get_virtual_printer_status() -> Result<VirtualPrinterStatus, String> {
|
|
printers::virtual_printer_status().map_err(|error| error.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn install_virtual_printer(target_printer: String) -> Result<VirtualPrinterStatus, String> {
|
|
printers::install_virtual_printer(&target_printer).map_err(|error| error.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn remove_virtual_printer() -> Result<VirtualPrinterStatus, String> {
|
|
printers::remove_virtual_printer().map_err(|error| error.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn begin_login(state: State<'_, AppState>) -> Result<LoginStart, String> {
|
|
fieldlens::begin_login(&state.shared).map_err(|error| error.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn poll_login(state: State<'_, AppState>) -> Result<LoginPoll, String> {
|
|
fieldlens::poll_login(&state.shared).map_err(|error| error.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn get_auth_status(state: State<'_, AppState>) -> Result<AuthStatus, String> {
|
|
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<updates::UpdateInstallResult, String> {
|
|
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<u8> {
|
|
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<DebugSnapshot, String> {
|
|
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<String, String> {
|
|
#[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<SyncResult, String> {
|
|
fieldlens::sync_pending(&state.shared).map_err(|error| error.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn load_ocr_profile(state: State<'_, AppState>) -> Result<Option<Value>, 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<Value, String> {
|
|
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<RecentCapture, String> {
|
|
let config = state.shared.config.lock().clone();
|
|
let mut directories = Vec::<PathBuf>::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");
|
|
}
|