feat: add system printer setup and forwarding selection
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
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:
parent
478201badd
commit
7301725791
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "fieldlens-print"
|
name = "fieldlens-print"
|
||||||
version = "0.1.1"
|
version = "0.1.2"
|
||||||
description = "FieldLens print-stream barcode capture companion"
|
description = "FieldLens print-stream barcode capture companion"
|
||||||
authors = ["Arrowstack"]
|
authors = ["Arrowstack"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
mod capture;
|
mod capture;
|
||||||
mod fieldlens;
|
mod fieldlens;
|
||||||
mod model;
|
mod model;
|
||||||
|
mod printers;
|
||||||
mod state;
|
mod state;
|
||||||
mod zpl;
|
mod zpl;
|
||||||
|
|
||||||
@ -9,6 +10,7 @@ use tauri::{AppHandle, Manager, State};
|
|||||||
|
|
||||||
use capture::CaptureRuntime;
|
use capture::CaptureRuntime;
|
||||||
use model::{AppConfig, CaptureStatus, JobSummary, LoginPoll, LoginStart, SyncResult};
|
use model::{AppConfig, CaptureStatus, JobSummary, LoginPoll, LoginStart, SyncResult};
|
||||||
|
use printers::{SystemPrinter, VirtualPrinterStatus};
|
||||||
use state::SharedState;
|
use state::SharedState;
|
||||||
|
|
||||||
struct AppState {
|
struct AppState {
|
||||||
@ -64,6 +66,26 @@ fn stop_capture(state: State<'_, AppState>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn list_system_printers() -> Result<Vec<SystemPrinter>, String> {
|
||||||
|
printers::list_system_printers().map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_virtual_printer_status() -> Result<VirtualPrinterStatus, String> {
|
||||||
|
printers::virtual_printer_status().map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn install_virtual_printer(target_printer: String) -> Result<VirtualPrinterStatus, String> {
|
||||||
|
printers::install_virtual_printer(&target_printer).map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn remove_virtual_printer() -> Result<VirtualPrinterStatus, String> {
|
||||||
|
printers::remove_virtual_printer().map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn begin_login(state: State<'_, AppState>) -> Result<LoginStart, String> {
|
fn begin_login(state: State<'_, AppState>) -> Result<LoginStart, String> {
|
||||||
fieldlens::begin_login(&state.shared).map_err(|error| error.to_string())
|
fieldlens::begin_login(&state.shared).map_err(|error| error.to_string())
|
||||||
@ -101,6 +123,10 @@ pub fn run() {
|
|||||||
recent_jobs,
|
recent_jobs,
|
||||||
start_capture,
|
start_capture,
|
||||||
stop_capture,
|
stop_capture,
|
||||||
|
list_system_printers,
|
||||||
|
get_virtual_printer_status,
|
||||||
|
install_virtual_printer,
|
||||||
|
remove_virtual_printer,
|
||||||
begin_login,
|
begin_login,
|
||||||
poll_login,
|
poll_login,
|
||||||
sync_pending
|
sync_pending
|
||||||
|
|||||||
@ -22,6 +22,8 @@ pub struct AppConfig {
|
|||||||
pub mode: CaptureMode,
|
pub mode: CaptureMode,
|
||||||
pub listen_addr: String,
|
pub listen_addr: String,
|
||||||
pub forward_addr: String,
|
pub forward_addr: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub forward_printer: String,
|
||||||
pub spool_dir: String,
|
pub spool_dir: String,
|
||||||
pub api_base_url: String,
|
pub api_base_url: String,
|
||||||
pub issuer: String,
|
pub issuer: String,
|
||||||
@ -37,6 +39,7 @@ impl Default for AppConfig {
|
|||||||
mode: CaptureMode::RawProxy,
|
mode: CaptureMode::RawProxy,
|
||||||
listen_addr: "127.0.0.1:9100".into(),
|
listen_addr: "127.0.0.1:9100".into(),
|
||||||
forward_addr: String::new(),
|
forward_addr: String::new(),
|
||||||
|
forward_printer: String::new(),
|
||||||
spool_dir: String::new(),
|
spool_dir: String::new(),
|
||||||
api_base_url: "https://fieldlens.arrowstack.dev".into(),
|
api_base_url: "https://fieldlens.arrowstack.dev".into(),
|
||||||
issuer: "https://auth.arrowstack.dev/realms/dev".into(),
|
issuer: "https://auth.arrowstack.dev/realms/dev".into(),
|
||||||
|
|||||||
335
src-tauri/src/printers.rs
Normal file
335
src-tauri/src/printers.rs
Normal file
@ -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<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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
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<VirtualPrinterStatus> {
|
||||||
|
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<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 = 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<Vec<SystemPrinter>> {
|
||||||
|
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<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 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(())
|
||||||
|
}
|
||||||
132
src/main.ts
132
src/main.ts
@ -12,6 +12,7 @@ type AppConfig = {
|
|||||||
mode: CaptureMode
|
mode: CaptureMode
|
||||||
listenAddr: string
|
listenAddr: string
|
||||||
forwardAddr: string
|
forwardAddr: string
|
||||||
|
forwardPrinter: string
|
||||||
spoolDir: string
|
spoolDir: string
|
||||||
apiBaseUrl: string
|
apiBaseUrl: string
|
||||||
issuer: string
|
issuer: string
|
||||||
@ -21,6 +22,20 @@ type AppConfig = {
|
|||||||
deviceId?: string | null
|
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 Barcode = { format: string; value: string }
|
||||||
|
|
||||||
type Job = {
|
type Job = {
|
||||||
@ -58,6 +73,8 @@ let currentVersion = '0.1.1'
|
|||||||
let bundleType: BundleType | null = null
|
let bundleType: BundleType | null = null
|
||||||
let availableUpdate: Update | null = null
|
let availableUpdate: Update | null = null
|
||||||
let updateBusy = false
|
let updateBusy = false
|
||||||
|
let systemPrinters: SystemPrinter[] = []
|
||||||
|
let virtualPrinterStatus: VirtualPrinterStatus | null = null
|
||||||
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
@ -84,15 +101,33 @@ app.innerHTML = `
|
|||||||
<label>Listen address
|
<label>Listen address
|
||||||
<input id="listenAddr" placeholder="127.0.0.1:9100" />
|
<input id="listenAddr" placeholder="127.0.0.1:9100" />
|
||||||
</label>
|
</label>
|
||||||
<label>Forward printer
|
<label>Forward to installed printer
|
||||||
|
<select id="forwardPrinter">
|
||||||
|
<option value="">Choose a printer…</option>
|
||||||
|
</select>
|
||||||
|
<span id="forward-printer-help" class="help">Select the physical printer that should receive the original print bytes.</span>
|
||||||
|
</label>
|
||||||
|
<label>RAW/TCP destination
|
||||||
<input id="forwardAddr" placeholder="10.0.0.50:9100" />
|
<input id="forwardAddr" placeholder="10.0.0.50:9100" />
|
||||||
<span class="help">Leave blank for capture-only testing.</span>
|
<span class="help">Auto-filled for network printers. Keep as the manual fallback for USB/shared queues.</span>
|
||||||
</label>
|
</label>
|
||||||
<label>Spool capture folder
|
<label>Spool capture folder
|
||||||
<input id="spoolDir" placeholder="C:\\Users\\me\\FieldLensPrint\\spool" />
|
<input id="spoolDir" placeholder="C:\\Users\\me\\FieldLensPrint\\spool" />
|
||||||
<span class="help">Accepts .zpl, .raw, .prn and .spl drops from a virtual/print-to-file route.</span>
|
<span class="help">Accepts .zpl, .raw, .prn and .spl drops from a virtual/print-to-file route.</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="printer-setup">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="actions printer-actions">
|
||||||
|
<button class="btn" id="refresh-printers">Refresh printers</button>
|
||||||
|
<button class="btn blue" id="install-printer">Create / repair FieldLens Print</button>
|
||||||
|
<button class="btn stop" id="remove-printer">Remove printer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn" id="save">Save settings</button>
|
<button class="btn" id="save">Save settings</button>
|
||||||
<button class="btn primary" id="start">Start capture</button>
|
<button class="btn primary" id="start">Start capture</button>
|
||||||
@ -160,6 +195,7 @@ function readForm(): AppConfig {
|
|||||||
mode: el<HTMLSelectElement>('mode').value as CaptureMode,
|
mode: el<HTMLSelectElement>('mode').value as CaptureMode,
|
||||||
listenAddr: el<HTMLInputElement>('listenAddr').value.trim(),
|
listenAddr: el<HTMLInputElement>('listenAddr').value.trim(),
|
||||||
forwardAddr: el<HTMLInputElement>('forwardAddr').value.trim(),
|
forwardAddr: el<HTMLInputElement>('forwardAddr').value.trim(),
|
||||||
|
forwardPrinter: el<HTMLSelectElement>('forwardPrinter').value,
|
||||||
spoolDir: el<HTMLInputElement>('spoolDir').value.trim(),
|
spoolDir: el<HTMLInputElement>('spoolDir').value.trim(),
|
||||||
apiBaseUrl: el<HTMLInputElement>('apiBaseUrl').value.trim(),
|
apiBaseUrl: el<HTMLInputElement>('apiBaseUrl').value.trim(),
|
||||||
issuer: el<HTMLInputElement>('issuer').value.trim(),
|
issuer: el<HTMLInputElement>('issuer').value.trim(),
|
||||||
@ -172,6 +208,7 @@ function writeForm(next: AppConfig) {
|
|||||||
el<HTMLSelectElement>('mode').value = next.mode
|
el<HTMLSelectElement>('mode').value = next.mode
|
||||||
el<HTMLInputElement>('listenAddr').value = next.listenAddr
|
el<HTMLInputElement>('listenAddr').value = next.listenAddr
|
||||||
el<HTMLInputElement>('forwardAddr').value = next.forwardAddr
|
el<HTMLInputElement>('forwardAddr').value = next.forwardAddr
|
||||||
|
el<HTMLSelectElement>('forwardPrinter').value = next.forwardPrinter || ''
|
||||||
el<HTMLInputElement>('spoolDir').value = next.spoolDir
|
el<HTMLInputElement>('spoolDir').value = next.spoolDir
|
||||||
el<HTMLInputElement>('apiBaseUrl').value = next.apiBaseUrl
|
el<HTMLInputElement>('apiBaseUrl').value = next.apiBaseUrl
|
||||||
el<HTMLInputElement>('issuer').value = next.issuer
|
el<HTMLInputElement>('issuer').value = next.issuer
|
||||||
@ -179,6 +216,50 @@ function writeForm(next: AppConfig) {
|
|||||||
el<HTMLInputElement>('stationId').value = next.stationId
|
el<HTMLInputElement>('stationId').value = next.stationId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPrinters() {
|
||||||
|
const select = el<HTMLSelectElement>('forwardPrinter')
|
||||||
|
const current = config?.forwardPrinter || select.value
|
||||||
|
const options = systemPrinters.filter((printer) => !printer.isVirtual)
|
||||||
|
|
||||||
|
select.innerHTML = '<option value="">Choose a printer…</option>' + options.map((printer) => {
|
||||||
|
const suffix = printer.address ? ` · ${printer.address}` : printer.port ? ` · ${printer.port}` : ''
|
||||||
|
return `<option value="${escapeHtml(printer.name)}">${escapeHtml(printer.name + suffix)}</option>`
|
||||||
|
}).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<HTMLButtonElement>('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<SystemPrinter[]>('list_system_printers'),
|
||||||
|
invoke<VirtualPrinterStatus>('get_virtual_printer_status'),
|
||||||
|
])
|
||||||
|
systemPrinters = printers
|
||||||
|
virtualPrinterStatus = printerStatus
|
||||||
|
renderPrinters()
|
||||||
|
} catch (error) {
|
||||||
|
state.textContent = `Printer discovery failed: ${String(error)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
const pill = el<HTMLDivElement>('status-pill')
|
const pill = el<HTMLDivElement>('status-pill')
|
||||||
pill.classList.toggle('live', status?.running ?? false)
|
pill.classList.toggle('live', status?.running ?? false)
|
||||||
@ -208,6 +289,51 @@ async function refreshStatus() {
|
|||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
el('forwardPrinter').addEventListener('change', () => {
|
||||||
|
const selected = systemPrinters.find((printer) => printer.name === el<HTMLSelectElement>('forwardPrinter').value)
|
||||||
|
if (selected?.address) el<HTMLInputElement>('forwardAddr').value = selected.address
|
||||||
|
renderPrinters()
|
||||||
|
})
|
||||||
|
|
||||||
|
el('refresh-printers').addEventListener('click', () => { void refreshPrinters() })
|
||||||
|
|
||||||
|
el('install-printer').addEventListener('click', async () => {
|
||||||
|
const button = el<HTMLButtonElement>('install-printer')
|
||||||
|
const state = el('virtual-printer-state')
|
||||||
|
const targetPrinter = el<HTMLSelectElement>('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<VirtualPrinterStatus>('install_virtual_printer', { targetPrinter })
|
||||||
|
config = await invoke<AppConfig>('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<HTMLButtonElement>('remove-printer')
|
||||||
|
const state = el('virtual-printer-state')
|
||||||
|
button.disabled = true
|
||||||
|
state.textContent = 'Removing FieldLens Print…'
|
||||||
|
try {
|
||||||
|
virtualPrinterStatus = await invoke<VirtualPrinterStatus>('remove_virtual_printer')
|
||||||
|
await refreshPrinters()
|
||||||
|
} catch (error) {
|
||||||
|
state.textContent = `Printer removal failed: ${String(error)}`
|
||||||
|
} finally {
|
||||||
|
button.disabled = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
el('save').addEventListener('click', async () => {
|
el('save').addEventListener('click', async () => {
|
||||||
config = await invoke<AppConfig>('save_config', { config: readForm() })
|
config = await invoke<AppConfig>('save_config', { config: readForm() })
|
||||||
writeForm(config)
|
writeForm(config)
|
||||||
@ -352,7 +478,7 @@ await listen<Job>('capture-job', (event) => {
|
|||||||
|
|
||||||
config = await invoke<AppConfig>('get_config')
|
config = await invoke<AppConfig>('get_config')
|
||||||
writeForm(config)
|
writeForm(config)
|
||||||
await refreshStatus()
|
await Promise.all([refreshStatus(), refreshPrinters()])
|
||||||
|
|
||||||
currentVersion = await getVersion()
|
currentVersion = await getVersion()
|
||||||
bundleType = await getBundleType().catch(() => null)
|
bundleType = await getBundleType().catch(() => null)
|
||||||
|
|||||||
@ -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 { 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; }
|
.update-progress span { display: block; width: 0; height: 100%; border-radius: inherit; background: #38bdf8; transition: width .16s ease; }
|
||||||
.btn:disabled { cursor: wait; opacity: .55; }
|
.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; }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user