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
702 lines
24 KiB
TypeScript
702 lines
24 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
|
|
}
|
|
|
|
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
|
|
let loginTimer: number | undefined
|
|
let currentVersion = '0.1.10'
|
|
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 | 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 id="status-pill" class="status"><span class="dot"></span><span>Loading</span></div>
|
|
</header>
|
|
|
|
<main 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 danger" id="remove-printer">Remove</button>
|
|
</div>
|
|
|
|
<details class="advanced">
|
|
<summary>Advanced 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 ghost" id="start">Start capture</button>
|
|
<button class="btn ghost danger" id="stop">Stop capture</button>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="card activity-card">
|
|
<div class="card-head">
|
|
<div>
|
|
<span class="eyebrow">ACTIVITY</span>
|
|
<h2>FieldLens</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="account-row">
|
|
<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">
|
|
<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>
|
|
</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>
|
|
</main>
|
|
</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 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 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.barcodes.length
|
|
? `${job.barcodes.length} code${job.barcodes.length === 1 ? '' : 's'} detected`
|
|
: 'Captured · no barcode decoded yet'
|
|
|
|
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>
|
|
<time>${new Date(job.capturedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</time>
|
|
</div>
|
|
<strong class="job-result">${decodeText}</strong>
|
|
<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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]!))
|
|
}
|
|
|
|
async function refreshStatus() {
|
|
status = await invoke<Status>('get_status')
|
|
jobs = await invoke<Job[]>('recent_jobs')
|
|
render()
|
|
}
|
|
|
|
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()
|
|
})
|
|
|
|
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 restartCapture()
|
|
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)
|
|
if (status?.running) await restartCapture()
|
|
else await refreshStatus()
|
|
})
|
|
|
|
el('start').addEventListener('click', async () => {
|
|
config = await invoke<AppConfig>('save_config', { config: readForm() })
|
|
await restartCapture()
|
|
})
|
|
|
|
el('stop').addEventListener('click', async () => {
|
|
captureError = null
|
|
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…'
|
|
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 = '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
|
|
|
|
if (availableManualUpdate) {
|
|
state.textContent = 'Opening the current FieldLens Print installer…'
|
|
await openUrl(availableManualUpdate.url)
|
|
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
|
|
|
|
const signedUpdate = availableUpdate
|
|
if (!signedUpdate) return
|
|
|
|
try {
|
|
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
|
|
} 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://git.arrowstack.dev/Admin/fieldlens-desktop/raw/branch/main/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()
|
|
})
|
|
|
|
config = await invoke<AppConfig>('get_config')
|
|
writeForm(config)
|
|
await refreshPrinters()
|
|
await ensureCaptureStarted()
|
|
|
|
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)
|