Admin 91b155aa49
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(ocr): clear document placeholder after load
2026-09-25 23:52:14 +00:00

1157 lines
39 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 { initOcrRefinement } from './ocrRefinement'
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 PalletPrintStatus = 'new-pallet' | 'reprint-same' | 'reprint-changed' | 'reprint-unreadable'
type PalletFieldChange = {
field: string
previous: string
current: string
}
type PalletPrintComparison = {
palletId: string
status: PalletPrintStatus
occurrence: number
previousCapturedAt?: string | null
changes: PalletFieldChange[]
}
type Job = {
id: string
source: string
label: string
bytes: number
barcodes: Barcode[]
palletPrint?: PalletPrintComparison | null
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
}
type LoginPoll = {
status: string
accessStatus?: string | null
role?: string | null
}
type AuthStatus = {
signedIn: boolean
accessStatus: string
role?: string | null
}
type DebugCheck = {
name: string
ok: boolean
detail: string
}
type DebugSnapshot = {
platform: string
arch: string
appVersion: string
dataDir: string
captureStorage: string
captureRunning: boolean
captureMode: CaptureMode
pending: number
jobs: number
signedIn: boolean
forwardPrinter: string
listenAddr: string
forwardAddr: string
clientId: string
virtualPrinter: string
installedPrinters: number
recentErrors: string[]
checks: DebugCheck[]
}
type ManualReleaseDownload = {
url: string
sha256?: string
}
type ManualRelease = {
version: string
notes?: string
downloads: Record<string, ManualReleaseDownload>
}
type ManualUpdate = {
version: string
url: string
}
const app = document.querySelector<HTMLDivElement>('#app')!
let config: AppConfig
let jobs: Job[] = []
let status: Status = { running: false, mode: 'raw-proxy', pending: 0, jobs: 0 }
let loginTimer: number | undefined
let currentVersion = '0.1.25'
let bundleType: BundleType | null = null
let availableUpdate: Update | null = null
let availableManualUpdate: ManualUpdate | null = null
let updateBusy = false
let captureError: string | null = null
let systemPrinters: SystemPrinter[] = []
let virtualPrinterStatus: VirtualPrinterStatus = {
installed: false,
name: 'FieldLens Print',
detail: 'Checking FieldLens queues…',
}
let authStatus: AuthStatus = { signedIn: false, accessStatus: 'signed-out', role: null }
let debugEnabled = false
let debugSnapshot: DebugSnapshot | null = null
app.innerHTML = `
<div class="shell">
<header class="topbar">
<div class="brand">
<div class="mark">FL</div>
<div>
<h1>FieldLens Print</h1>
<p class="subtitle">Capture and inspect print jobs before the printer driver renders them.</p>
</div>
</div>
<div class="topbar-actions">
<nav class="view-tabs" aria-label="FieldLens Print view">
<button class="view-tab active" id="setup-tab" type="button">Setup</button>
<button class="view-tab" id="running-tab" type="button">Running</button>
<button class="view-tab" id="ocr-tab" type="button">OCR Refinement</button>
</nav>
<div id="status-pill" class="status"><span class="dot"></span><span>Loading</span></div>
</div>
</header>
<main>
<section id="setup-view" class="app-view">
<div class="grid">
<section class="card route-card">
<div class="card-head">
<div>
<span class="eyebrow">PRINTER ROUTING</span>
<h2>Destination</h2>
</div>
<button class="icon-btn" id="refresh-printers" title="Refresh printers" aria-label="Refresh printers">↻</button>
</div>
<div class="card-body">
<label class="field-label">Physical printer
<select id="forwardPrinter">
<option value="">Choose a printer…</option>
</select>
<span id="forward-printer-help" class="help">Choose the printer that should receive production jobs.</span>
</label>
<div class="route-tiles">
<div class="route-tile">
<div class="route-icon live-route">P</div>
<div>
<strong>FieldLens Print</strong>
<span>Capture + physical print</span>
</div>
</div>
<div class="route-tile">
<div class="route-icon test-route">T</div>
<div>
<strong>FieldLens Test Capture</strong>
<span>Full pipeline, no paper</span>
</div>
</div>
</div>
<div class="printer-state">
<span class="state-dot"></span>
<div>
<strong>System printer</strong>
<span id="virtual-printer-state">Checking FieldLens queues…</span>
</div>
</div>
<div class="actions printer-actions">
<button class="btn primary" id="install-printer">Create / repair printers</button>
<button class="btn ghost" id="self-test">Run capture self-test</button>
<button class="btn ghost danger" id="remove-printer">Remove</button>
</div>
</div>
</section>
<section class="card settings-card">
<div class="card-head">
<div>
<span class="eyebrow">SETTINGS</span>
<h2>FieldLens Print</h2>
</div>
<span id="settings-state" class="muted">Local configuration</span>
</div>
<div class="card-body">
<div class="account-row settings-account">
<div>
<strong>FieldLens account</strong>
<span id="login-state" class="muted">Not signed in.</span>
<div id="login-code" class="login-code"></div>
</div>
<div class="inline-actions">
<button class="btn ghost" id="sync">Sync</button>
<button class="btn blue" id="login">Sign in</button>
</div>
</div>
<details class="advanced" open>
<summary>Capture settings</summary>
<div class="advanced-body">
<div class="fields">
<label>Capture mode
<select id="mode">
<option value="raw-proxy">RAW 9100 proxy</option>
<option value="spool-folder">Spool folder</option>
<option value="both">Both routes</option>
</select>
</label>
<label>Listen address
<input id="listenAddr" placeholder="127.0.0.1:9100" />
</label>
<label>RAW/TCP destination
<input id="forwardAddr" placeholder="10.0.0.50:9100" />
</label>
<label>Spool capture folder
<input id="spoolDir" />
</label>
</div>
<div class="actions compact-actions">
<button class="btn ghost" id="save">Save settings</button>
<button class="btn blue" id="start">Start capture</button>
<button class="btn ghost danger" id="stop">Stop capture</button>
</div>
</div>
</details>
<details class="advanced">
<summary>Connection & updates</summary>
<div class="advanced-body">
<div class="fields">
<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>
<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="inline-actions">
<button class="btn ghost" id="check-update">Check</button>
<button class="btn blue" id="install-update" hidden>Install</button>
</div>
<div id="update-progress" class="update-progress" hidden><span></span></div>
</div>
</div>
</details>
<section id="debug-panel" class="debug-panel" hidden>
<div class="debug-head">
<div>
<span class="eyebrow">DEBUG MODE</span>
<strong>Diagnostics</strong>
</div>
<div class="inline-actions">
<button class="btn ghost" id="debug-refresh" type="button">Refresh</button>
<button class="btn ghost" id="debug-copy" type="button">Copy report</button>
</div>
</div>
<pre id="debug-output" class="debug-output">Loading diagnostics…</pre>
<span id="debug-action-state" class="muted"></span>
</section>
</div>
</section>
</div>
</section>
<section id="running-view" class="app-view" hidden>
<div class="running-grid">
<section class="card running-summary-card">
<div class="card-head">
<div>
<span class="eyebrow">RUNNING</span>
<h2>Capture activity</h2>
</div>
<span id="job-hint" class="muted">Waiting for capture</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="running-route">
<div>
<span class="eyebrow">PHYSICAL DESTINATION</span>
<strong id="running-printer">No printer selected</strong>
</div>
<div class="inline-actions">
<button class="btn ghost" id="running-self-test">Test capture</button>
<button class="btn ghost danger" id="running-stop">Stop capture</button>
</div>
</div>
<span id="running-action-state" class="muted running-action-state"></span>
</div>
</section>
<section class="card jobs-card">
<div class="card-head">
<div>
<span class="eyebrow">RECENT ACTIVITY</span>
<h2>Print jobs</h2>
</div>
<span class="muted">Newest first</span>
</div>
<div class="card-body">
<div id="jobs" class="jobs"></div>
</div>
</section>
</div>
</section>
<section id="ocr-view" class="app-view" hidden>
<div id="ocr-refinement-root"></div>
</section>
</main>
</div>
`
function el<T extends HTMLElement>(id: string) {
return document.getElementById(id) as T
}
function setView(view: 'setup' | 'running' | 'ocr') {
el('setup-view').hidden = view !== 'setup'
el('running-view').hidden = view !== 'running'
el('ocr-view').hidden = view !== 'ocr'
el('setup-tab').classList.toggle('active', view === 'setup')
el('running-tab').classList.toggle('active', view === 'running')
el('ocr-tab').classList.toggle('active', view === 'ocr')
}
function formatDebugReport(snapshot: DebugSnapshot) {
const checks = snapshot.checks.length
? snapshot.checks.map((check) => ` ${check.ok ? 'OK ' : 'ERR'} ${check.name}: ${check.detail}`).join('\n')
: ' (no platform command checks)'
const errors = snapshot.recentErrors.length
? snapshot.recentErrors.map((error) => ` - ${error}`).join('\n')
: ' none'
return [
`FieldLens Print ${snapshot.appVersion}`,
`Platform: ${snapshot.platform} / ${snapshot.arch}`,
`Bundle: ${bundleType ?? 'unknown'}`,
'',
`Capture: ${snapshot.captureRunning ? 'running' : 'stopped'} (${snapshot.captureMode})`,
`Pending scans: ${snapshot.pending}`,
`Recent jobs: ${snapshot.jobs}`,
`Capture storage: ${snapshot.captureStorage}`,
`Data dir: ${snapshot.dataDir}`,
'',
`Physical printer: ${snapshot.forwardPrinter || '(not selected)'}`,
`Installed printers: ${snapshot.installedPrinters}`,
`Virtual printer: ${snapshot.virtualPrinter}`,
`Listen: ${snapshot.listenAddr || '(unset)'}`,
`Forward: ${snapshot.forwardAddr || '(unset)'}`,
'',
`Auth session: ${snapshot.signedIn ? 'present' : 'none'}`,
`Access: ${authStatus.accessStatus}${authStatus.role ? ` (${authStatus.role})` : ''}`,
`OIDC client: ${snapshot.clientId}`,
'',
'Platform checks:',
checks,
'',
'Recent job errors:',
errors,
].join('\n')
}
async function refreshDebugSnapshot() {
if (!debugEnabled) return
const output = el('debug-output')
output.textContent = 'Loading diagnostics…'
try {
debugSnapshot = await invoke<DebugSnapshot>('get_debug_snapshot')
output.textContent = formatDebugReport(debugSnapshot)
} catch (error) {
output.textContent = `Debug snapshot failed: ${String(error)}`
}
}
function setDebugEnabled(enabled: boolean) {
debugEnabled = enabled
el('debug-panel').hidden = !enabled
if (enabled) {
setView('setup')
void refreshDebugSnapshot()
}
}
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)
el('running-printer').textContent = selected?.name || config?.forwardPrinter || 'No physical printer selected'
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 renderAuthStatus() {
const state = el('login-state')
const login = el<HTMLButtonElement>('login')
const sync = el<HTMLButtonElement>('sync')
if (!authStatus.signedIn) {
state.textContent = 'Not signed in.'
login.textContent = 'Sign in'
sync.disabled = true
return
}
login.textContent = 'Sign out'
if (authStatus.accessStatus === 'approved') {
state.textContent = authStatus.role
? `Signed in · ${authStatus.role}`
: 'Signed in · access approved'
sync.disabled = false
} else if (authStatus.accessStatus === 'pending') {
state.textContent = 'Signed in · access pending approval'
sync.disabled = true
} else {
state.textContent = `Signed in · access ${authStatus.accessStatus}`
sync.disabled = true
}
}
async function refreshAuthStatus() {
try {
authStatus = await invoke<AuthStatus>('get_auth_status')
} catch (error) {
authStatus = { signedIn: false, accessStatus: 'unavailable', role: null }
el('login-state').textContent = `Sign-in status unavailable: ${String(error)}`
el<HTMLButtonElement>('sync').disabled = true
el<HTMLButtonElement>('login').textContent = 'Sign in'
return
}
renderAuthStatus()
if (debugEnabled) void refreshDebugSnapshot()
}
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function friendlySource(source: string) {
if (source === 'cups-original') return 'Live print'
if (source === 'cups-test') return 'Test capture'
if (source === 'raw-proxy') return 'RAW proxy'
if (source === 'spool-folder') return 'Spool'
return source
}
function fieldLabel(field: string) {
const labels: Record<string, string> = {
origin: 'Origin',
variety: 'Variety',
brand: 'Brand',
class: 'Class',
base: 'Base',
pack: 'Pack',
grower: 'Grower',
packrun: 'Packrun',
size: 'Size',
qty: 'Qty',
pickDate: 'Pick date',
packDate: 'Pack date',
total: 'Total',
}
return labels[field] ?? field
}
function palletStatusLabel(status: PalletPrintStatus) {
if (status === 'new-pallet') return 'First print'
if (status === 'reprint-same') return 'Reprint · same'
if (status === 'reprint-changed') return 'Reprint · changed'
return 'Reprint · diff unavailable'
}
function render() {
const pill = el<HTMLDivElement>('status-pill')
const captureLive = (status?.running ?? false) && !captureError
pill.classList.toggle('live', captureLive)
pill.classList.toggle('error-state', Boolean(captureError))
pill.querySelector('span:last-child')!.textContent = captureError
? 'Capture error'
: captureLive
? 'Capture active'
: 'Capture 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 = captureError
? 'Capture needs attention'
: status?.running
? 'Ready'
: 'Stopped'
el('jobs').innerHTML = jobs.length ? jobs.map((job) => {
const source = friendlySource(job.source)
const isTest = job.source === 'cups-test'
const routeText = isTest ? 'No paper' : job.forwarded ? 'Printed' : 'Captured'
const routeClass = isTest ? 'test' : job.forwarded ? 'success' : 'neutral'
const decodeText = job.palletPrint
? `Pallet ${job.palletPrint.palletId}`
: job.barcodes.length
? `${job.barcodes.length} code${job.barcodes.length === 1 ? '' : 's'} detected`
: 'Captured · no barcode decoded yet'
const palletPrint = job.palletPrint
const palletBadge = palletPrint
? `<span class="pallet-badge ${escapeHtml(palletPrint.status)}">${escapeHtml(palletStatusLabel(palletPrint.status))}</span>`
: ''
const palletDetail = palletPrint
? `
<div class="pallet-print-detail">
<span>Print #${palletPrint.occurrence}</span>
${palletPrint.previousCapturedAt ? `<span>Previous ${new Date(palletPrint.previousCapturedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>` : ''}
</div>
${palletPrint.changes.length
? `<div class="pallet-diff">${palletPrint.changes.map((change) => `
<span><strong>${escapeHtml(fieldLabel(change.field))}</strong> ${escapeHtml(change.previous)} → ${escapeHtml(change.current)}</span>
`).join('')}</div>`
: ''}
`
: ''
return `
<article class="job">
<div class="job-main">
<div class="job-title-row">
<span class="source-badge">${escapeHtml(source)}</span>
<span class="route-badge ${routeClass}">${routeText}</span>
${palletBadge}
<time>${new Date(job.capturedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</time>
</div>
<strong class="job-result">${decodeText}</strong>
${palletDetail}
<div class="codes">${job.barcodes.map((code) => `<span class="code"><small>${escapeHtml(code.format)}</small>${escapeHtml(code.value)}</span>`).join('')}</div>
${job.error ? `<div class="job-error">${escapeHtml(job.error)}</div>` : ''}
</div>
<div class="job-meta">
<span>${formatBytes(job.bytes)}</span>
<span title="${escapeHtml(job.label)}">${escapeHtml(job.label)}</span>
</div>
</article>
`
}).join('') : `
<div class="empty-state">
<div class="empty-icon">⇢</div>
<strong>No print jobs yet</strong>
<span>Send a job to FieldLens Print or FieldLens Test Capture.</span>
</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()
if (debugEnabled) void refreshDebugSnapshot()
}
async function ensureCaptureStarted() {
try {
await invoke('start_capture')
captureError = null
} catch (error) {
captureError = String(error)
}
await refreshStatus()
return !captureError
}
async function restartCapture() {
await invoke('stop_capture').catch(() => undefined)
return ensureCaptureStarted()
}
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()
if (debugEnabled) void refreshDebugSnapshot()
})
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
let stoppedForRepair = false
try {
state.textContent = 'Stopping capture before printer repair…'
await invoke('stop_capture')
stoppedForRepair = true
await refreshStatus()
state.textContent = 'Repairing printers and migrating captured jobs… administrator approval may appear.'
virtualPrinterStatus = await invoke<VirtualPrinterStatus>('install_virtual_printer', { targetPrinter })
config = await invoke<AppConfig>('save_config', { config: readForm() })
state.textContent = 'Restarting capture and processing migrated backlog…'
const restarted = await ensureCaptureStarted()
if (!restarted) {
throw new Error(captureError || 'capture did not restart')
}
stoppedForRepair = false
await refreshPrinters()
setView('running')
} catch (error) {
state.textContent = `Printer setup failed: ${String(error)}`
if (stoppedForRepair) {
await ensureCaptureStarted().catch(() => undefined)
}
} finally {
button.disabled = false
}
})
async function submitCaptureSelfTest(button: HTMLButtonElement, state: HTMLElement) {
button.disabled = true
state.textContent = 'Submitting a no-paper capture self-test…'
try {
const result = await invoke<string>('run_capture_self_test')
state.textContent = result
window.setTimeout(() => { void refreshStatus() }, 1200)
} catch (error) {
state.textContent = `Self-test failed: ${String(error)}`
} finally {
button.disabled = false
}
}
el('self-test').addEventListener('click', () => {
void submitCaptureSelfTest(
el<HTMLButtonElement>('self-test'),
el('virtual-printer-state'),
)
})
el('running-self-test').addEventListener('click', () => {
void submitCaptureSelfTest(
el<HTMLButtonElement>('running-self-test'),
el('running-action-state'),
)
})
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)
if (status?.running) await restartCapture()
else await refreshStatus()
})
el('start').addEventListener('click', async () => {
config = await invoke<AppConfig>('save_config', { config: readForm() })
const started = await restartCapture()
if (started) setView('running')
})
el('stop').addEventListener('click', async () => {
captureError = null
await invoke('stop_capture')
await refreshStatus()
})
window.addEventListener('keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === 'd') {
event.preventDefault()
setDebugEnabled(!debugEnabled)
}
})
el('debug-refresh').addEventListener('click', () => {
void refreshDebugSnapshot()
})
el('debug-copy').addEventListener('click', async () => {
const state = el('debug-action-state')
if (!debugSnapshot) await refreshDebugSnapshot()
if (!debugSnapshot) {
state.textContent = 'No debug report available.'
return
}
try {
await navigator.clipboard.writeText(formatDebugReport(debugSnapshot))
state.textContent = 'Diagnostic report copied. Secrets and tokens are excluded.'
} catch (error) {
state.textContent = `Could not copy report: ${String(error)}`
}
})
el('setup-tab').addEventListener('click', () => setView('setup'))
el('running-tab').addEventListener('click', () => setView('running'))
el('ocr-tab').addEventListener('click', () => setView('ocr'))
el('running-stop').addEventListener('click', async () => {
const state = el('running-action-state')
state.textContent = 'Stopping capture…'
captureError = null
await invoke('stop_capture')
await refreshStatus()
state.textContent = 'Capture stopped. Open Setup to restart or change configuration.'
})
el('sync').addEventListener('click', async () => {
const state = el('login-state')
try {
const result = await invoke<{ synced: number; pending: number }>('sync_pending')
state.textContent = `Synced ${result.synced}. ${result.pending} pending.`
await refreshStatus()
} catch (error) {
state.textContent = `Sync failed: ${String(error)}`
await refreshAuthStatus()
}
})
el('login').addEventListener('click', async () => {
const login = el<HTMLButtonElement>('login')
const state = el('login-state')
if (authStatus.signedIn) {
try {
await invoke('logout')
authStatus = { signedIn: false, accessStatus: 'signed-out', role: null }
el('login-code').textContent = ''
renderAuthStatus()
} catch (error) {
state.textContent = `Sign out failed: ${String(error)}`
}
return
}
login.disabled = true
try {
const session = await invoke<LoginStart>('begin_login')
el('login-code').textContent = session.userCode
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<LoginPoll>('poll_login')
if (result.status === 'authorized') {
window.clearInterval(loginTimer)
loginTimer = undefined
el('login-code').textContent = ''
authStatus = {
signedIn: true,
accessStatus: result.accessStatus || 'unknown',
role: result.role ?? null,
}
renderAuthStatus()
} else if (result.status === 'expired' || result.status === 'denied') {
window.clearInterval(loginTimer)
loginTimer = undefined
state.textContent = `Login ${result.status}.`
}
} catch (error) {
if (loginTimer) window.clearInterval(loginTimer)
loginTimer = undefined
state.textContent = String(error)
}
}, Math.max(3, session.interval) * 1000)
} catch (error) {
state.textContent = `Sign in failed: ${String(error)}`
} finally {
login.disabled = false
}
})
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…'
availableUpdate = null
availableManualUpdate = null
if (bundleType !== BundleType.Deb) {
try {
const update = await check()
availableUpdate = update
if (update) {
state.textContent = `Version ${update.version} is available.`
installButton.hidden = false
installButton.textContent = 'Download & restart'
return
}
} catch {
// The signed updater feed may not exist yet on a freshly bootstrapped
// install. Fall back to the stable installer feed below.
}
}
const manualUpdate = await checkManualRelease()
availableManualUpdate = manualUpdate
if (manualUpdate) {
state.textContent = `Version ${manualUpdate.version} is available.`
installButton.hidden = false
installButton.textContent = bundleType === BundleType.Deb ? 'Install & restart' : 'Open installer'
return
}
installButton.hidden = true
state.textContent = `FieldLens Print ${currentVersion} is up to date.`
} 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 && !availableManualUpdate) || 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
const wasRunning = Boolean(status?.running)
updateBusy = true
button.disabled = true
try {
if (availableManualUpdate && bundleType === BundleType.Deb) {
progress.hidden = false
bar.style.width = '20%'
state.textContent = 'Stopping capture before update…'
if (wasRunning) {
await invoke('stop_capture')
await refreshStatus()
}
state.textContent = `Downloading and verifying FieldLens Print ${availableManualUpdate.version}…`
bar.style.width = '45%'
const result = await invoke<{ status: string; version: string }>('install_linux_update')
if (result.status === 'up-to-date') {
state.textContent = `FieldLens Print ${result.version} is already installed.`
progress.hidden = true
if (wasRunning) await ensureCaptureStarted()
return
}
bar.style.width = '100%'
state.textContent = `FieldLens Print ${result.version} installed. Restarting…`
await relaunch()
return
}
if (availableManualUpdate) {
if (wasRunning) {
state.textContent = 'Stopping capture before opening the installer…'
await invoke('stop_capture')
await refreshStatus()
}
state.textContent = 'Opening the current FieldLens Print installer…'
try {
await openUrl(availableManualUpdate.url)
state.textContent = 'Installer opened. Complete the update, then restart FieldLens Print.'
} catch (error) {
if (wasRunning) await ensureCaptureStarted().catch(() => undefined)
throw error
}
return
}
progress.hidden = false
bar.style.width = '8%'
let downloaded = 0
let total = 0
const signedUpdate = availableUpdate
if (!signedUpdate) return
if (wasRunning) {
state.textContent = 'Stopping capture before update…'
await invoke('stop_capture')
await refreshStatus()
}
state.textContent = `Downloading FieldLens Print ${signedUpdate.version}…`
await signedUpdate.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
if (wasRunning) await ensureCaptureStarted().catch(() => undefined)
} finally {
updateBusy = false
button.disabled = false
}
}
function compareVersions(left: string, right: string) {
const a = left.split('.').map((part) => Number.parseInt(part, 10) || 0)
const b = right.split('.').map((part) => Number.parseInt(part, 10) || 0)
const length = Math.max(a.length, b.length)
for (let index = 0; index < length; index += 1) {
const diff = (a[index] ?? 0) - (b[index] ?? 0)
if (diff !== 0) return diff
}
return 0
}
async function checkManualRelease(): Promise<ManualUpdate | null> {
const response = await fetch(
'https://fieldlens.arrowstack.dev/downloads/release.json',
{ cache: 'no-store' },
)
if (!response.ok) throw new Error(`release feed returned HTTP ${response.status}`)
const release = await response.json() as ManualRelease
if (!release.version || compareVersions(release.version, currentVersion) <= 0) return null
const key = bundleType === BundleType.Deb
? 'linux-x86_64-deb'
: navigator.userAgent.includes('Windows')
? 'windows-x86_64'
: null
if (!key) return null
const download = release.downloads?.[key]
if (!download?.url) return null
return { version: release.version, url: download.url }
}
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()
})
initOcrRefinement(el('ocr-refinement-root'))
config = await invoke<AppConfig>('get_config')
writeForm(config)
await refreshPrinters()
await ensureCaptureStarted()
void refreshAuthStatus()
const readyForRunning = Boolean(
virtualPrinterStatus?.installed
&& config.forwardPrinter
&& status?.running
&& !captureError
)
setView(readyForRunning ? 'running' : 'setup')
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)