feat(linux): add no-output test capture printer
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

This commit is contained in:
Admin 2026-09-24 03:56:02 +00:00
parent eddcdd6af6
commit cc9031fcfd
7 changed files with 111 additions and 28 deletions

View File

@ -58,4 +58,6 @@ Windows creates the virtual queue with the selected destination printer's existi
Linux creates a CUPS queue named `FieldLens-Print` using the selected physical printer's capabilities and device URI. A FieldLens CUPS PDF pre-filter copies the original submitted PDF into `/var/spool/fieldlens-print` before the physical printer driver renders it, then passes the PDF onward unchanged through the normal CUPS filter chain. FieldLens Print watches that spool for capture/decoding while CUPS continues printing normally. Linux creates a CUPS queue named `FieldLens-Print` using the selected physical printer's capabilities and device URI. A FieldLens CUPS PDF pre-filter copies the original submitted PDF into `/var/spool/fieldlens-print` before the physical printer driver renders it, then passes the PDF onward unchanged through the normal CUPS filter chain. FieldLens Print watches that spool for capture/decoding while CUPS continues printing normally.
On Linux the same repair action also installs **FieldLens Test Capture**. It uses the same PPD and FieldLens pre-filter, but its rendered output is sent to a local discard sink at `127.0.0.1:9101`. Selecting that queue from a browser exercises original-document capture, spool ingestion, decoding, and the normal printer render/filter chain without sending anything to physical hardware.
USB/shared queues may not expose a RAW/TCP address. In that case the installed-printer selector still supplies the driver for the virtual Windows queue, while the physical destination address must be entered manually. USB/shared queues may not expose a RAW/TCP address. In that case the installed-printer selector still supplies the driver for the virtual Windows queue, while the physical destination address must be entered manually.

View File

@ -1,6 +1,6 @@
{ {
"name": "@fieldlens/print", "name": "@fieldlens/print",
"version": "0.1.7", "version": "0.1.8",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -1,6 +1,6 @@
[package] [package]
name = "fieldlens-print" name = "fieldlens-print"
version = "0.1.7" version = "0.1.8"
description = "FieldLens print-stream barcode capture companion" description = "FieldLens print-stream barcode capture companion"
authors = ["Arrowstack"] authors = ["Arrowstack"]
edition = "2021" edition = "2021"

View File

@ -26,6 +26,8 @@ use crate::{
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
const SYSTEM_CUPS_SPOOL_DIR: &str = "/var/spool/fieldlens-print"; const SYSTEM_CUPS_SPOOL_DIR: &str = "/var/spool/fieldlens-print";
#[cfg(target_os = "linux")]
const TEST_CAPTURE_SINK_ADDR: &str = "127.0.0.1:9101";
pub struct CaptureRuntime { pub struct CaptureRuntime {
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
@ -101,6 +103,12 @@ pub fn start(config: AppConfig, state: SharedState, app: AppHandle) -> Result<Ca
) )
})); }));
} }
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() { if handles.is_empty() {
@ -110,6 +118,37 @@ pub fn start(config: AppConfig, state: SharedState, app: AppHandle) -> Result<Ca
Ok(CaptureRuntime { stop, handles }) 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( fn raw_proxy_loop(
listener: TcpListener, listener: TcpListener,
config: AppConfig, config: AppConfig,

View File

@ -11,6 +11,12 @@ use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
pub const VIRTUAL_PRINTER_NAME: &str = "FieldLens Print"; pub const VIRTUAL_PRINTER_NAME: &str = "FieldLens Print";
#[cfg(target_os = "linux")]
pub const TEST_VIRTUAL_PRINTER_NAME: &str = "FieldLens Test Capture";
#[cfg(target_os = "linux")]
const TEST_VIRTUAL_PRINTER_QUEUE: &str = "FieldLens-Test-Capture";
#[cfg(target_os = "linux")]
const TEST_CAPTURE_DEVICE_URI: &str = "socket://127.0.0.1:9101";
const WINDOWS_PORT_NAME: &str = "FieldLensPrint-9100"; const WINDOWS_PORT_NAME: &str = "FieldLensPrint-9100";
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@ -108,8 +114,20 @@ pub struct VirtualPrinterStatus {
} }
fn is_virtual_printer_name(name: &str) -> bool { fn is_virtual_printer_name(name: &str) -> bool {
name.eq_ignore_ascii_case(VIRTUAL_PRINTER_NAME) if name.eq_ignore_ascii_case(VIRTUAL_PRINTER_NAME)
|| name.eq_ignore_ascii_case("FieldLens-Print") || name.eq_ignore_ascii_case("FieldLens-Print")
{
return true;
}
#[cfg(target_os = "linux")]
{
return name.eq_ignore_ascii_case(TEST_VIRTUAL_PRINTER_NAME)
|| name.eq_ignore_ascii_case(TEST_VIRTUAL_PRINTER_QUEUE);
}
#[cfg(not(target_os = "linux"))]
false
} }
pub fn list_system_printers() -> Result<Vec<SystemPrinter>> { pub fn list_system_printers() -> Result<Vec<SystemPrinter>> {
@ -389,7 +407,7 @@ fn linux_virtual_printer_status() -> Result<VirtualPrinterStatus> {
let printers = list_linux_printers()?; let printers = list_linux_printers()?;
let installed = printers let installed = printers
.iter() .iter()
.any(|printer| is_virtual_printer_name(&printer.name)); .any(|printer| printer.name.eq_ignore_ascii_case("FieldLens-Print"));
if !installed { if !installed {
return Ok(VirtualPrinterStatus { return Ok(VirtualPrinterStatus {
@ -420,7 +438,7 @@ fn linux_virtual_printer_status() -> Result<VirtualPrinterStatus> {
installed: ready, installed: ready,
name: VIRTUAL_PRINTER_NAME.into(), name: VIRTUAL_PRINTER_NAME.into(),
detail: if ready { detail: if ready {
"Ready. Original PDF jobs are captured before the physical printer driver renders them.".into() "Ready. FieldLens Print sends to the physical printer; FieldLens Test Capture runs the same capture/render path but discards the rendered output.".into()
} else { } else {
"FieldLens Print exists but CUPS has it disabled or not accepting jobs. Click Create / repair FieldLens Print.".into() "FieldLens Print exists but CUPS has it disabled or not accepting jobs. Click Create / repair FieldLens Print.".into()
}, },
@ -556,7 +574,8 @@ fn install_linux_virtual_printer(target_printer: &str) -> Result<()> {
install_linux_capture_filter()?; install_linux_capture_filter()?;
let ppd_path = build_fieldlens_ppd(target_printer)?; let ppd_path = build_fieldlens_ppd(target_printer)?;
let args = vec![
let production_args = vec![
"-p".to_string(), "-p".to_string(),
"FieldLens-Print".to_string(), "FieldLens-Print".to_string(),
"-E".to_string(), "-E".to_string(),
@ -567,34 +586,57 @@ fn install_linux_virtual_printer(target_printer: &str) -> Result<()> {
"-D".to_string(), "-D".to_string(),
VIRTUAL_PRINTER_NAME.to_string(), VIRTUAL_PRINTER_NAME.to_string(),
]; ];
run_linux_admin(
"lpadmin",
&production_args,
"configure FieldLens production CUPS printer",
)?;
let setup_result = run_linux_admin("lpadmin", &args, "configure FieldLens CUPS printer"); let test_args = vec![
"-p".to_string(),
TEST_VIRTUAL_PRINTER_QUEUE.to_string(),
"-E".to_string(),
"-v".to_string(),
TEST_CAPTURE_DEVICE_URI.to_string(),
"-P".to_string(),
ppd_path.to_string_lossy().into_owned(),
"-D".to_string(),
TEST_VIRTUAL_PRINTER_NAME.to_string(),
];
let test_result = run_linux_admin(
"lpadmin",
&test_args,
"configure FieldLens test-capture CUPS printer",
);
let _ = fs::remove_file(&ppd_path); let _ = fs::remove_file(&ppd_path);
setup_result?; test_result?;
// lpadmin -E normally enables the queue, but explicitly repair both CUPS // Explicitly repair both CUPS state flags because Chromium disables Print
// state flags because Chromium disables Print when either is stale. // when either is stale.
run_cups_repair_command("cupsenable", &["FieldLens-Print"])?; for queue in ["FieldLens-Print", TEST_VIRTUAL_PRINTER_QUEUE] {
run_cups_repair_command("cupsaccept", &["FieldLens-Print"])?; run_cups_repair_command("cupsenable", &[queue])?;
run_cups_repair_command("cupsaccept", &[queue])?;
}
Ok(()) Ok(())
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn remove_linux_virtual_printer() -> Result<()> { fn remove_linux_virtual_printer() -> Result<()> {
let direct = Command::new("lpadmin") for queue in ["FieldLens-Print", TEST_VIRTUAL_PRINTER_QUEUE] {
.args(["-x", "FieldLens-Print"]) let args = vec!["-x".to_string(), queue.to_string()];
.status(); let direct = Command::new("lpadmin").args(&args).status();
if matches!(direct, Ok(status) if status.success()) { if matches!(direct, Ok(status) if status.success()) {
return Ok(()); continue;
} }
let status = Command::new("pkexec") let status = Command::new("pkexec")
.args(["lpadmin", "-x", "FieldLens-Print"]) .arg("lpadmin")
.args(&args)
.status() .status()
.context("run elevated CUPS printer removal")?; .context("run elevated CUPS printer removal")?;
if !status.success() { if !status.success() {
return Err(anyhow!("removing FieldLens Print from CUPS failed")); return Err(anyhow!("removing FieldLens CUPS printer '{queue}' failed"));
}
} }
Ok(()) Ok(())
} }

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "FieldLens Print", "productName": "FieldLens Print",
"version": "0.1.7", "version": "0.1.8",
"identifier": "dev.arrowstack.fieldlens.print", "identifier": "dev.arrowstack.fieldlens.print",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View File

@ -85,7 +85,7 @@ let config: AppConfig
let jobs: Job[] = [] let jobs: Job[] = []
let status: Status let status: Status
let loginTimer: number | undefined let loginTimer: number | undefined
let currentVersion = '0.1.7' let currentVersion = '0.1.8'
let bundleType: BundleType | null = null let bundleType: BundleType | null = null
let availableUpdate: Update | null = null let availableUpdate: Update | null = null
let availableManualUpdate: ManualUpdate | null = null let availableManualUpdate: ManualUpdate | null = null
@ -138,7 +138,7 @@ app.innerHTML = `
<div> <div>
<strong>System printer</strong> <strong>System printer</strong>
<span id="virtual-printer-state" class="muted">Checking whether FieldLens Print is installed…</span> <span id="virtual-printer-state" class="muted">Checking whether FieldLens Print is installed…</span>
<small class="muted">Creates a printer named <b>FieldLens Print</b> in the normal OS print dialog and routes it into the local RAW listener.</small> <small class="muted">Linux creates <b>FieldLens Print</b> for real printing and <b>FieldLens Test Capture</b> for a full capture/render test with no physical output.</small>
</div> </div>
<div class="actions printer-actions"> <div class="actions printer-actions">
<button class="btn" id="refresh-printers">Refresh printers</button> <button class="btn" id="refresh-printers">Refresh printers</button>