Compare commits

..

1 Commits

Author SHA1 Message Date
6e05abca93 fix(packaging): configure generated icons for Linux AppImage 2026-09-26 08:05:31 +00:00
11 changed files with 24 additions and 180 deletions

View File

@ -1,4 +1,4 @@
FROM node:24-trixie AS build FROM node:24-bookworm AS build
WORKDIR /workspace WORKDIR /workspace
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
@ -19,9 +19,6 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-ins
wget \ wget \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Tauri's NSIS template requires Win/RestartManager.nsh (NSIS 3.11+).
RUN test -f /usr/share/nsis/Include/Win/RestartManager.nsh
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal
ENV PATH="/root/.cargo/bin:$PATH" ENV PATH="/root/.cargo/bin:$PATH"
ENV CARGO_BUILD_JOBS=1 ENV CARGO_BUILD_JOBS=1
@ -34,7 +31,7 @@ RUN npm install
COPY . . COPY . .
RUN node scripts/check-signing.mjs RUN test -n "$TAURI_SIGNING_PRIVATE_KEY"
RUN nice -n 10 npm run bundle:windows RUN nice -n 10 npm run bundle:windows
RUN set -eux; \ RUN set -eux; \

View File

@ -14,7 +14,6 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-ins
python3 \ python3 \
librsvg2-dev \ librsvg2-dev \
wget \ wget \
xdg-utils \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal
@ -27,8 +26,8 @@ RUN npm install
COPY . . COPY . .
RUN npm run icons RUN npm run icons
RUN node scripts/check-signing.mjs
RUN cargo test --manifest-path src-tauri/Cargo.toml --lib RUN cargo test --manifest-path src-tauri/Cargo.toml --lib
RUN test -n "$TAURI_SIGNING_PRIVATE_KEY"
RUN nice -n 10 npm run bundle:linux RUN nice -n 10 npm run bundle:linux
RUN set -eux; \ RUN set -eux; \

View File

@ -1,6 +1,6 @@
{ {
"name": "@fieldlens/print", "name": "@fieldlens/print",
"version": "0.1.30", "version": "0.1.28",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -1,40 +0,0 @@
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import { createHash, createPublicKey, verify } from 'node:crypto';
const temp = mkdtempSync(join(tmpdir(), 'fieldlens-signing-'));
try {
if (!process.env.TAURI_SIGNING_PRIVATE_KEY) throw new Error('missing key');
const probe = join(temp, 'probe.txt');
writeFileSync(probe, 'FieldLens updater signing preflight\n', { mode: 0o600 });
const result = spawnSync(process.execPath, [resolve('node_modules/@tauri-apps/cli/tauri.js'), 'signer', 'sign', probe], {
env: process.env, stdio: 'pipe', timeout: 30000,
});
if (result.status !== 0) throw new Error('signing failed');
const config = JSON.parse(readFileSync('src-tauri/tauri.conf.json', 'utf8'));
const publicLines = Buffer.from(config.plugins.updater.pubkey, 'base64').toString().trim().split(/\r?\n/);
const signatureLines = Buffer.from(readFileSync(probe + '.sig', 'utf8').trim(), 'base64').toString().trim().split(/\r?\n/);
const pub = Buffer.from(publicLines[1], 'base64');
const sig = Buffer.from(signatureLines[1], 'base64');
if (pub.length !== 42 || sig.length !== 74 || !pub.subarray(2, 10).equals(sig.subarray(2, 10))) throw new Error('key mismatch');
const key = createPublicKey({
key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), pub.subarray(10)]),
format: 'der', type: 'spki',
});
const algorithm = sig.subarray(0, 2).toString();
if (!['ED', 'Ed'].includes(algorithm)) throw new Error('unsupported signature');
const message = readFileSync(probe);
const signed = algorithm === 'ED' ? createHash('blake2b512').update(message).digest() : message;
if (!verify(null, signed, key, sig.subarray(10))) throw new Error('invalid signature');
if (!signatureLines[2].startsWith('trusted comment: ')) throw new Error('invalid comment');
const comment = Buffer.from(signatureLines[2].slice('trusted comment: '.length));
if (!verify(null, Buffer.concat([sig.subarray(10), comment]), key, Buffer.from(signatureLines[3], 'base64'))) throw new Error('invalid trusted comment');
console.log('Updater signing preflight passed: key, password and public key match.');
} catch {
console.error('Updater signing preflight failed: check the signing key/password and configured public key.');
process.exitCode = 1;
} finally {
rmSync(temp, { recursive: true, force: true });
}

View File

@ -1,6 +1,6 @@
[package] [package]
name = "fieldlens-print" name = "fieldlens-print"
version = "0.1.30" version = "0.1.28"
description = "FieldLens print-stream barcode capture companion" description = "FieldLens print-stream barcode capture companion"
authors = ["Arrowstack"] authors = ["Arrowstack"]
edition = "2021" edition = "2021"

View File

@ -457,7 +457,9 @@ fn process_captured_pdf(
let target = unique_processed_path(processed_dir, label); let target = unique_processed_path(processed_dir, label);
if let Err(error) = fs::rename(path, &target) { if let Err(error) = fs::rename(path, &target) {
let job = JobSummary { state.push_job(
app,
JobSummary {
id, id,
source: source.into(), source: source.into(),
label: label.to_string(), label: label.to_string(),
@ -467,9 +469,8 @@ fn process_captured_pdf(
captured_at: chrono::Utc::now().to_rfc3339(), captured_at: chrono::Utc::now().to_rfc3339(),
forwarded, forwarded,
error: Some(format!("archive captured PDF failed: {error}")), error: Some(format!("archive captured PDF failed: {error}")),
}; },
queue_capture_diagnostic(state, &job, "pdf"); );
state.push_job(app, job);
return; return;
} }
@ -494,7 +495,6 @@ fn process_captured_pdf(
Ok(count) => count, Ok(count) => count,
Err(error) => { Err(error) => {
job.error = Some(error); job.error = Some(error);
queue_capture_diagnostic(state, &job, "pdf");
state.push_job(app, job); state.push_job(app, job);
return; return;
} }
@ -507,7 +507,6 @@ fn process_captured_pdf(
page.saturating_sub(1), page.saturating_sub(1),
page_count page_count
)); ));
queue_capture_diagnostic(state, &job, "pdf");
state.push_job(app, job); state.push_job(app, job);
return; return;
} }
@ -559,7 +558,6 @@ fn process_captured_pdf(
} }
} }
} }
queue_capture_diagnostic(state, &job, "pdf");
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@ -963,7 +961,6 @@ fn process_payload(
}; };
let error = error.or(parse_error); let error = error.or(parse_error);
let captured_at = chrono::Utc::now().to_rfc3339(); let captured_at = chrono::Utc::now().to_rfc3339();
let stream_format = classify_print_stream(&bytes);
let pallet_print = barcodes.first().map(|barcode| { let pallet_print = barcodes.first().map(|barcode| {
let text = String::from_utf8_lossy(&bytes); let text = String::from_utf8_lossy(&bytes);
let snapshot = parse_pallet_snapshot(&text, &barcode.value); let snapshot = parse_pallet_snapshot(&text, &barcode.value);
@ -983,7 +980,9 @@ fn process_payload(
comparison comparison
}); });
let job = JobSummary { state.push_job(
app,
JobSummary {
id, id,
source: source.into(), source: source.into(),
label, label,
@ -993,53 +992,6 @@ fn process_payload(
captured_at, captured_at,
forwarded, forwarded,
error, error,
}; },
queue_capture_diagnostic(state, &job, stream_format); );
state.push_job(app, job);
}
fn queue_capture_diagnostic(state: &SharedState, job: &JobSummary, stream_format: &str) {
state.queue_print_telemetry(crate::model::PrintTelemetry {
id: job.id.clone(),
source: job.source.clone(),
stream_format: stream_format.into(),
byte_count: job.bytes,
barcode_count: job.barcodes.len(),
forwarded: job.forwarded,
failure_kind: job.error.as_ref().map(|message| {
if message.contains("parser") || message.contains("decode") || message.contains("pdfinfo") {
"parser"
} else if message.contains("forward") {
"forward"
} else {
"capture"
}.into()
}),
app_version: env!("CARGO_PKG_VERSION").into(),
captured_at: job.captured_at.clone(),
});
}
fn classify_print_stream(bytes: &[u8]) -> &'static str {
if bytes.starts_with(b"%PDF-") { "pdf" }
else if bytes.starts_with(b"%!PS") { "postscript" }
else if bytes.starts_with(b"@PJL") || bytes.starts_with(b"\x1b%-12345X@PJL") { "pjl" }
else if bytes.starts_with(b"\x1bE") { "pcl" }
else if bytes.starts_with(b"PK\x03\x04") { "zip" }
else if bytes.windows(3).take(4096).any(|part| part == b"^XA") { "zpl" }
else { "unknown" }
}
#[cfg(test)]
mod telemetry_tests {
use super::classify_print_stream;
#[test]
fn classifies_known_print_headers_without_retaining_bytes() {
assert_eq!(classify_print_stream(b"@PJL ENTER LANGUAGE=PCL"), "pjl");
assert_eq!(classify_print_stream(b"\x1bEprinter"), "pcl");
assert_eq!(classify_print_stream(b"^XA^BC"), "zpl");
assert_eq!(classify_print_stream(b"%PDF-1.4"), "pdf");
assert_eq!(classify_print_stream(b"binary"), "unknown");
}
} }

View File

@ -283,8 +283,7 @@ fn refresh_access_token(state: &SharedState) -> Result<String> {
pub fn sync_pending(state: &SharedState) -> Result<SyncResult> { pub fn sync_pending(state: &SharedState) -> Result<SyncResult> {
let pending = state.pending.lock().clone(); let pending = state.pending.lock().clone();
let telemetry = state.pending_telemetry.lock().clone(); if pending.is_empty() {
if pending.is_empty() && telemetry.is_empty() {
return Ok(SyncResult { return Ok(SyncResult {
synced: 0, synced: 0,
pending: 0, pending: 0,
@ -295,17 +294,8 @@ pub fn sync_pending(state: &SharedState) -> Result<SyncResult> {
let client = client()?; let client = client()?;
let device_id = ensure_device(&client, state, &access_token)?; let device_id = ensure_device(&client, state, &access_token)?;
let telemetry_batch: Vec<_> = telemetry.into_iter().take(100).collect(); let batch: Vec<ScanEvent> = pending.into_iter().take(100).collect();
let batch: Vec<ScanEvent> = pending.into_iter().take(100 - telemetry_batch.len()).collect(); let events: Vec<Value> = batch
let mut events: Vec<Value> = telemetry_batch.iter().map(|item| json!({
"id": item.id,
"aggregateId": item.id,
"eventType": "print.capture.diagnostic",
"schemaVersion": 1,
"createdAt": item.captured_at,
"payload": item,
})).collect();
events.extend(batch
.iter() .iter()
.map(|scan| { .map(|scan| {
json!({ json!({
@ -326,7 +316,7 @@ pub fn sync_pending(state: &SharedState) -> Result<SyncResult> {
} }
}) })
}) })
.collect::<Vec<Value>>()); .collect();
let config = state.config.lock().clone(); let config = state.config.lock().clone();
let response = client let response = client
@ -365,7 +355,6 @@ pub fn sync_pending(state: &SharedState) -> Result<SyncResult> {
queue.retain(|scan| !accepted.contains(&scan.id)); queue.retain(|scan| !accepted.contains(&scan.id));
} }
state.persist_pending()?; state.persist_pending()?;
state.acknowledge_print_telemetry(&accepted)?;
Ok(SyncResult { Ok(SyncResult {
synced, synced,

View File

@ -134,20 +134,6 @@ pub struct JobSummary {
pub error: Option<String>, pub error: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrintTelemetry {
pub id: String,
pub source: String,
pub stream_format: String,
pub byte_count: usize,
pub barcode_count: usize,
pub forwarded: bool,
pub failure_kind: Option<String>,
pub app_version: String,
pub captured_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ScanEvent { pub struct ScanEvent {

View File

@ -14,7 +14,7 @@ use tauri::{AppHandle, Emitter};
use crate::{ use crate::{
model::{ model::{
AppConfig, AuthState, JobSummary, PalletPrintComparison, PalletPrintRecord, AppConfig, AuthState, JobSummary, PalletPrintComparison, PalletPrintRecord,
PalletPrintStatus, PalletSnapshot, PrintTelemetry, ScanEvent, PalletPrintStatus, PalletSnapshot, ScanEvent,
}, },
pallet::{comparable_field_count, diff_snapshots}, pallet::{comparable_field_count, diff_snapshots},
}; };
@ -27,8 +27,6 @@ pub struct SharedState {
pub config: Arc<Mutex<AppConfig>>, pub config: Arc<Mutex<AppConfig>>,
pub jobs: Arc<Mutex<VecDeque<JobSummary>>>, pub jobs: Arc<Mutex<VecDeque<JobSummary>>>,
pub pending: Arc<Mutex<Vec<ScanEvent>>>, pub pending: Arc<Mutex<Vec<ScanEvent>>>,
pub pending_telemetry: Arc<Mutex<Vec<PrintTelemetry>>>,
telemetry_io: Arc<Mutex<()>>,
pub print_history: Arc<Mutex<Vec<PalletPrintRecord>>>, pub print_history: Arc<Mutex<Vec<PalletPrintRecord>>>,
pub auth: Arc<Mutex<AuthState>>, pub auth: Arc<Mutex<AuthState>>,
pub data_dir: PathBuf, pub data_dir: PathBuf,
@ -53,7 +51,6 @@ impl SharedState {
fs::create_dir_all(&config.spool_dir).ok(); fs::create_dir_all(&config.spool_dir).ok();
let pending = read_json::<Vec<ScanEvent>>(&data_dir.join("pending.json")).unwrap_or_default(); let pending = read_json::<Vec<ScanEvent>>(&data_dir.join("pending.json")).unwrap_or_default();
let pending_telemetry = read_json::<Vec<PrintTelemetry>>(&data_dir.join("pending-telemetry.json")).unwrap_or_default();
let print_history = let print_history =
read_json::<Vec<PalletPrintRecord>>(&data_dir.join("print-history.json")) read_json::<Vec<PalletPrintRecord>>(&data_dir.join("print-history.json"))
.unwrap_or_default(); .unwrap_or_default();
@ -68,8 +65,6 @@ impl SharedState {
config: Arc::new(Mutex::new(config)), config: Arc::new(Mutex::new(config)),
jobs: Arc::new(Mutex::new(VecDeque::new())), jobs: Arc::new(Mutex::new(VecDeque::new())),
pending: Arc::new(Mutex::new(pending)), pending: Arc::new(Mutex::new(pending)),
pending_telemetry: Arc::new(Mutex::new(pending_telemetry)),
telemetry_io: Arc::new(Mutex::new(())),
print_history: Arc::new(Mutex::new(print_history)), print_history: Arc::new(Mutex::new(print_history)),
auth: Arc::new(Mutex::new(auth)), auth: Arc::new(Mutex::new(auth)),
data_dir, data_dir,
@ -86,22 +81,6 @@ impl SharedState {
write_json_atomic(&self.data_dir.join("pending.json"), &*self.pending.lock()) write_json_atomic(&self.data_dir.join("pending.json"), &*self.pending.lock())
} }
pub fn persist_pending_telemetry(&self) -> Result<()> {
write_json_atomic(&self.data_dir.join("pending-telemetry.json"), &*self.pending_telemetry.lock())
}
pub fn queue_print_telemetry(&self, event: PrintTelemetry) {
let _guard = self.telemetry_io.lock();
self.pending_telemetry.lock().push(event);
let _ = self.persist_pending_telemetry();
}
pub fn acknowledge_print_telemetry(&self, accepted: &std::collections::HashSet<String>) -> Result<()> {
let _guard = self.telemetry_io.lock();
self.pending_telemetry.lock().retain(|item| !accepted.contains(&item.id));
self.persist_pending_telemetry()
}
pub fn persist_print_history(&self) -> Result<()> { pub fn persist_print_history(&self) -> Result<()> {
write_json_atomic( write_json_atomic(
&self.data_dir.join("print-history.json"), &self.data_dir.join("print-history.json"),
@ -245,7 +224,7 @@ impl SharedState {
} }
pub fn pending_count(&self) -> usize { pub fn pending_count(&self) -> usize {
self.pending.lock().len() + self.pending_telemetry.lock().len() self.pending.lock().len()
} }
} }

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "FieldLens Print", "productName": "FieldLens Print",
"version": "0.1.30", "version": "0.1.28",
"identifier": "dev.arrowstack.fieldlens.print", "identifier": "dev.arrowstack.fieldlens.print",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@ -58,7 +58,7 @@
}, },
"plugins": { "plugins": {
"updater": { "updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDYwNzYwMEYwN0YwNjI4QTEKUldTaEtBWi84QUIyWUxYUVg3V0RqSDlJbVc4Ymh2TEh5dDFHZnNDUURlTjVuc09WZzdmajM1VzEK", "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDAwQURCMzYxRTNGQTJBODgKUldTSUt2cmpZYk90QU1KNEtLVmgzZ1FFd1FmMnY0SU1ETVZMMVVQSE9TM2VsS1RJdDlTZHFFS3UK",
"endpoints": [ "endpoints": [
"https://fieldlens.arrowstack.dev/downloads/latest.json" "https://fieldlens.arrowstack.dev/downloads/latest.json"
], ],

View File

@ -142,7 +142,7 @@ let config: AppConfig
let jobs: Job[] = [] let jobs: Job[] = []
let status: Status = { running: false, mode: 'raw-proxy', pending: 0, jobs: 0 } let status: Status = { running: false, mode: 'raw-proxy', pending: 0, jobs: 0 }
let loginTimer: number | undefined let loginTimer: number | undefined
let currentVersion = '0.1.30' let currentVersion = '0.1.28'
let bundleType: BundleType | null = null let bundleType: BundleType | null = null
let availableUpdate: Update | null = null let availableUpdate: Update | null = null
let availableManualUpdate: ManualUpdate | null = null let availableManualUpdate: ManualUpdate | null = null
@ -904,20 +904,6 @@ el('sync').addEventListener('click', async () => {
} }
}) })
let automaticSyncInFlight = false
async function syncAfterLogin() {
if (automaticSyncInFlight || !authStatus.signedIn || authStatus.accessStatus !== 'approved') return
automaticSyncInFlight = true
try {
await invoke('sync_pending')
await refreshStatus()
} catch {
// Keep local outbox entries for the next attempt or manual sync.
} finally {
automaticSyncInFlight = false
}
}
el('login').addEventListener('click', async () => { el('login').addEventListener('click', async () => {
const login = el<HTMLButtonElement>('login') const login = el<HTMLButtonElement>('login')
const state = el('login-state') const state = el('login-state')
@ -956,7 +942,6 @@ el('login').addEventListener('click', async () => {
role: result.role ?? null, role: result.role ?? null,
} }
renderAuthStatus() renderAuthStatus()
void syncAfterLogin()
} else if (result.status === 'expired' || result.status === 'denied') { } else if (result.status === 'expired' || result.status === 'denied') {
window.clearInterval(loginTimer) window.clearInterval(loginTimer)
loginTimer = undefined loginTimer = undefined
@ -1164,7 +1149,6 @@ el('install-update').addEventListener('click', () => { void installAvailableUpda
await listen<Job>('capture-job', (event) => { await listen<Job>('capture-job', (event) => {
jobs = [event.payload, ...jobs.filter((job) => job.id !== event.payload.id)].slice(0, 200) jobs = [event.payload, ...jobs.filter((job) => job.id !== event.payload.id)].slice(0, 200)
void refreshStatus() void refreshStatus()
void syncAfterLogin()
}) })
initOcrRefinement(el('ocr-refinement-root')) initOcrRefinement(el('ocr-refinement-root'))
@ -1174,8 +1158,6 @@ writeForm(config)
await refreshPrinters() await refreshPrinters()
await ensureCaptureStarted() await ensureCaptureStarted()
void refreshAuthStatus() void refreshAuthStatus()
window.setTimeout(() => { void syncAfterLogin() }, 5000)
window.setInterval(() => { void syncAfterLogin() }, 30000)
const readyForRunning = Boolean( const readyForRunning = Boolean(
virtualPrinterStatus?.installed virtualPrinterStatus?.installed