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.
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.

View File

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

View File

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

View File

@ -26,6 +26,8 @@ use crate::{
#[cfg(target_os = "linux")]
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 {
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() {
@ -110,6 +118,37 @@ pub fn start(config: AppConfig, state: SharedState, app: AppHandle) -> Result<Ca
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(
listener: TcpListener,
config: AppConfig,

View File

@ -11,6 +11,12 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
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";
#[cfg(target_os = "linux")]
@ -108,8 +114,20 @@ pub struct VirtualPrinterStatus {
}
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")
{
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>> {
@ -389,7 +407,7 @@ fn linux_virtual_printer_status() -> Result<VirtualPrinterStatus> {
let printers = list_linux_printers()?;
let installed = printers
.iter()
.any(|printer| is_virtual_printer_name(&printer.name));
.any(|printer| printer.name.eq_ignore_ascii_case("FieldLens-Print"));
if !installed {
return Ok(VirtualPrinterStatus {
@ -420,7 +438,7 @@ fn linux_virtual_printer_status() -> Result<VirtualPrinterStatus> {
installed: ready,
name: VIRTUAL_PRINTER_NAME.into(),
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 {
"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()?;
let ppd_path = build_fieldlens_ppd(target_printer)?;
let args = vec![
let production_args = vec![
"-p".to_string(),
"FieldLens-Print".to_string(),
"-E".to_string(),
@ -567,34 +586,57 @@ fn install_linux_virtual_printer(target_printer: &str) -> Result<()> {
"-D".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);
setup_result?;
test_result?;
// lpadmin -E normally enables the queue, but explicitly repair both CUPS
// state flags because Chromium disables Print when either is stale.
run_cups_repair_command("cupsenable", &["FieldLens-Print"])?;
run_cups_repair_command("cupsaccept", &["FieldLens-Print"])?;
// Explicitly repair both CUPS state flags because Chromium disables Print
// when either is stale.
for queue in ["FieldLens-Print", TEST_VIRTUAL_PRINTER_QUEUE] {
run_cups_repair_command("cupsenable", &[queue])?;
run_cups_repair_command("cupsaccept", &[queue])?;
}
Ok(())
}
#[cfg(target_os = "linux")]
fn remove_linux_virtual_printer() -> Result<()> {
let direct = Command::new("lpadmin")
.args(["-x", "FieldLens-Print"])
.status();
for queue in ["FieldLens-Print", TEST_VIRTUAL_PRINTER_QUEUE] {
let args = vec!["-x".to_string(), queue.to_string()];
let direct = Command::new("lpadmin").args(&args).status();
if matches!(direct, Ok(status) if status.success()) {
return Ok(());
continue;
}
let status = Command::new("pkexec")
.args(["lpadmin", "-x", "FieldLens-Print"])
.arg("lpadmin")
.args(&args)
.status()
.context("run elevated CUPS printer removal")?;
if !status.success() {
return Err(anyhow!("removing FieldLens Print from CUPS failed"));
return Err(anyhow!("removing FieldLens CUPS printer '{queue}' failed"));
}
}
Ok(())
}

View File

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

View File

@ -85,7 +85,7 @@ let config: AppConfig
let jobs: Job[] = []
let status: Status
let loginTimer: number | undefined
let currentVersion = '0.1.7'
let currentVersion = '0.1.8'
let bundleType: BundleType | null = null
let availableUpdate: Update | null = null
let availableManualUpdate: ManualUpdate | null = null
@ -138,7 +138,7 @@ app.innerHTML = `
<div>
<strong>System printer</strong>
<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 class="actions printer-actions">
<button class="btn" id="refresh-printers">Refresh printers</button>