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
944 lines
30 KiB
Rust
944 lines
30 KiB
Rust
use std::process::Command;
|
|
|
|
#[cfg(target_os = "windows")]
|
|
use std::os::windows::process::CommandExt;
|
|
|
|
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
|
use std::fs;
|
|
#[cfg(target_os = "linux")]
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{anyhow, Context, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
#[cfg(target_os = "windows")]
|
|
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 = "windows")]
|
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn hidden_windows_command(program: &str) -> Command {
|
|
let mut command = Command::new(program);
|
|
command.creation_flags(CREATE_NO_WINDOW);
|
|
command
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
const FIELDLENS_CUPS_SPOOL_DIR: &str = "/var/lib/fieldlens-print/capture";
|
|
#[cfg(target_os = "linux")]
|
|
const FIELDLENS_CUPS_FILTER: &str = r#"#!/usr/bin/python3
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
CACHE = "/var/lib/fieldlens-print/capture"
|
|
OWNER_UID = __FIELDLENS_OWNER_UID__
|
|
OWNER_GID = __FIELDLENS_OWNER_GID__
|
|
|
|
def safe(value):
|
|
value = re.sub(r"[^A-Za-z0-9._-]+", "-", value or "job").strip("-")
|
|
return value[:80] or "job"
|
|
|
|
job_id = safe(sys.argv[1] if len(sys.argv) > 1 else "job")
|
|
title = safe(sys.argv[3] if len(sys.argv) > 3 else "print")
|
|
printer = safe(os.environ.get("PRINTER", "printer"))
|
|
prefix = "test-" if printer.lower() == "fieldlens-test-capture" else "print-"
|
|
suffix = "" if title.lower().endswith(".pdf") else ".pdf"
|
|
name = f"{prefix}{printer}-{job_id}-{title}{suffix}"
|
|
final_path = os.path.join(CACHE, name)
|
|
part_path = final_path + ".part"
|
|
|
|
source = None
|
|
capture = None
|
|
captured_ok = False
|
|
|
|
try:
|
|
if len(sys.argv) > 6 and sys.argv[6]:
|
|
source = open(sys.argv[6], "rb")
|
|
else:
|
|
source = sys.stdin.buffer
|
|
|
|
try:
|
|
os.makedirs(CACHE, exist_ok=True)
|
|
if not os.path.exists(final_path):
|
|
capture = open(part_path, "wb")
|
|
except OSError as error:
|
|
print(f"WARNING: FieldLens capture cache unavailable: {error}", file=sys.stderr)
|
|
capture = None
|
|
|
|
while True:
|
|
chunk = source.read(65536)
|
|
if not chunk:
|
|
break
|
|
|
|
if capture is not None:
|
|
try:
|
|
capture.write(chunk)
|
|
except OSError as error:
|
|
print(f"WARNING: FieldLens capture write failed: {error}", file=sys.stderr)
|
|
try:
|
|
capture.close()
|
|
except OSError:
|
|
pass
|
|
capture = None
|
|
|
|
sys.stdout.buffer.write(chunk)
|
|
|
|
sys.stdout.buffer.flush()
|
|
|
|
if capture is not None:
|
|
capture.flush()
|
|
capture.close()
|
|
capture = None
|
|
os.chmod(part_path, 0o600)
|
|
try:
|
|
os.chown(part_path, OWNER_UID, OWNER_GID)
|
|
except PermissionError:
|
|
# On this Linux lane CUPS runs filters as root. Keep printing alive
|
|
# if a future CUPS policy drops privileges; repair can surface it.
|
|
pass
|
|
os.replace(part_path, final_path)
|
|
captured_ok = True
|
|
print(f"INFO: FieldLens captured {name}", file=sys.stderr)
|
|
finally:
|
|
if source is not None and source is not sys.stdin.buffer:
|
|
source.close()
|
|
if capture is not None:
|
|
capture.close()
|
|
if not captured_ok:
|
|
try:
|
|
os.unlink(part_path)
|
|
except OSError:
|
|
pass
|
|
"#;
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn render_linux_capture_filter(uid: &str, gid: &str) -> String {
|
|
FIELDLENS_CUPS_FILTER
|
|
.replace("__FIELDLENS_OWNER_UID__", uid)
|
|
.replace("__FIELDLENS_OWNER_GID__", gid)
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
const FIELDLENS_CUPS_REPAIR_HELPER: &str = r#"#!/usr/bin/python3
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
target_printer = sys.argv[1]
|
|
target_port = sys.argv[2]
|
|
temp_filter = sys.argv[3]
|
|
filter_path = sys.argv[4]
|
|
owner_uid = int(sys.argv[5])
|
|
owner_gid = int(sys.argv[6])
|
|
|
|
source_ppd = os.path.join("/etc/cups/ppd", f"{target_printer}.ppd")
|
|
spool_dir = "/var/lib/fieldlens-print/capture"
|
|
old_spool_dir = "/var/cache/cups/fieldlens-print"
|
|
|
|
with open(source_ppd, "r", encoding="utf-8", errors="replace") as source:
|
|
original = source.read()
|
|
|
|
cleaned = "\n".join(
|
|
line for line in original.splitlines()
|
|
if "fieldlens-capture" not in line
|
|
) + "\n"
|
|
|
|
prefilter = (
|
|
"*% FieldLens original-document capture before cups-filters universal conversion\n"
|
|
"*cupsPreFilter: \"application/vnd.universal-input 0 fieldlens-capture\"\n"
|
|
)
|
|
|
|
index = cleaned.rfind("*% End")
|
|
if index >= 0:
|
|
cleaned = cleaned[:index] + prefilter + cleaned[index:]
|
|
else:
|
|
cleaned += prefilter
|
|
|
|
fd, ppd_path = tempfile.mkstemp(prefix="fieldlens-", suffix=".ppd")
|
|
os.close(fd)
|
|
|
|
try:
|
|
with open(ppd_path, "w", encoding="utf-8") as target:
|
|
target.write(cleaned)
|
|
|
|
subprocess.run(["install", "-m", "0755", temp_filter, filter_path], check=True)
|
|
|
|
os.makedirs(spool_dir, exist_ok=True)
|
|
os.chown(spool_dir, owner_uid, owner_gid)
|
|
os.chmod(spool_dir, 0o700)
|
|
|
|
# Bring forward captures stranded by 0.1.13/0.1.14 in CUPS' protected
|
|
# cache. Ignore directories and partial writes.
|
|
if os.path.isdir(old_spool_dir):
|
|
for name in os.listdir(old_spool_dir):
|
|
source = os.path.join(old_spool_dir, name)
|
|
if not os.path.isfile(source) or name.endswith(".part"):
|
|
continue
|
|
target = os.path.join(spool_dir, name)
|
|
if os.path.exists(target):
|
|
root, ext = os.path.splitext(name)
|
|
target = os.path.join(spool_dir, f"{root}-migrated{ext}")
|
|
try:
|
|
os.replace(source, target)
|
|
except OSError:
|
|
continue
|
|
try:
|
|
os.chown(target, owner_uid, owner_gid)
|
|
os.chmod(target, 0o600)
|
|
except OSError:
|
|
pass
|
|
|
|
subprocess.run([
|
|
"lpadmin", "-p", "FieldLens-Print", "-E",
|
|
"-v", target_port,
|
|
"-P", ppd_path,
|
|
"-D", "FieldLens Print",
|
|
], check=True)
|
|
|
|
subprocess.run([
|
|
"lpadmin", "-p", "FieldLens-Test-Capture", "-E",
|
|
"-v", "socket://127.0.0.1:9101",
|
|
"-P", ppd_path,
|
|
"-D", "FieldLens Test Capture",
|
|
], check=True)
|
|
|
|
for queue in ("FieldLens-Print", "FieldLens-Test-Capture"):
|
|
subprocess.run(["cupsenable", queue], check=True)
|
|
subprocess.run(["cupsaccept", queue], check=True)
|
|
finally:
|
|
try:
|
|
os.unlink(ppd_path)
|
|
except OSError:
|
|
pass
|
|
"#;
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SystemPrinter {
|
|
pub name: String,
|
|
pub driver: String,
|
|
pub port: String,
|
|
pub address: Option<String>,
|
|
pub is_virtual: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct VirtualPrinterStatus {
|
|
pub installed: bool,
|
|
pub name: String,
|
|
pub detail: String,
|
|
}
|
|
|
|
fn is_virtual_printer_name(name: &str) -> bool {
|
|
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>> {
|
|
#[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<VirtualPrinterStatus> {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
return linux_virtual_printer_status();
|
|
}
|
|
|
|
let installed = list_system_printers()?
|
|
.iter()
|
|
.any(|printer| is_virtual_printer_name(&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<VirtualPrinterStatus> {
|
|
let target_printer = target_printer.trim();
|
|
if target_printer.is_empty() {
|
|
return Err(anyhow!("choose the physical printer to forward to first"));
|
|
}
|
|
if is_virtual_printer_name(target_printer) {
|
|
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(target_printer)?;
|
|
|
|
virtual_printer_status()
|
|
}
|
|
|
|
pub fn remove_virtual_printer() -> Result<VirtualPrinterStatus> {
|
|
#[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<Vec<SystemPrinter>> {
|
|
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 = hidden_windows_command("powershell.exe")
|
|
.args(["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-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
|
|
}}
|
|
|
|
$targetConfig = Get-PrintConfiguration -PrinterName $TargetPrinter -ErrorAction SilentlyContinue
|
|
$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
|
|
}}
|
|
|
|
# The virtual queue uses the same driver as the physical destination, so its
|
|
# PrintTicket can be copied safely. This keeps paper size, duplex settings and
|
|
# especially orientation aligned with the real printer instead of falling back
|
|
# to the driver's portrait default.
|
|
if ($null -ne $targetConfig -and $targetConfig.PrintTicketXML) {{
|
|
Set-PrintConfiguration -PrinterName $queueName -PrintTicketXml $targetConfig.PrintTicketXML
|
|
}}
|
|
"#,
|
|
port = WINDOWS_PORT_NAME,
|
|
queue = VIRTUAL_PRINTER_NAME,
|
|
);
|
|
|
|
fs::write(&script_path, script).context("write Windows printer setup script")?;
|
|
|
|
// First run the script directly. This is the reliable path when FieldLens
|
|
// itself was started as administrator and avoids a second hidden RunAs
|
|
// process plus fragile nested PowerShell argument quoting.
|
|
let direct = hidden_windows_command("powershell.exe")
|
|
.args([
|
|
"-NoProfile",
|
|
"-NonInteractive",
|
|
"-ExecutionPolicy",
|
|
"Bypass",
|
|
"-File",
|
|
])
|
|
.arg(&script_path)
|
|
.args(["-TargetPrinter", target_printer])
|
|
.output()
|
|
.context("run Windows printer setup")?;
|
|
|
|
if direct.status.success() {
|
|
let _ = fs::remove_file(&script_path);
|
|
return Ok(());
|
|
}
|
|
|
|
// If the app is not elevated, retry through UAC. Keep this as a fallback
|
|
// rather than the primary path so admin-launched builds never depend on a
|
|
// nested hidden RunAs process.
|
|
let path = ps_quote(&script_path.to_string_lossy());
|
|
let elevated = format!(
|
|
"$p = Start-Process -FilePath powershell.exe -Verb RunAs -Wait -PassThru -ArgumentList @('-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File','{path}','-TargetPrinter','{target}'); exit $p.ExitCode"
|
|
);
|
|
|
|
let elevated_status = hidden_windows_command("powershell.exe")
|
|
.args(["-NoProfile", "-NonInteractive", "-Command", &elevated])
|
|
.status()
|
|
.context("launch elevated Windows printer setup")?;
|
|
|
|
let _ = fs::remove_file(&script_path);
|
|
|
|
if !elevated_status.success() {
|
|
let stderr = String::from_utf8_lossy(&direct.stderr).trim().to_string();
|
|
return Err(anyhow!(
|
|
"Windows printer setup failed{}",
|
|
if stderr.is_empty() {
|
|
"; administrator approval may be required".to_string()
|
|
} else {
|
|
format!(": {stderr}")
|
|
}
|
|
));
|
|
}
|
|
|
|
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 direct = hidden_windows_command("powershell.exe")
|
|
.args([
|
|
"-NoProfile",
|
|
"-NonInteractive",
|
|
"-ExecutionPolicy",
|
|
"Bypass",
|
|
"-File",
|
|
])
|
|
.arg(&script_path)
|
|
.output()
|
|
.context("run Windows printer removal")?;
|
|
|
|
if direct.status.success() {
|
|
let _ = fs::remove_file(&script_path);
|
|
return Ok(());
|
|
}
|
|
|
|
let path = ps_quote(&script_path.to_string_lossy());
|
|
let elevated = format!(
|
|
"$p = Start-Process -FilePath powershell.exe -Verb RunAs -Wait -PassThru -ArgumentList @('-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File','{path}'); exit $p.ExitCode"
|
|
);
|
|
let status = hidden_windows_command("powershell.exe")
|
|
.args(["-NoProfile", "-NonInteractive", "-Command", &elevated])
|
|
.status()?;
|
|
let _ = fs::remove_file(&script_path);
|
|
|
|
if !status.success() {
|
|
let stderr = String::from_utf8_lossy(&direct.stderr).trim().to_owned();
|
|
return Err(anyhow!(
|
|
"removing FieldLens Print failed{}",
|
|
if stderr.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(": {stderr}")
|
|
}
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn ps_quote(value: &str) -> String {
|
|
value.replace('\'', "''")
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn list_linux_printers() -> Result<Vec<SystemPrinter>> {
|
|
let output = match Command::new("lpstat").arg("-v").output() {
|
|
Ok(output) => output,
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
return Err(anyhow!(
|
|
"CUPS client tools are missing. Install the cups-client package, then reopen FieldLens Print."
|
|
))
|
|
}
|
|
Err(error) => return Err(error).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;
|
|
};
|
|
// Ignore placeholder/dummy queues. They can expose a PPD but are not a
|
|
// usable physical destination and can make Chromium reject the cloned
|
|
// FieldLens queue as "not installed correctly".
|
|
if uri == "///dev/null" || uri == "file:///dev/null" {
|
|
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: is_virtual_printer_name(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<String> {
|
|
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 linux_queue_ready(queue: &str) -> Result<bool> {
|
|
let state = Command::new("lpstat")
|
|
.args(["-p", queue])
|
|
.output()
|
|
.with_context(|| format!("query {queue} CUPS state"))?;
|
|
let accepting = Command::new("lpstat")
|
|
.args(["-a", queue])
|
|
.output()
|
|
.with_context(|| format!("query {queue} accepting state"))?;
|
|
|
|
let state_text = String::from_utf8_lossy(&state.stdout);
|
|
let accepting_text = String::from_utf8_lossy(&accepting.stdout);
|
|
Ok(
|
|
state.status.success()
|
|
&& !state_text.to_ascii_lowercase().contains("disabled")
|
|
&& accepting.status.success()
|
|
&& accepting_text.to_ascii_lowercase().contains("accepting requests"),
|
|
)
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn linux_capture_filter_health() -> Result<()> {
|
|
let filter_path = cups_filter_dir()?.join("fieldlens-capture");
|
|
let metadata = fs::metadata(&filter_path)
|
|
.with_context(|| format!("FieldLens CUPS filter missing at {}", filter_path.display()))?;
|
|
if !metadata.is_file() {
|
|
return Err(anyhow!("FieldLens CUPS filter is not a regular file"));
|
|
}
|
|
|
|
let check = Command::new("python3")
|
|
.arg("-c")
|
|
.arg("import pathlib,sys; p=sys.argv[1]; compile(pathlib.Path(p).read_text(), p, 'exec')")
|
|
.arg(&filter_path)
|
|
.output()
|
|
.context("validate FieldLens CUPS filter with python3")?;
|
|
if !check.status.success() {
|
|
return Err(anyhow!(
|
|
"FieldLens CUPS filter is invalid: {}",
|
|
String::from_utf8_lossy(&check.stderr).trim()
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn linux_virtual_printer_status() -> Result<VirtualPrinterStatus> {
|
|
let printers = list_linux_printers()?;
|
|
let print_installed = printers
|
|
.iter()
|
|
.any(|printer| printer.name.eq_ignore_ascii_case("FieldLens-Print"));
|
|
let test_installed = printers
|
|
.iter()
|
|
.any(|printer| printer.name.eq_ignore_ascii_case(TEST_VIRTUAL_PRINTER_QUEUE));
|
|
|
|
if !print_installed || !test_installed {
|
|
return Ok(VirtualPrinterStatus {
|
|
installed: false,
|
|
name: VIRTUAL_PRINTER_NAME.into(),
|
|
detail: "FieldLens CUPS queues are incomplete. Click Create / repair printers.".into(),
|
|
});
|
|
}
|
|
|
|
let print_ready = linux_queue_ready("FieldLens-Print")?;
|
|
let test_ready = linux_queue_ready(TEST_VIRTUAL_PRINTER_QUEUE)?;
|
|
if !print_ready || !test_ready {
|
|
return Ok(VirtualPrinterStatus {
|
|
installed: false,
|
|
name: VIRTUAL_PRINTER_NAME.into(),
|
|
detail: "A FieldLens CUPS queue is disabled or not accepting jobs. Click Create / repair printers.".into(),
|
|
});
|
|
}
|
|
|
|
if let Err(error) = linux_capture_filter_health() {
|
|
return Ok(VirtualPrinterStatus {
|
|
installed: false,
|
|
name: VIRTUAL_PRINTER_NAME.into(),
|
|
detail: format!("Capture filter is broken: {error}. Click Create / repair printers."),
|
|
});
|
|
}
|
|
|
|
Ok(VirtualPrinterStatus {
|
|
installed: true,
|
|
name: VIRTUAL_PRINTER_NAME.into(),
|
|
detail: "Ready. Original documents are captured by the CUPS pre-filter before normal CUPS rendering; test jobs use the same capture path with rendered output discarded.".into(),
|
|
})
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn run_cups_repair_command(program: &str, args: &[&str]) -> Result<()> {
|
|
let direct = Command::new(program).args(args).status();
|
|
if matches!(direct, Ok(status) if status.success()) {
|
|
return Ok(());
|
|
}
|
|
|
|
let status = Command::new("pkexec")
|
|
.arg(program)
|
|
.args(args)
|
|
.status()
|
|
.with_context(|| format!("run elevated {program}"))?;
|
|
if !status.success() {
|
|
return Err(anyhow!("{program} failed; administrator approval may be required"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn cups_filter_dir() -> Result<&'static Path> {
|
|
for candidate in ["/usr/lib/cups/filter", "/usr/libexec/cups/filter"] {
|
|
let path = Path::new(candidate);
|
|
if path.is_dir() {
|
|
return Ok(path);
|
|
}
|
|
}
|
|
Err(anyhow!("CUPS filter directory was not found"))
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn run_linux_admin(program: &str, args: &[String], context: &str) -> Result<()> {
|
|
let direct = Command::new(program).args(args).status();
|
|
if matches!(direct, Ok(status) if status.success()) {
|
|
return Ok(());
|
|
}
|
|
|
|
let status = Command::new("pkexec")
|
|
.arg(program)
|
|
.args(args)
|
|
.status()
|
|
.with_context(|| format!("run elevated {context}"))?;
|
|
if !status.success() {
|
|
return Err(anyhow!("{context} failed; administrator approval may be required"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn install_linux_capture_filter() -> Result<()> {
|
|
let filter_dir = cups_filter_dir()?;
|
|
let filter_path = filter_dir.join("fieldlens-capture");
|
|
let temp_filter = std::env::temp_dir().join(format!(
|
|
"fieldlens-capture-{}.py",
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
fs::write(&temp_filter, FIELDLENS_CUPS_FILTER).context("write FieldLens CUPS pre-filter")?;
|
|
|
|
let install_filter_args = vec![
|
|
"-m".to_string(),
|
|
"0755".to_string(),
|
|
temp_filter.to_string_lossy().into_owned(),
|
|
filter_path.to_string_lossy().into_owned(),
|
|
];
|
|
let filter_result = run_linux_admin(
|
|
"install",
|
|
&install_filter_args,
|
|
"install FieldLens CUPS pre-filter",
|
|
);
|
|
let _ = fs::remove_file(&temp_filter);
|
|
filter_result?;
|
|
|
|
let spool_args = vec![
|
|
"-d".to_string(),
|
|
"-m".to_string(),
|
|
"0777".to_string(),
|
|
FIELDLENS_CUPS_SPOOL_DIR.to_string(),
|
|
];
|
|
run_linux_admin("install", &spool_args, "create FieldLens CUPS capture spool")?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn build_fieldlens_ppd(target_printer: &str) -> Result<PathBuf> {
|
|
let source = PathBuf::from("/etc/cups/ppd").join(format!("{target_printer}.ppd"));
|
|
if !source.exists() {
|
|
return Err(anyhow!(
|
|
"CUPS PPD for '{target_printer}' was not found; FieldLens needs the installed printer definition to preserve browser print capabilities"
|
|
));
|
|
}
|
|
|
|
let original = match fs::read_to_string(&source) {
|
|
Ok(contents) => contents,
|
|
Err(direct_error) => {
|
|
// CUPS commonly stores PPDs as root:lp 0640. Desktop users can
|
|
// enumerate the queue but cannot necessarily read its PPD.
|
|
// Reuse the existing PolicyKit setup flow for a read-only fallback.
|
|
let output = Command::new("pkexec")
|
|
.arg("cat")
|
|
.arg(&source)
|
|
.output()
|
|
.with_context(|| {
|
|
format!(
|
|
"read target printer PPD {} (direct read failed: {direct_error})",
|
|
source.display()
|
|
)
|
|
})?;
|
|
|
|
if !output.status.success() {
|
|
return Err(anyhow!(
|
|
"could not read target printer PPD {}: {}",
|
|
source.display(),
|
|
String::from_utf8_lossy(&output.stderr).trim()
|
|
));
|
|
}
|
|
|
|
String::from_utf8(output.stdout)
|
|
.with_context(|| format!("decode target printer PPD {}", source.display()))?
|
|
}
|
|
};
|
|
let mut cleaned = original
|
|
.lines()
|
|
.filter(|line| !line.contains("fieldlens-capture"))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
cleaned.push('\n');
|
|
|
|
let prefilter = concat!(
|
|
"*% FieldLens original-document capture before cups-filters universal conversion\n",
|
|
"*cupsPreFilter: \"application/vnd.universal-input 0 fieldlens-capture\"\n"
|
|
);
|
|
if let Some(index) = cleaned.rfind("*% End") {
|
|
cleaned.insert_str(index, prefilter);
|
|
} else {
|
|
cleaned.push_str(prefilter);
|
|
}
|
|
|
|
let target = std::env::temp_dir().join(format!(
|
|
"fieldlens-{}.ppd",
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
fs::write(&target, cleaned).context("write FieldLens derived PPD")?;
|
|
Ok(target)
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn install_linux_virtual_printer(target_printer: &str) -> Result<()> {
|
|
let printers = list_linux_printers()?;
|
|
let target = printers
|
|
.iter()
|
|
.find(|printer| printer.name == target_printer && !printer.is_virtual)
|
|
.ok_or_else(|| anyhow!(
|
|
"selected printer '{target_printer}' is not installed or available in CUPS"
|
|
))?;
|
|
|
|
let filter_dir = cups_filter_dir()?;
|
|
let filter_path = filter_dir.join("fieldlens-capture");
|
|
|
|
let temp_filter = std::env::temp_dir().join(format!(
|
|
"fieldlens-capture-{}.py",
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
let temp_helper = std::env::temp_dir().join(format!(
|
|
"fieldlens-cups-repair-{}.py",
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
|
|
let uid = Command::new("id")
|
|
.arg("-u")
|
|
.output()
|
|
.context("read current Linux user id")?;
|
|
let gid = Command::new("id")
|
|
.arg("-g")
|
|
.output()
|
|
.context("read current Linux group id")?;
|
|
if !uid.status.success() || !gid.status.success() {
|
|
return Err(anyhow!("could not determine the current Linux user for capture ownership"));
|
|
}
|
|
let uid = String::from_utf8_lossy(&uid.stdout).trim().to_string();
|
|
let gid = String::from_utf8_lossy(&gid.stdout).trim().to_string();
|
|
|
|
let filter_script = render_linux_capture_filter(&uid, &gid);
|
|
fs::write(&temp_filter, filter_script)
|
|
.context("write FieldLens CUPS capture filter")?;
|
|
fs::write(&temp_helper, FIELDLENS_CUPS_REPAIR_HELPER)
|
|
.context("write FieldLens CUPS repair helper")?;
|
|
|
|
let status = Command::new("pkexec")
|
|
.arg("python3")
|
|
.arg(&temp_helper)
|
|
.arg(target_printer)
|
|
.arg(&target.port)
|
|
.arg(&temp_filter)
|
|
.arg(&filter_path)
|
|
.arg(&uid)
|
|
.arg(&gid)
|
|
.status()
|
|
.context("run elevated FieldLens CUPS repair")?;
|
|
|
|
let _ = fs::remove_file(&temp_filter);
|
|
let _ = fs::remove_file(&temp_helper);
|
|
|
|
if !status.success() {
|
|
return Err(anyhow!(
|
|
"FieldLens printer repair failed; administrator approval may be required"
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn remove_linux_virtual_printer() -> Result<()> {
|
|
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()) {
|
|
continue;
|
|
}
|
|
|
|
let status = Command::new("pkexec")
|
|
.arg("lpadmin")
|
|
.args(&args)
|
|
.status()
|
|
.context("run elevated CUPS printer removal")?;
|
|
if !status.success() {
|
|
return Err(anyhow!("removing FieldLens CUPS printer '{queue}' failed"));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
|
|
#[cfg(all(test, target_os = "linux"))]
|
|
mod linux_filter_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn rendered_cups_filter_compiles_with_python() {
|
|
let path = std::env::temp_dir().join(format!(
|
|
"fieldlens-filter-test-{}.py",
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
let script = render_linux_capture_filter("1000", "1000");
|
|
fs::write(&path, script).expect("write rendered CUPS filter");
|
|
|
|
let output = Command::new("python3")
|
|
.args(["-m", "py_compile"])
|
|
.arg(&path)
|
|
.output()
|
|
.expect("run python3 -m py_compile");
|
|
|
|
let _ = fs::remove_file(&path);
|
|
assert!(
|
|
output.status.success(),
|
|
"rendered CUPS filter must compile: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
}
|