From 3e17e1b55be4cbf592af3bdeb8b9800086dd162d Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 26 Sep 2026 06:15:54 +0000 Subject: [PATCH] fix(windows): forward captures through installed printer queue --- PRINT_CAPTURE_ARCHITECTURE.md | 12 +++- src-tauri/Cargo.toml | 4 ++ src-tauri/src/capture.rs | 123 ++++++++++++++++++++++++++++++++-- src/main.ts | 21 ++++-- 4 files changed, 149 insertions(+), 11 deletions(-) diff --git a/PRINT_CAPTURE_ARCHITECTURE.md b/PRINT_CAPTURE_ARCHITECTURE.md index 6cfa7cd..5689ba6 100644 --- a/PRINT_CAPTURE_ARCHITECTURE.md +++ b/PRINT_CAPTURE_ARCHITECTURE.md @@ -30,11 +30,19 @@ original spool/document -> physical printer ``` -The current localhost:9100 RAW proxy remains a compatibility backend only until the native Windows backend is complete. +The current localhost:9100 capture listener remains a compatibility capture backend until the native Windows backend is complete. Forwarding from that listener should use the selected installed Windows printer queue through the Winspool RAW API by default, so USB, WSD, shared/local, and network printers work without requiring a destination TCP/9100 endpoint. + +## Windows compatibility priority + +1. Default forwarding to the selected installed Windows queue through Winspool (OpenPrinter / StartDocPrinter / WritePrinter) instead of connecting directly to the printer's TCP/9100 endpoint. +2. Validate USB, WSD, shared/local, IPP/class-driver, and network queues using the same-driver FieldLens virtual queue. +3. Keep direct RAW/TCP only as an advanced fallback. +4. Add Windows end-to-end capture/forward self-test and queue health diagnostics. ## Windows native backend implementation track -1. Spike the supported Windows spool interception options: +1. Build on the modern Windows Print Support Virtual Printer / Software Printer architecture rather than introducing a new legacy v3/v4 driver. +2. Spike the supported Windows spool interception options: - custom port monitor, - print processor, - virtual printer / print support backend. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9330a2e..564f926 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,3 +27,7 @@ tauri-plugin-opener = "2" uuid = { version = "1", features = ["v4", "serde"] } tauri-plugin-process = "2" tauri-plugin-updater = "2" + + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Graphics_Printing"] } diff --git a/src-tauri/src/capture.rs b/src-tauri/src/capture.rs index 37d036b..b33638e 100644 --- a/src-tauri/src/capture.rs +++ b/src-tauri/src/capture.rs @@ -217,7 +217,12 @@ fn handle_raw_connection( ) { let _ = source.set_read_timeout(Some(Duration::from_millis(750))); let mut forward_error: Option = None; - let mut destination = if config.forward_addr.trim().is_empty() { + #[cfg(target_os = "windows")] + let prefer_system_printer = !config.forward_printer.trim().is_empty(); + #[cfg(not(target_os = "windows"))] + let prefer_system_printer = false; + + let mut destination = if prefer_system_printer || config.forward_addr.trim().is_empty() { None } else { match config.forward_addr.parse() { @@ -267,9 +272,11 @@ fn handle_raw_connection( return; } - let tcp_requested = !config.forward_addr.trim().is_empty(); + let tcp_requested = !prefer_system_printer && !config.forward_addr.trim().is_empty(); let (forwarded, final_error) = if forward_error.is_some() { (false, forward_error) + } else if prefer_system_printer { + forward_to_system_printer(&config.forward_printer, &bytes) } else if tcp_requested { (true, None) } else if !config.forward_printer.trim().is_empty() { @@ -817,12 +824,120 @@ fn forward_to_system_printer(printer: &str, bytes: &[u8]) -> (bool, Option (bool, Option) { + use std::{ffi::OsStr, os::windows::ffi::OsStrExt, ptr::{null, null_mut}}; + use windows_sys::Win32::Graphics::Printing::{ + ClosePrinter, EndDocPrinter, OpenPrinterW, StartDocPrinterW, WritePrinter, + DOC_INFO_1W, PRINTER_HANDLE, + }; + + if printer.trim().is_empty() { + return (false, Some("no Windows destination printer selected".into())); + } + if bytes.is_empty() { + return (false, Some("captured Windows print job was empty".into())); + } + + fn wide(value: &str) -> Vec { + OsStr::new(value).encode_wide().chain(std::iter::once(0)).collect() + } + + let printer_name = wide(printer); + let mut handle: PRINTER_HANDLE = null_mut(); + + let opened = unsafe { OpenPrinterW(printer_name.as_ptr(), &mut handle, null()) }; + if opened == 0 || handle.is_null() { + return ( + false, + Some(format!( + "Windows spooler could not open destination printer '{printer}': {}", + std::io::Error::last_os_error() + )), + ); + } + + let doc_name = wide("FieldLens Print"); + let raw = wide("RAW"); + let doc = DOC_INFO_1W { + pDocName: doc_name.as_ptr() as *mut u16, + pOutputFile: null_mut(), + pDatatype: raw.as_ptr() as *mut u16, + }; + + let job_id = unsafe { + StartDocPrinterW( + handle, + 1, + (&doc as *const DOC_INFO_1W).cast::(), + ) + }; + + if job_id == 0 { + let error = std::io::Error::last_os_error(); + unsafe { ClosePrinter(handle); } + return ( + false, + Some(format!( + "Windows spooler could not start RAW job on '{printer}': {error}" + )), + ); + } + + let mut offset = 0usize; + let mut failure = None; + while offset < bytes.len() { + let remaining = &bytes[offset..]; + let chunk_len = remaining.len().min(u32::MAX as usize); + let mut written = 0u32; + let ok = unsafe { + WritePrinter( + handle, + remaining.as_ptr().cast(), + chunk_len as u32, + &mut written, + ) + }; + if ok == 0 { + failure = Some(format!( + "Windows spooler write to '{printer}' failed after {offset} bytes: {}", + std::io::Error::last_os_error() + )); + break; + } + if written == 0 { + failure = Some(format!( + "Windows spooler accepted zero bytes while forwarding to '{printer}'" + )); + break; + } + offset += written as usize; + } + + let ended = unsafe { EndDocPrinter(handle) }; + let end_error = if ended == 0 { + Some(format!( + "Windows spooler could not finish RAW job on '{printer}': {}", + std::io::Error::last_os_error() + )) + } else { + None + }; + unsafe { ClosePrinter(handle); } + + if let Some(error) = failure.or(end_error) { + (false, Some(error)) + } else { + (true, None) + } +} + +#[cfg(not(any(target_os = "linux", target_os = "windows")))] fn forward_to_system_printer(printer: &str, _bytes: &[u8]) -> (bool, Option) { ( false, Some(format!( - "installed-printer RAW forwarding is not available for '{printer}' without a network RAW/TCP address" + "installed-printer RAW forwarding is not available for '{printer}' on this platform" )), ) } diff --git a/src/main.ts b/src/main.ts index 95e1a64..46ec0bf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -149,7 +149,9 @@ let availableManualUpdate: ManualUpdate | null = null let updateBusy = false let captureError: string | null = null let systemPrinters: SystemPrinter[] = [] -const isLinuxDesktop = navigator.userAgent.toLowerCase().includes('linux') +const userAgent = navigator.userAgent.toLowerCase() +const isLinuxDesktop = userAgent.includes('linux') +const isWindowsDesktop = userAgent.includes('windows') let virtualPrinterStatus: VirtualPrinterStatus = { installed: false, name: 'FieldLens Print', @@ -460,7 +462,7 @@ function readForm(): AppConfig { ...config, mode: el('mode').value as CaptureMode, listenAddr: el('listenAddr').value.trim(), - forwardAddr: el('forwardAddr').value.trim(), + forwardAddr: isWindowsDesktop ? '' : el('forwardAddr').value.trim(), forwardPrinter: el('forwardPrinter').value, spoolDir: el('spoolDir').value.trim(), apiBaseUrl: el('apiBaseUrl').value.trim(), @@ -486,6 +488,11 @@ function writeForm(next: AppConfig) { el('capture-mode-field').setAttribute('hidden', '') el('listen-address-field').setAttribute('hidden', '') el('raw-destination-field').setAttribute('hidden', '') + } else if (isWindowsDesktop) { + el('mode').value = 'raw-proxy' + el('capture-mode-field').setAttribute('hidden', '') + el('listen-address-field').setAttribute('hidden', '') + el('raw-destination-field').setAttribute('hidden', '') } } @@ -505,10 +512,12 @@ function renderPrinters() { const selected = options.find((printer) => printer.name === select.value) el('running-printer').textContent = selected?.name || config?.forwardPrinter || 'No physical printer selected' const help = el('forward-printer-help') - if (selected?.address) { + if (selected && isWindowsDesktop) { + help.textContent = 'FieldLens forwards through this installed Windows printer queue. USB, WSD, shared/local, and network ports do not require a separate RAW/TCP address.' + } else 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.' + help.textContent = 'No RAW/TCP address was exposed for this queue.' } else if (config?.forwardPrinter && !options.some((printer) => printer.name === config.forwardPrinter)) { help.textContent = `Previously selected printer "${config.forwardPrinter}" is not installed or available. Add it in system printer settings, then Refresh printers.` } else if (!options.length) { @@ -728,7 +737,9 @@ async function restartCapture() { el('forwardPrinter').addEventListener('change', () => { const selected = systemPrinters.find((printer) => printer.name === el('forwardPrinter').value) - if (selected?.address) { + if (isWindowsDesktop) { + el('forwardAddr').value = '' + } else if (selected?.address) { el('forwardAddr').value = selected.address } else if (selected) { el('forwardAddr').value = ''