From 73017257915504db7bf968b112801be608e1012f Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 23 Sep 2026 23:59:33 +0000 Subject: [PATCH] feat: add system printer setup and forwarding selection --- src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 26 +++ src-tauri/src/model.rs | 3 + src-tauri/src/printers.rs | 335 ++++++++++++++++++++++++++++++++++++++ src/main.ts | 132 ++++++++++++++- src/styles.css | 7 + 6 files changed, 501 insertions(+), 4 deletions(-) create mode 100644 src-tauri/src/printers.rs diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 416ec3a..2b17c8b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fieldlens-print" -version = "0.1.1" +version = "0.1.2" description = "FieldLens print-stream barcode capture companion" authors = ["Arrowstack"] edition = "2021" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3ad938e..25242e9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod capture; mod fieldlens; mod model; +mod printers; mod state; mod zpl; @@ -9,6 +10,7 @@ use tauri::{AppHandle, Manager, State}; use capture::CaptureRuntime; use model::{AppConfig, CaptureStatus, JobSummary, LoginPoll, LoginStart, SyncResult}; +use printers::{SystemPrinter, VirtualPrinterStatus}; use state::SharedState; struct AppState { @@ -64,6 +66,26 @@ fn stop_capture(state: State<'_, AppState>) { } } +#[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()) @@ -101,6 +123,10 @@ pub fn run() { recent_jobs, start_capture, stop_capture, + list_system_printers, + get_virtual_printer_status, + install_virtual_printer, + remove_virtual_printer, begin_login, poll_login, sync_pending diff --git a/src-tauri/src/model.rs b/src-tauri/src/model.rs index 71913a5..c0ac507 100644 --- a/src-tauri/src/model.rs +++ b/src-tauri/src/model.rs @@ -22,6 +22,8 @@ pub struct AppConfig { pub mode: CaptureMode, pub listen_addr: String, pub forward_addr: String, + #[serde(default)] + pub forward_printer: String, pub spool_dir: String, pub api_base_url: String, pub issuer: String, @@ -37,6 +39,7 @@ impl Default for AppConfig { mode: CaptureMode::RawProxy, listen_addr: "127.0.0.1:9100".into(), forward_addr: String::new(), + forward_printer: String::new(), spool_dir: String::new(), api_base_url: "https://fieldlens.arrowstack.dev".into(), issuer: "https://auth.arrowstack.dev/realms/dev".into(), diff --git a/src-tauri/src/printers.rs b/src-tauri/src/printers.rs new file mode 100644 index 0000000..26e4dde --- /dev/null +++ b/src-tauri/src/printers.rs @@ -0,0 +1,335 @@ +use std::{ + fs, + process::Command, +}; + +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const VIRTUAL_PRINTER_NAME: &str = "FieldLens Print"; +const WINDOWS_PORT_NAME: &str = "FieldLensPrint-9100"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SystemPrinter { + pub name: String, + pub driver: String, + pub port: String, + pub address: Option, + pub is_virtual: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VirtualPrinterStatus { + pub installed: bool, + pub name: String, + pub detail: String, +} + +pub fn list_system_printers() -> Result> { + #[cfg(target_os = "windows")] + { + return list_windows_printers(); + } + + #[cfg(target_os = "linux")] + { + return list_linux_printers(); + } + + #[allow(unreachable_code)] + Ok(Vec::new()) +} + +pub fn virtual_printer_status() -> Result { + let installed = list_system_printers()? + .iter() + .any(|printer| printer.name.eq_ignore_ascii_case(VIRTUAL_PRINTER_NAME)); + + Ok(VirtualPrinterStatus { + installed, + name: VIRTUAL_PRINTER_NAME.into(), + detail: if installed { + "Ready in the system print dialog. Keep RAW capture running while printing.".into() + } else { + "Not installed yet. Choose the physical destination printer, then create it.".into() + }, + }) +} + +pub fn install_virtual_printer(target_printer: &str) -> Result { + let target_printer = target_printer.trim(); + if target_printer.is_empty() { + return Err(anyhow!("choose the physical printer to forward to first")); + } + if target_printer.eq_ignore_ascii_case(VIRTUAL_PRINTER_NAME) { + return Err(anyhow!("FieldLens Print cannot forward to itself")); + } + + #[cfg(target_os = "windows")] + install_windows_virtual_printer(target_printer)?; + + #[cfg(target_os = "linux")] + install_linux_virtual_printer()?; + + virtual_printer_status() +} + +pub fn remove_virtual_printer() -> Result { + #[cfg(target_os = "windows")] + remove_windows_virtual_printer()?; + + #[cfg(target_os = "linux")] + remove_linux_virtual_printer()?; + + virtual_printer_status() +} + +#[cfg(target_os = "windows")] +fn list_windows_printers() -> Result> { + let script = r#" +$items = @( + Get-Printer | ForEach-Object { + $printer = $_ + $port = Get-PrinterPort -Name $printer.PortName -ErrorAction SilentlyContinue + $address = $null + if ($null -ne $port -and $port.PrinterHostAddress) { + $number = if ($port.PortNumber) { $port.PortNumber } else { 9100 } + $address = "$($port.PrinterHostAddress):$number" + } + [pscustomobject]@{ + name = $printer.Name + driver = $printer.DriverName + port = $printer.PortName + address = $address + isVirtual = ($printer.Name -eq 'FieldLens Print') + } + } +) +$items | ConvertTo-Json -Compress +"#; + + let output = Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + .output() + .context("query Windows printers")?; + + if !output.status.success() { + return Err(anyhow!( + "Windows printer query failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let value: Value = serde_json::from_str(stdout.trim()).context("decode Windows printer list")?; + let values = match value { + Value::Array(values) => values, + Value::Null => vec![], + value => vec![value], + }; + + let mut printers = Vec::new(); + for value in values { + let printer: SystemPrinter = serde_json::from_value(value)?; + printers.push(printer); + } + printers.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + Ok(printers) +} + +#[cfg(target_os = "windows")] +fn install_windows_virtual_printer(target_printer: &str) -> Result<()> { + let target = ps_quote(target_printer); + let script_path = std::env::temp_dir().join(format!( + "fieldlens-printer-setup-{}.ps1", + uuid::Uuid::new_v4() + )); + + let script = format!( + r#"param([string]$TargetPrinter) +$ErrorActionPreference = 'Stop' +$target = Get-Printer -Name $TargetPrinter -ErrorAction Stop +$portName = '{port}' +$queueName = '{queue}' + +if (-not (Get-PrinterPort -Name $portName -ErrorAction SilentlyContinue)) {{ + Add-PrinterPort -Name $portName -PrinterHostAddress '127.0.0.1' -PortNumber 9100 +}} + +$existing = Get-Printer -Name $queueName -ErrorAction SilentlyContinue +if ($existing) {{ + Set-Printer -Name $queueName -DriverName $target.DriverName -PortName $portName +}} else {{ + Add-Printer -Name $queueName -DriverName $target.DriverName -PortName $portName +}} +"#, + port = WINDOWS_PORT_NAME, + queue = VIRTUAL_PRINTER_NAME, + ); + + fs::write(&script_path, script).context("write Windows printer setup script")?; + + let path = ps_quote(&script_path.to_string_lossy()); + let elevated = format!( + "$p = Start-Process powershell -Verb RunAs -Wait -PassThru -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{path}','-TargetPrinter','{target}'); exit $p.ExitCode" + ); + + let status = Command::new("powershell") + .args(["-NoProfile", "-Command", &elevated]) + .status() + .context("launch elevated Windows printer setup")?; + + let _ = fs::remove_file(&script_path); + + if !status.success() { + return Err(anyhow!( + "printer setup was cancelled or failed; Windows administrator approval may be required" + )); + } + + Ok(()) +} + +#[cfg(target_os = "windows")] +fn remove_windows_virtual_printer() -> Result<()> { + let script = format!( + r#"$ErrorActionPreference = 'Stop' +$queueName = '{queue}' +$portName = '{port}' +if (Get-Printer -Name $queueName -ErrorAction SilentlyContinue) {{ + Remove-Printer -Name $queueName +}} +if (Get-PrinterPort -Name $portName -ErrorAction SilentlyContinue) {{ + Remove-PrinterPort -Name $portName +}} +"#, + queue = VIRTUAL_PRINTER_NAME, + port = WINDOWS_PORT_NAME, + ); + + let script_path = std::env::temp_dir().join(format!( + "fieldlens-printer-remove-{}.ps1", + uuid::Uuid::new_v4() + )); + fs::write(&script_path, script)?; + let path = ps_quote(&script_path.to_string_lossy()); + let elevated = format!( + "$p = Start-Process powershell -Verb RunAs -Wait -PassThru -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{path}'); exit $p.ExitCode" + ); + let status = Command::new("powershell") + .args(["-NoProfile", "-Command", &elevated]) + .status()?; + let _ = fs::remove_file(&script_path); + + if !status.success() { + return Err(anyhow!("removing FieldLens Print was cancelled or failed")); + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn ps_quote(value: &str) -> String { + value.replace(''', "''") +} + +#[cfg(target_os = "linux")] +fn list_linux_printers() -> Result> { + let output = Command::new("lpstat") + .arg("-v") + .output() + .context("query CUPS printers with lpstat")?; + + if !output.status.success() { + return Err(anyhow!( + "CUPS printer query failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + let mut printers = Vec::new(); + for line in String::from_utf8_lossy(&output.stdout).lines() { + let Some(rest) = line.strip_prefix("device for ") else { + continue; + }; + let Some((name, uri)) = rest.split_once(": ") else { + continue; + }; + let address = socket_uri_to_address(uri); + printers.push(SystemPrinter { + name: name.to_string(), + driver: String::new(), + port: uri.to_string(), + address, + is_virtual: name.eq_ignore_ascii_case(VIRTUAL_PRINTER_NAME), + }); + } + printers.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + Ok(printers) +} + +#[cfg(target_os = "linux")] +fn socket_uri_to_address(uri: &str) -> Option { + let value = uri.strip_prefix("socket://")?; + let authority = value.split('/').next().unwrap_or(value); + if authority.contains(':') { + Some(authority.to_string()) + } else if authority.is_empty() { + None + } else { + Some(format!("{authority}:9100")) + } +} + +#[cfg(target_os = "linux")] +fn install_linux_virtual_printer() -> Result<()> { + let args = [ + "-p", + "FieldLens-Print", + "-E", + "-v", + "socket://127.0.0.1:9100", + "-m", + "raw", + ]; + + let direct = Command::new("lpadmin").args(args).status(); + if matches!(direct, Ok(status) if status.success()) { + return Ok(()); + } + + let status = Command::new("pkexec") + .arg("lpadmin") + .args(args) + .status() + .context("run elevated CUPS printer setup")?; + + if !status.success() { + return Err(anyhow!( + "CUPS printer setup failed; administrator approval may be required" + )); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn remove_linux_virtual_printer() -> Result<()> { + let direct = Command::new("lpadmin") + .args(["-x", "FieldLens-Print"]) + .status(); + if matches!(direct, Ok(status) if status.success()) { + return Ok(()); + } + + let status = Command::new("pkexec") + .args(["lpadmin", "-x", "FieldLens-Print"]) + .status() + .context("run elevated CUPS printer removal")?; + + if !status.success() { + return Err(anyhow!("removing FieldLens Print from CUPS failed")); + } + Ok(()) +} diff --git a/src/main.ts b/src/main.ts index 9572a1d..a876e3b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,6 +12,7 @@ type AppConfig = { mode: CaptureMode listenAddr: string forwardAddr: string + forwardPrinter: string spoolDir: string apiBaseUrl: string issuer: string @@ -21,6 +22,20 @@ type AppConfig = { deviceId?: string | null } +type SystemPrinter = { + name: string + driver: string + port: string + address?: string | null + isVirtual: boolean +} + +type VirtualPrinterStatus = { + installed: boolean + name: string + detail: string +} + type Barcode = { format: string; value: string } type Job = { @@ -58,6 +73,8 @@ let currentVersion = '0.1.1' let bundleType: BundleType | null = null let availableUpdate: Update | null = null let updateBusy = false +let systemPrinters: SystemPrinter[] = [] +let virtualPrinterStatus: VirtualPrinterStatus | null = null app.innerHTML = `
@@ -84,15 +101,33 @@ app.innerHTML = ` -
+
+
+ System printer + Checking whether FieldLens Print is installed… + Creates a printer named FieldLens Print in the normal OS print dialog and routes it into the local RAW listener. +
+
+ + + +
+
@@ -160,6 +195,7 @@ function readForm(): AppConfig { mode: el('mode').value as CaptureMode, listenAddr: el('listenAddr').value.trim(), forwardAddr: el('forwardAddr').value.trim(), + forwardPrinter: el('forwardPrinter').value, spoolDir: el('spoolDir').value.trim(), apiBaseUrl: el('apiBaseUrl').value.trim(), issuer: el('issuer').value.trim(), @@ -172,6 +208,7 @@ function writeForm(next: AppConfig) { el('mode').value = next.mode el('listenAddr').value = next.listenAddr el('forwardAddr').value = next.forwardAddr + el('forwardPrinter').value = next.forwardPrinter || '' el('spoolDir').value = next.spoolDir el('apiBaseUrl').value = next.apiBaseUrl el('issuer').value = next.issuer @@ -179,6 +216,50 @@ function writeForm(next: AppConfig) { el('stationId').value = next.stationId } +function renderPrinters() { + const select = el('forwardPrinter') + const current = config?.forwardPrinter || select.value + const options = systemPrinters.filter((printer) => !printer.isVirtual) + + select.innerHTML = '' + options.map((printer) => { + const suffix = printer.address ? ` · ${printer.address}` : printer.port ? ` · ${printer.port}` : '' + return `` + }).join('') + + if (options.some((printer) => printer.name === current)) select.value = current + + const selected = options.find((printer) => printer.name === select.value) + const help = el('forward-printer-help') + if (selected?.address) { + help.textContent = `Detected RAW network endpoint: ${selected.address}` + } else if (selected) { + help.textContent = 'No RAW/TCP address was exposed for this queue. Enter the printer IP:9100 manually.' + } else { + help.textContent = 'Select the physical printer that should receive the original print bytes.' + } + + const virtualState = el('virtual-printer-state') + virtualState.textContent = virtualPrinterStatus?.detail ?? 'Printer status unavailable.' + virtualState.classList.toggle('ready', Boolean(virtualPrinterStatus?.installed)) + el('remove-printer').disabled = !virtualPrinterStatus?.installed +} + +async function refreshPrinters() { + const state = el('virtual-printer-state') + state.textContent = 'Refreshing installed printers…' + try { + const [printers, printerStatus] = await Promise.all([ + invoke('list_system_printers'), + invoke('get_virtual_printer_status'), + ]) + systemPrinters = printers + virtualPrinterStatus = printerStatus + renderPrinters() + } catch (error) { + state.textContent = `Printer discovery failed: ${String(error)}` + } +} + function render() { const pill = el('status-pill') pill.classList.toggle('live', status?.running ?? false) @@ -208,6 +289,51 @@ async function refreshStatus() { render() } +el('forwardPrinter').addEventListener('change', () => { + const selected = systemPrinters.find((printer) => printer.name === el('forwardPrinter').value) + if (selected?.address) el('forwardAddr').value = selected.address + renderPrinters() +}) + +el('refresh-printers').addEventListener('click', () => { void refreshPrinters() }) + +el('install-printer').addEventListener('click', async () => { + const button = el('install-printer') + const state = el('virtual-printer-state') + const targetPrinter = el('forwardPrinter').value + if (!targetPrinter) { + state.textContent = 'Choose the physical destination printer first.' + return + } + + button.disabled = true + state.textContent = 'Creating FieldLens Print… administrator approval may appear.' + try { + virtualPrinterStatus = await invoke('install_virtual_printer', { targetPrinter }) + config = await invoke('save_config', { config: readForm() }) + await refreshPrinters() + } catch (error) { + state.textContent = `Printer setup failed: ${String(error)}` + } finally { + button.disabled = false + } +}) + +el('remove-printer').addEventListener('click', async () => { + const button = el('remove-printer') + const state = el('virtual-printer-state') + button.disabled = true + state.textContent = 'Removing FieldLens Print…' + try { + virtualPrinterStatus = await invoke('remove_virtual_printer') + await refreshPrinters() + } catch (error) { + state.textContent = `Printer removal failed: ${String(error)}` + } finally { + button.disabled = false + } +}) + el('save').addEventListener('click', async () => { config = await invoke('save_config', { config: readForm() }) writeForm(config) @@ -352,7 +478,7 @@ await listen('capture-job', (event) => { config = await invoke('get_config') writeForm(config) -await refreshStatus() +await Promise.all([refreshStatus(), refreshPrinters()]) currentVersion = await getVersion() bundleType = await getBundleType().catch(() => null) diff --git a/src/styles.css b/src/styles.css index 1b22035..28de579 100644 --- a/src/styles.css +++ b/src/styles.css @@ -64,3 +64,10 @@ input:focus, select:focus { border-color: #38bdf8; box-shadow: 0 0 0 3px #38bdf8 .update-progress { height: 5px; margin-top: 12px; overflow: hidden; border-radius: 999px; background: #1e293b; } .update-progress span { display: block; width: 0; height: 100%; border-radius: inherit; background: #38bdf8; transition: width .16s ease; } .btn:disabled { cursor: wait; opacity: .55; } + +.printer-setup { display: grid; gap: 10px; margin-top: 15px; padding: 13px; border: 1px solid #263449; border-radius: 12px; background: #0b1220aa; } +.printer-setup > div:first-child { display: grid; gap: 4px; } +.printer-setup strong { font-size: 12px; } +.printer-setup small { line-height: 1.45; } +.printer-actions { margin-top: 4px; } +#virtual-printer-state.ready { color: #86efac; }