Admin 869f14cf19
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
fix(linux): make installed printer forwarding reliable
2026-09-24 00:57:28 +00:00

500 lines
19 KiB
TypeScript

import { BundleType, getBundleType, getVersion } from '@tauri-apps/api/app'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { openUrl } from '@tauri-apps/plugin-opener'
import { relaunch } from '@tauri-apps/plugin-process'
import { check, type Update } from '@tauri-apps/plugin-updater'
import './styles.css'
type CaptureMode = 'raw-proxy' | 'spool-folder' | 'both'
type AppConfig = {
mode: CaptureMode
listenAddr: string
forwardAddr: string
forwardPrinter: string
spoolDir: string
apiBaseUrl: string
issuer: string
clientId: string
stationId: string
installationId: string
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 Job = {
id: string
source: string
label: string
bytes: number
barcodes: Barcode[]
capturedAt: string
forwarded: boolean
error?: string | null
}
type Status = {
running: boolean
mode: CaptureMode
pending: number
jobs: number
}
type LoginStart = {
userCode: string
verificationUri: string
verificationUriComplete?: string | null
expiresIn: number
interval: number
}
const app = document.querySelector<HTMLDivElement>('#app')!
let config: AppConfig
let jobs: Job[] = []
let status: Status
let loginTimer: number | undefined
let currentVersion = '0.1.3'
let bundleType: BundleType | null = null
let availableUpdate: Update | null = null
let updateBusy = false
let systemPrinters: SystemPrinter[] = []
let virtualPrinterStatus: VirtualPrinterStatus | null = null
app.innerHTML = `
<div class="shell">
<header>
<div class="brand">
<div class="mark">FL</div>
<div><h1>FieldLens Print</h1><p class="subtitle">Digital barcode capture before the label leaves the printer.</p></div>
</div>
<div id="status-pill" class="status"><span class="dot"></span><span>Loading</span></div>
</header>
<div class="grid">
<section class="card">
<div class="card-head"><h2>Capture</h2><span class="muted">RAW + spool routes</span></div>
<div class="card-body">
<div class="fields">
<label>Capture mode
<select id="mode">
<option value="raw-proxy">RAW 9100 proxy</option>
<option value="spool-folder">Spool / virtual-printer drop</option>
<option value="both">Both routes</option>
</select>
</label>
<label>Listen address
<input id="listenAddr" placeholder="127.0.0.1:9100" />
</label>
<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" />
<span class="help">Auto-filled for network printers. Keep as the manual fallback for USB/shared queues.</span>
</label>
<label>Spool capture folder
<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>
</label>
</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">
<button class="btn" id="save">Save settings</button>
<button class="btn primary" id="start">Start capture</button>
<button class="btn stop" id="stop">Stop</button>
</div>
</div>
</section>
<section class="card">
<div class="card-head"><h2>FieldLens</h2><span class="muted">Device login</span></div>
<div class="card-body">
<div class="metrics">
<div class="metric"><b id="job-count">0</b><span>Jobs</span></div>
<div class="metric"><b id="code-count">0</b><span>Codes</span></div>
<div class="metric"><b id="pending-count">0</b><span>Pending</span></div>
</div>
<div class="login-box">
<div id="login-state" class="muted">Not signed in.</div>
<div id="login-code" class="login-code"></div>
<div class="actions">
<button class="btn blue" id="login">Sign in</button>
<button class="btn" id="sync">Sync now</button>
</div>
</div>
<details style="margin-top:14px">
<summary class="muted">Connection settings</summary>
<div class="fields" style="margin-top:12px">
<label class="full">FieldLens API<input id="apiBaseUrl" /></label>
<label class="full">Keycloak issuer<input id="issuer" /></label>
<label>Desktop client<input id="clientId" /></label>
<label>Station ID<input id="stationId" placeholder="optional" /></label>
</div>
</details>
<div class="update-box">
<div>
<strong>App updates</strong>
<span id="version-label" class="muted">Version …</span>
<small id="update-state" class="muted">Checking for updates shortly after startup.</small>
</div>
<div class="actions update-actions">
<button class="btn" id="check-update">Check for updates</button>
<button class="btn blue" id="install-update" hidden>Download & restart</button>
</div>
<div id="update-progress" class="update-progress" hidden><span></span></div>
</div>
</div>
</section>
<section class="card" style="grid-column: 1 / -1">
<div class="card-head"><h2>Recent print jobs</h2><span id="job-hint" class="muted">Waiting for capture</span></div>
<div class="card-body"><div id="jobs" class="jobs"></div></div>
</section>
</div>
</div>
`
function el<T extends HTMLElement>(id: string) {
return document.getElementById(id) as T
}
function readForm(): AppConfig {
return {
...config,
mode: el<HTMLSelectElement>('mode').value as CaptureMode,
listenAddr: el<HTMLInputElement>('listenAddr').value.trim(),
forwardAddr: el<HTMLInputElement>('forwardAddr').value.trim(),
forwardPrinter: el<HTMLSelectElement>('forwardPrinter').value,
spoolDir: el<HTMLInputElement>('spoolDir').value.trim(),
apiBaseUrl: el<HTMLInputElement>('apiBaseUrl').value.trim(),
issuer: el<HTMLInputElement>('issuer').value.trim(),
clientId: el<HTMLInputElement>('clientId').value.trim(),
stationId: el<HTMLInputElement>('stationId').value.trim(),
}
}
function writeForm(next: AppConfig) {
el<HTMLSelectElement>('mode').value = next.mode
el<HTMLInputElement>('listenAddr').value = next.listenAddr
el<HTMLInputElement>('forwardAddr').value = next.forwardAddr
el<HTMLSelectElement>('forwardPrinter').value = next.forwardPrinter || ''
el<HTMLInputElement>('spoolDir').value = next.spoolDir
el<HTMLInputElement>('apiBaseUrl').value = next.apiBaseUrl
el<HTMLInputElement>('issuer').value = next.issuer
el<HTMLInputElement>('clientId').value = next.clientId
el<HTMLInputElement>('stationId').value = next.stationId
}
function renderPrinters() {
const select = el<HTMLSelectElement>('forwardPrinter')
const current = select.value || config?.forwardPrinter || ''
const options = systemPrinters.filter((printer) => !printer.isVirtual)
const emptyLabel = options.length ? 'Choose a printer…' : 'No installed printers found'
select.innerHTML = `<option value="">${emptyLabel}</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 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) {
help.textContent = 'No installed CUPS printers were found. Add the physical printer in system settings, then Refresh printers.'
} else {
help.textContent = 'Select the physical printer that should receive the original print bytes.'
}
el<HTMLButtonElement>('install-printer').disabled = !options.length
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() {
const pill = el<HTMLDivElement>('status-pill')
pill.classList.toggle('live', status?.running ?? false)
pill.querySelector('span:last-child')!.textContent = status?.running ? `Capturing · ${status.mode}` : 'Stopped'
el('job-count').textContent = String(status?.jobs ?? jobs.length)
el('code-count').textContent = String(jobs.reduce((sum, job) => sum + job.barcodes.length, 0))
el('pending-count').textContent = String(status?.pending ?? 0)
el('job-hint').textContent = jobs.length ? `${jobs.length} retained locally` : 'Waiting for capture'
el('jobs').innerHTML = jobs.length ? jobs.map((job) => `
<article class="job">
<div class="job-top"><span>${escapeHtml(job.source)} · ${escapeHtml(job.label)}</span><span>${new Date(job.capturedAt).toLocaleTimeString()}</span></div>
<strong>${job.barcodes.length ? `${job.barcodes.length} barcode${job.barcodes.length === 1 ? '' : 's'} detected` : '<span class="warn">No supported barcode command detected</span>'}</strong>
<div class="codes">${job.barcodes.map((code) => `<span class="code">${escapeHtml(code.format)} · ${escapeHtml(code.value)}</span>`).join('')}</div>
<div class="muted small" style="margin-top:7px">${job.bytes.toLocaleString()} bytes · ${job.forwarded ? 'forwarded' : 'not forwarded'}${job.error ? ` · <span class="error">${escapeHtml(job.error)}</span>` : ''}</div>
</article>
`).join('') : '<div class="muted">No jobs captured yet.</div>'
}
function escapeHtml(value: string) {
return value.replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[char]!))
}
async function refreshStatus() {
status = await invoke<Status>('get_status')
jobs = await invoke<Job[]>('recent_jobs')
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
} else if (selected) {
el<HTMLInputElement>('forwardAddr').value = ''
}
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 () => {
config = await invoke<AppConfig>('save_config', { config: readForm() })
writeForm(config)
await refreshStatus()
})
el('start').addEventListener('click', async () => {
config = await invoke<AppConfig>('save_config', { config: readForm() })
await invoke('start_capture')
await refreshStatus()
})
el('stop').addEventListener('click', async () => {
await invoke('stop_capture')
await refreshStatus()
})
el('sync').addEventListener('click', async () => {
const result = await invoke<{ synced: number; pending: number }>('sync_pending')
el('login-state').textContent = `Synced ${result.synced}. ${result.pending} pending.`
await refreshStatus()
})
el('login').addEventListener('click', async () => {
const session = await invoke<LoginStart>('begin_login')
el('login-code').textContent = session.userCode
el('login-state').textContent = 'Authorize this device in your browser…'
const target = session.verificationUriComplete || session.verificationUri
await openUrl(target)
if (loginTimer) window.clearInterval(loginTimer)
loginTimer = window.setInterval(async () => {
try {
const result = await invoke<{ status: string }>('poll_login')
if (result.status === 'authorized') {
window.clearInterval(loginTimer)
loginTimer = undefined
el('login-state').textContent = 'Signed in to FieldLens.'
el('login-code').textContent = ''
} else if (result.status === 'expired' || result.status === 'denied') {
window.clearInterval(loginTimer)
loginTimer = undefined
el('login-state').textContent = `Login ${result.status}.`
}
} catch (error) {
el('login-state').textContent = String(error)
}
}, Math.max(3, session.interval) * 1000)
})
async function checkForUpdates(manual = false) {
if (updateBusy) return
const state = el('update-state')
const installButton = el<HTMLButtonElement>('install-update')
const checkButton = el<HTMLButtonElement>('check-update')
try {
checkButton.disabled = true
state.textContent = manual ? 'Checking for updates…' : 'Checking quietly for updates…'
const update = await check()
availableUpdate = update
if (!update) {
installButton.hidden = true
state.textContent = `FieldLens Print ${currentVersion} is up to date.`
return
}
state.textContent = `Version ${update.version} is available.`
installButton.hidden = false
installButton.textContent = bundleType === BundleType.Deb ? 'Open Linux installer' : 'Download & restart'
} catch (error) {
if (manual) state.textContent = `Update check failed: ${String(error)}`
else state.textContent = 'Automatic update check unavailable. You can retry manually.'
} finally {
checkButton.disabled = false
}
}
async function installAvailableUpdate() {
if (!availableUpdate || updateBusy) return
const state = el('update-state')
const button = el<HTMLButtonElement>('install-update')
const progress = el<HTMLDivElement>('update-progress')
const bar = progress.querySelector('span') as HTMLSpanElement
if (bundleType === BundleType.Deb) {
state.textContent = 'Debian packages update through the Linux installer.'
await openUrl('https://fieldlens.arrowstack.dev/downloads')
return
}
if (status?.running) {
state.textContent = 'Stop capture before installing an update.'
return
}
updateBusy = true
button.disabled = true
progress.hidden = false
bar.style.width = '8%'
let downloaded = 0
let total = 0
try {
state.textContent = `Downloading FieldLens Print ${availableUpdate.version}…`
await availableUpdate.downloadAndInstall((event) => {
if (event.event === 'Started') {
total = event.data.contentLength ?? 0
downloaded = 0
bar.style.width = '10%'
} else if (event.event === 'Progress') {
downloaded += event.data.chunkLength
if (total > 0) {
const percent = Math.min(92, Math.max(10, Math.round((downloaded / total) * 100)))
bar.style.width = `${percent}%`
}
} else if (event.event === 'Finished') {
bar.style.width = '100%'
state.textContent = 'Update installed. Restarting…'
}
})
await relaunch()
} catch (error) {
state.textContent = `Update failed: ${String(error)}`
progress.hidden = true
} finally {
updateBusy = false
button.disabled = false
}
}
el('check-update').addEventListener('click', () => { void checkForUpdates(true) })
el('install-update').addEventListener('click', () => { void installAvailableUpdate() })
await listen<Job>('capture-job', (event) => {
jobs = [event.payload, ...jobs.filter((job) => job.id !== event.payload.id)].slice(0, 200)
void refreshStatus()
})
config = await invoke<AppConfig>('get_config')
writeForm(config)
await Promise.all([refreshStatus(), refreshPrinters()])
currentVersion = await getVersion()
bundleType = await getBundleType().catch(() => null)
el('version-label').textContent = `Version ${currentVersion}${bundleType ? ` · ${bundleType}` : ''}`
window.setTimeout(() => { void checkForUpdates(false) }, 3000)
window.setInterval(() => { void refreshStatus() }, 5000)