deploy: sync FieldLens Print and camera lifecycle
Some checks failed
CI / verify (push) Has been cancelled
CI / print-rust (push) Has been cancelled
Desktop Release / Windows x64 NSIS (push) Has been cancelled
Desktop Release / Linux x64 DEB (push) Has been cancelled
Desktop Release / Publish tagged desktop release (push) Has been cancelled

This commit is contained in:
Admin 2026-09-23 21:31:42 +00:00
parent c91c059b31
commit 386ca99889
38 changed files with 2686 additions and 146 deletions

View File

@ -1,6 +1,7 @@
node_modules node_modules
**/node_modules **/node_modules
**/dist **/dist
apps/print
.git .git
.env .env
.env.* .env.*

View File

@ -6,6 +6,9 @@ KEYCLOAK_ISSUER=https://auth.example.invalid/realms/example
KEYCLOAK_JWKS_URI=https://auth.example.invalid/realms/example/protocol/openid-connect/certs KEYCLOAK_JWKS_URI=https://auth.example.invalid/realms/example/protocol/openid-connect/certs
KEYCLOAK_AUDIENCE=fieldlens-api KEYCLOAK_AUDIENCE=fieldlens-api
KEYCLOAK_BROWSER_CLIENT_ID=fieldlens KEYCLOAK_BROWSER_CLIENT_ID=fieldlens
KEYCLOAK_DESKTOP_CLIENT_ID=fieldlens-print
KEYCLOAK_MACHINE_CLIENT_ID=fieldlens-integration KEYCLOAK_MACHINE_CLIENT_ID=fieldlens-integration
FIELDLENS_TENANT_ID=fieldlens-production FIELDLENS_TENANT_ID=fieldlens-production
WEB_ORIGIN=https://fieldlens.example.invalid WEB_ORIGIN=https://fieldlens.example.invalid
FIELDLENS_PRINT_RELEASE_TAG=fieldlens-print-v0.1.0
FIELDLENS_PRINT_RELEASE_BASE_URL=https://git.example.invalid/owner/fieldlens/releases/download

View File

@ -18,4 +18,21 @@ jobs:
- run: npm run lint - run: npm run lint
- run: npm run migration:validate - run: npm run migration:validate
- run: npm run build - run: npm run build
- run: npm run build:print
- run: docker build -t fieldlens-ci . - run: docker build -t fieldlens-ci .
print-rust:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Tauri Linux prerequisites
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- uses: dtolnay/rust-toolchain@stable
- name: Test FieldLens Print native core
working-directory: apps/print/src-tauri
run: cargo test
- name: Check FieldLens Print native core
working-directory: apps/print/src-tauri
run: cargo check

View File

@ -0,0 +1,202 @@
name: Desktop Release
on:
push:
branches: [main]
tags:
- "fieldlens-print-v*"
paths:
- "apps/print/**"
- ".gitea/workflows/desktop-release.yml"
pull_request:
paths:
- "apps/print/**"
- ".gitea/workflows/desktop-release.yml"
workflow_dispatch:
permissions:
code: read
releases: read
jobs:
windows-nsis:
name: Windows x64 NSIS
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Install Windows cross-build tools
run: |
sudo apt-get update
sudo apt-get install -y nsis lld llvm clang
cargo install --locked cargo-xwin
- name: Install Node dependencies
run: npm install
- name: Build Windows NSIS installer
run: npm run bundle:windows --workspace @fieldlens/print
env:
XWIN_CACHE_DIR: ${{ runner.temp }}/xwin-cache
- name: Stage Windows installer
shell: bash
run: |
set -euo pipefail
source_file="$(find apps/print/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis -maxdepth 1 -type f -name '*-setup.exe' -print -quit)"
test -n "$source_file"
mkdir -p dist/desktop
cp "$source_file" dist/desktop/fieldlens-print-windows-x64-setup.exe
- name: Upload Windows installer
uses: actions/upload-artifact@v3
with:
name: fieldlens-print-windows-x64
path: dist/desktop/fieldlens-print-windows-x64-setup.exe
if-no-files-found: error
retention-days: 14
linux-deb:
name: Linux x64 DEB
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- uses: dtolnay/rust-toolchain@stable
- name: Install Tauri Linux prerequisites
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
curl \
wget \
file \
libwebkit2gtk-4.1-dev \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf
- name: Install Node dependencies
run: npm install
- name: Build Debian package
run: npm run bundle:deb --workspace @fieldlens/print
- name: Stage Debian package
shell: bash
run: |
set -euo pipefail
source_file="$(find apps/print/src-tauri/target/release/bundle/deb -maxdepth 1 -type f -name '*.deb' -print -quit)"
test -n "$source_file"
mkdir -p dist/desktop
cp "$source_file" dist/desktop/fieldlens-print-linux-x64.deb
- name: Upload Debian package
uses: actions/upload-artifact@v3
with:
name: fieldlens-print-linux-x64-deb
path: dist/desktop/fieldlens-print-linux-x64.deb
if-no-files-found: error
retention-days: 14
publish-release:
name: Publish tagged desktop release
if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/fieldlens-print-v')
needs: [windows-nsis, linux-deb]
runs-on: ubuntu-latest
permissions:
code: read
releases: write
steps:
- name: Download Windows installer
uses: actions/download-artifact@v3
with:
name: fieldlens-print-windows-x64
path: release-assets
- name: Download Debian package
uses: actions/download-artifact@v3
with:
name: fieldlens-print-linux-x64-deb
path: release-assets
- name: Publish Gitea release assets
shell: bash
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_REF: ${{ gitea.ref }}
GITEA_REF_NAME: ${{ gitea.ref_name }}
GITEA_SHA: ${{ gitea.sha }}
run: |
set -euo pipefail
api="${GITEA_SERVER_URL%/}/api/v1/repos/${GITEA_REPOSITORY}"
auth_header="Authorization: token ${GITEA_TOKEN}"
release_tag="$GITEA_REF_NAME"
if [ "$GITEA_REF" = "refs/heads/main" ]; then
release_tag="fieldlens-print-v0.1.0"
fi
status="$(curl -sS -o /tmp/release.json -w '%{http_code}' \
-H "$auth_header" \
"$api/releases/tags/$release_tag")"
if [ "$status" = "404" ]; then
jq -n \
--arg tag "$release_tag" \
--arg sha "$GITEA_SHA" \
'{
tag_name: $tag,
target_commitish: $sha,
name: ("FieldLens Print " + ($tag | sub("^fieldlens-print-"; ""))),
body: "FieldLens Print desktop installers.",
draft: false,
prerelease: false
}' > /tmp/create-release.json
curl -fsS \
-X POST \
-H "$auth_header" \
-H "Content-Type: application/json" \
--data-binary @/tmp/create-release.json \
"$api/releases" > /tmp/release.json
elif [ "$status" != "200" ]; then
cat /tmp/release.json
exit 1
fi
release_id="$(jq -r '.id' /tmp/release.json)"
test "$release_id" != "null"
curl -fsS -H "$auth_header" "$api/releases/$release_id/assets" > /tmp/assets.json
for file in release-assets/fieldlens-print-windows-x64-setup.exe release-assets/fieldlens-print-linux-x64.deb; do
name="$(basename "$file")"
existing_id="$(jq -r --arg name "$name" '.[] | select(.name == $name) | .id' /tmp/assets.json | head -n 1)"
if [ -n "$existing_id" ] && [ "$existing_id" != "null" ]; then
curl -fsS -X DELETE -H "$auth_header" "$api/releases/$release_id/assets/$existing_id"
fi
curl -fsS \
-X POST \
-H "$auth_header" \
-F "attachment=@$file" \
"$api/releases/$release_id/assets?name=$name"
done

View File

@ -33,3 +33,49 @@ docker build -t fieldlens .
``` ```
For Arrowstack `database-per-tenant` deployments, the restricted application runtime identity owns DDL inside its dedicated database. The API applies versioned migrations at startup under a PostgreSQL advisory lock before it begins listening; applied migration filenames and checksums are recorded so startup is idempotent and changed historical migrations fail closed. For Arrowstack `database-per-tenant` deployments, the restricted application runtime identity owns DDL inside its dedicated database. The API applies versioned migrations at startup under a PostgreSQL advisory lock before it begins listening; applied migration filenames and checksums are recorded so startup is idempotent and changed historical migrations fail closed.
## FieldLens Print desktop companion
`apps/print` is a Tauri desktop companion that turns an outgoing print stream into FieldLens scan events without requiring a physical barcode scan.
Capture routes:
- **RAW 9100 proxy** — listen locally (default `127.0.0.1:9100`), extract supported barcode fields from ZPL, and forward the original bytes unchanged to the real printer.
- **Spool / virtual-printer folder** — watch a local folder for `.zpl`, `.raw`, `.prn`, or `.spl` jobs produced by a print-to-file or virtual-printer route. Parsed jobs can also be forwarded to the physical printer.
- **Both** — run both adapters at the same time.
The first parser handles ZPL barcode commands for Code 128, Code 39, Code 93, EAN-8/13, UPC-A/E, ITF, QR, Data Matrix, and PDF417. Non-ZPL raster/EMF/XPS rendering is intentionally a later fallback; unsupported jobs are retained in recent-job diagnostics instead of being treated as successful scans.
Authentication uses Keycloak OAuth Device Authorization Grant with the public `fieldlens-print` client. No client secret is embedded in the desktop executable. Access and refresh tokens are kept in memory only; unsynced barcode events are stored locally under the Tauri app-data directory.
### Development
```bash
npm install
npm run build --workspace @fieldlens/print
npm run tauri --workspace @fieldlens/print -- dev
```
The Windows NSIS bundle is configured for `currentUser` installation, so installing FieldLens Print itself does not require elevation. Configuring or changing a Windows printer queue/port may still require IT/admin rights depending on machine policy.
### Desktop release artifacts
The `.gitea/workflows/desktop-release.yml` workflow packages FieldLens Print on changes to the desktop app, on `main`, and on `fieldlens-print-v*` tags.
Artifacts:
- **Windows x64:** NSIS current-user installer from `apps/print/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe`
- **Linux x64:** Debian package from `apps/print/src-tauri/target/release/bundle/deb/*.deb`
After a successful Gitea Actions run, download the corresponding `fieldlens-print-windows-x64` or `fieldlens-print-linux-x64-deb` artifact from the workflow run.
Local packaging commands:
```bash
npm run bundle:windows --workspace @fieldlens/print
npm run bundle:deb --workspace @fieldlens/print
```
The Windows package is cross-compiled with `cargo-xwin` and bundled with NSIS. MSI packaging is intentionally not part of the Linux runner workflow because WiX/MSI bundling requires Windows.

View File

@ -4,9 +4,12 @@ KEYCLOAK_ISSUER=https://auth.example.invalid/realms/example
KEYCLOAK_JWKS_URI=https://auth.example.invalid/realms/example/protocol/openid-connect/certs KEYCLOAK_JWKS_URI=https://auth.example.invalid/realms/example/protocol/openid-connect/certs
KEYCLOAK_AUDIENCE=fieldlens-api KEYCLOAK_AUDIENCE=fieldlens-api
KEYCLOAK_BROWSER_CLIENT_ID=fieldlens KEYCLOAK_BROWSER_CLIENT_ID=fieldlens
KEYCLOAK_DESKTOP_CLIENT_ID=fieldlens-print
KEYCLOAK_MACHINE_CLIENT_ID=fieldlens-integration KEYCLOAK_MACHINE_CLIENT_ID=fieldlens-integration
FIELDLENS_TENANT_ID=fieldlens-production FIELDLENS_TENANT_ID=fieldlens-production
FIELDLENS_ENV=development FIELDLENS_ENV=development
LOG_LEVEL=info LOG_LEVEL=info
WEB_ORIGIN=http://localhost:5173 WEB_ORIGIN=http://localhost:5173
STATIC_DIR= STATIC_DIR=
FIELDLENS_PRINT_RELEASE_TAG=fieldlens-print-v0.1.0
FIELDLENS_PRINT_RELEASE_BASE_URL=https://git.example.invalid/owner/fieldlens/releases/download

View File

@ -33,6 +33,7 @@ export function rolesFromPayload(payload: JWTPayload) {
return [...new Set([ return [...new Set([
...strings(resourceAccess?.[config.audience]?.roles), ...strings(resourceAccess?.[config.audience]?.roles),
...strings(resourceAccess?.[config.browserClientId]?.roles), ...strings(resourceAccess?.[config.browserClientId]?.roles),
...strings(resourceAccess?.[config.desktopClientId]?.roles),
...strings(resourceAccess?.[config.machineClientId]?.roles), ...strings(resourceAccess?.[config.machineClientId]?.roles),
])] ])]
} }
@ -47,6 +48,7 @@ function intendedForFieldLens(payload: JWTPayload) {
const azp = typeof payload.azp === 'string' ? payload.azp : '' const azp = typeof payload.azp === 'string' ? payload.azp : ''
return audience.includes(config.audience) return audience.includes(config.audience)
|| azp === config.browserClientId || azp === config.browserClientId
|| azp === config.desktopClientId
|| azp === config.machineClientId || azp === config.machineClientId
} }

View File

@ -11,10 +11,13 @@ export const config = {
jwksUri: (process.env.KEYCLOAK_JWKS_URI ?? '').trim(), jwksUri: (process.env.KEYCLOAK_JWKS_URI ?? '').trim(),
audience: process.env.KEYCLOAK_AUDIENCE?.trim() || 'fieldlens-api', audience: process.env.KEYCLOAK_AUDIENCE?.trim() || 'fieldlens-api',
browserClientId: process.env.KEYCLOAK_BROWSER_CLIENT_ID?.trim() || 'fieldlens', browserClientId: process.env.KEYCLOAK_BROWSER_CLIENT_ID?.trim() || 'fieldlens',
desktopClientId: process.env.KEYCLOAK_DESKTOP_CLIENT_ID?.trim() || 'fieldlens-print',
machineClientId: process.env.KEYCLOAK_MACHINE_CLIENT_ID?.trim() || 'fieldlens-integration', machineClientId: process.env.KEYCLOAK_MACHINE_CLIENT_ID?.trim() || 'fieldlens-integration',
tenantId: process.env.FIELDLENS_TENANT_ID?.trim() || 'fieldlens-production', tenantId: process.env.FIELDLENS_TENANT_ID?.trim() || 'fieldlens-production',
environment: process.env.FIELDLENS_ENV?.trim() || 'development', environment: process.env.FIELDLENS_ENV?.trim() || 'development',
logLevel: process.env.LOG_LEVEL?.trim() || 'info', logLevel: process.env.LOG_LEVEL?.trim() || 'info',
webOrigin: process.env.WEB_ORIGIN?.trim() || 'http://localhost:5173', webOrigin: process.env.WEB_ORIGIN?.trim() || 'http://localhost:5173',
staticDir: process.env.STATIC_DIR?.trim() || '', staticDir: process.env.STATIC_DIR?.trim() || '',
printReleaseTag: process.env.FIELDLENS_PRINT_RELEASE_TAG?.trim() || 'fieldlens-print-v0.1.0',
printReleaseBaseUrl: process.env.FIELDLENS_PRINT_RELEASE_BASE_URL?.trim() || 'https://git.arrowstack.dev/Admin/fieldlens/releases/download',
} }

View File

@ -61,6 +61,16 @@ app.get('/api/health/live', (_req, res) => {
res.json({ status: 'live', version: process.env.FIELDLENS_VERSION ?? 'dev' }) res.json({ status: 'live', version: process.env.FIELDLENS_VERSION ?? 'dev' })
}) })
app.get('/downloads/fieldlens-print/windows-x64', (_req, res) => {
res.setHeader('Cache-Control', 'no-store')
res.redirect(302, `${config.printReleaseBaseUrl.replace(/\/$/, '')}/${encodeURIComponent(config.printReleaseTag)}/fieldlens-print-windows-x64-setup.exe`)
})
app.get('/downloads/fieldlens-print/linux-x64.deb', (_req, res) => {
res.setHeader('Cache-Control', 'no-store')
res.redirect(302, `${config.printReleaseBaseUrl.replace(/\/$/, '')}/${encodeURIComponent(config.printReleaseTag)}/fieldlens-print-linux-x64.deb`)
})
app.get('/api/health/ready', async (_req, res) => { app.get('/api/health/ready', async (_req, res) => {
try { try {
await databaseReady() await databaseReady()
@ -225,7 +235,8 @@ app.get('/api/v1/scans', requireAccess('supervisor'), async (req, res) => {
const limit = Math.max(1, Math.min(500, Number(req.query.limit ?? 100))) const limit = Math.max(1, Math.min(500, Number(req.query.limit ?? 100)))
const result = await pool.query(` const result = await pool.query(`
SELECT id, station_id AS "stationId", device_id AS "deviceId", raw_value AS "rawValue", SELECT id, station_id AS "stationId", device_id AS "deviceId", raw_value AS "rawValue",
format, duplicate, captured_at AS "capturedAt", received_at AS "receivedAt" format, duplicate, source, source_metadata AS "sourceMetadata",
captured_at AS "capturedAt", received_at AS "receivedAt"
FROM scans WHERE tenant_id = $1 FROM scans WHERE tenant_id = $1
ORDER BY captured_at DESC LIMIT $2 ORDER BY captured_at DESC LIMIT $2
`, [config.tenantId, limit]) `, [config.tenantId, limit])

View File

@ -9,13 +9,15 @@ async function insertScan(client: PoolClient, event: OutboxEvent, context: Devic
await client.query(` await client.query(`
INSERT INTO scans ( INSERT INTO scans (
id, tenant_id, site_id, station_id, device_id, operator_subject, id, tenant_id, site_id, station_id, device_id, operator_subject,
raw_value, format, duplicate, captured_at, schema_version raw_value, format, duplicate, captured_at, schema_version, source, source_metadata
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
ON CONFLICT (id) DO NOTHING ON CONFLICT (id) DO NOTHING
`, [ `, [
payload.id, context.tenantId, context.siteId, context.stationId, context.deviceId, payload.id, context.tenantId, context.siteId, context.stationId, context.deviceId,
identity.subject, payload.rawValue, payload.format, Boolean(payload.duplicate), identity.subject, payload.rawValue, payload.format, Boolean(payload.duplicate),
payload.capturedAt, event.schemaVersion, payload.capturedAt, event.schemaVersion,
typeof payload.source === 'string' ? payload.source : 'camera',
payload.sourceMetadata ? JSON.stringify(payload.sourceMetadata) : null,
]) ])
} }

13
apps/print/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#111827" />
<title>FieldLens Print</title>
</head>
<body>
<main id="app"></main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

22
apps/print/package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "@fieldlens/print",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 1420",
"build": "tsc --noEmit && vite build",
"tauri": "tauri",
"bundle:windows": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis",
"bundle:deb": "tauri build --bundles deb"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-opener": "^2.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"typescript": "~6.0.2",
"vite": "^8.2.0"
}
}

View File

@ -0,0 +1,25 @@
[package]
name = "fieldlens-print"
version = "0.1.0"
description = "FieldLens print-stream barcode capture companion"
authors = ["Arrowstack"]
edition = "2021"
[lib]
name = "fieldlens_print_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
anyhow = "1"
chrono = { version = "0.4", features = ["serde", "clock"] }
parking_lot = "0.12"
regex = "1"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-opener = "2"
uuid = { version = "1", features = ["v4", "serde"] }

View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "FieldLens Print desktop permissions",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}

View File

@ -0,0 +1,344 @@
use std::{
fs,
io::{ErrorKind, Read, Write},
net::{TcpListener, TcpStream},
path::PathBuf,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
thread::{self, JoinHandle},
time::Duration,
};
use anyhow::{anyhow, Context, Result};
use tauri::AppHandle;
use uuid::Uuid;
use crate::{
model::{AppConfig, CaptureMode, JobSummary},
state::SharedState,
zpl::extract_barcodes,
};
pub struct CaptureRuntime {
stop: Arc<AtomicBool>,
handles: Vec<JoinHandle<()>>,
}
impl CaptureRuntime {
pub fn stop(mut self) {
self.stop.store(true, Ordering::SeqCst);
for handle in self.handles.drain(..) {
let _ = handle.join();
}
}
}
pub fn start(config: AppConfig, state: SharedState, app: AppHandle) -> Result<CaptureRuntime> {
let stop = Arc::new(AtomicBool::new(false));
let mut handles = Vec::new();
if matches!(config.mode, CaptureMode::RawProxy | CaptureMode::Both) {
let listener = TcpListener::bind(&config.listen_addr)
.with_context(|| format!("bind RAW capture listener {}", config.listen_addr))?;
listener.set_nonblocking(true)?;
let worker_stop = stop.clone();
let worker_state = state.clone();
let worker_app = app.clone();
let worker_config = config.clone();
handles.push(thread::spawn(move || {
raw_proxy_loop(listener, worker_config, worker_state, worker_app, worker_stop)
}));
}
if matches!(config.mode, CaptureMode::SpoolFolder | CaptureMode::Both) {
let spool_dir = PathBuf::from(&config.spool_dir);
fs::create_dir_all(&spool_dir)
.with_context(|| format!("create spool capture directory {}", spool_dir.display()))?;
let worker_stop = stop.clone();
let worker_state = state.clone();
let worker_app = app.clone();
let worker_config = config.clone();
handles.push(thread::spawn(move || {
spool_folder_loop(worker_config, worker_state, worker_app, worker_stop)
}));
}
if handles.is_empty() {
return Err(anyhow!("no capture route enabled"));
}
Ok(CaptureRuntime { stop, handles })
}
fn raw_proxy_loop(
listener: TcpListener,
config: AppConfig,
state: SharedState,
app: AppHandle,
stop: Arc<AtomicBool>,
) {
while !stop.load(Ordering::SeqCst) {
match listener.accept() {
Ok((stream, peer)) => {
let state = state.clone();
let app = app.clone();
let config = config.clone();
let label = peer.to_string();
thread::spawn(move || handle_raw_connection(stream, &config, &state, &app, label));
}
Err(error) if error.kind() == ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(80));
}
Err(error) => {
let job = JobSummary {
id: Uuid::new_v4().to_string(),
source: "raw-proxy".into(),
label: "listener".into(),
bytes: 0,
barcodes: vec![],
captured_at: chrono::Utc::now().to_rfc3339(),
forwarded: false,
error: Some(format!("accept failed: {error}")),
};
state.push_job(&app, job);
thread::sleep(Duration::from_millis(500));
}
}
}
}
fn handle_raw_connection(
mut source: TcpStream,
config: &AppConfig,
state: &SharedState,
app: &AppHandle,
label: String,
) {
let _ = source.set_read_timeout(Some(Duration::from_millis(750)));
let mut forward_error: Option<String> = None;
let mut destination = if config.forward_addr.trim().is_empty() {
None
} else {
match config.forward_addr.parse() {
Ok(addr) => match TcpStream::connect_timeout(&addr, Duration::from_secs(3)) {
Ok(stream) => Some(stream),
Err(error) => {
forward_error = Some(format!("forward connect failed: {error}"));
None
}
},
Err(error) => {
forward_error = Some(format!("invalid forward address: {error}"));
None
}
}
};
let mut bytes = Vec::new();
let mut buffer = [0u8; 16 * 1024];
loop {
match source.read(&mut buffer) {
Ok(0) => break,
Ok(read) => {
let chunk = &buffer[..read];
bytes.extend_from_slice(chunk);
if let Some(target) = destination.as_mut() {
if let Err(error) = target.write_all(chunk) {
forward_error = Some(format!("forward write failed: {error}"));
destination = None;
}
}
}
Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {
if !bytes.is_empty() {
break;
}
}
Err(error) => {
forward_error = Some(format!("capture read failed: {error}"));
break;
}
}
}
if bytes.is_empty() && forward_error.is_none() {
return;
}
process_payload(
state,
app,
"raw-proxy",
label,
bytes,
!config.forward_addr.trim().is_empty() && forward_error.is_none(),
forward_error,
);
}
fn spool_folder_loop(
config: AppConfig,
state: SharedState,
app: AppHandle,
stop: Arc<AtomicBool>,
) {
let spool_dir = PathBuf::from(&config.spool_dir);
let processed_dir = spool_dir.join("processed");
let _ = fs::create_dir_all(&processed_dir);
while !stop.load(Ordering::SeqCst) {
let entries = match fs::read_dir(&spool_dir) {
Ok(entries) => entries,
Err(error) => {
state.push_job(
&app,
JobSummary {
id: Uuid::new_v4().to_string(),
source: "spool-folder".into(),
label: spool_dir.display().to_string(),
bytes: 0,
barcodes: vec![],
captured_at: chrono::Utc::now().to_rfc3339(),
forwarded: false,
error: Some(format!("spool read failed: {error}")),
},
);
thread::sleep(Duration::from_secs(1));
continue;
}
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() || !is_supported_drop(&path) {
continue;
}
let stable = entry
.metadata()
.ok()
.and_then(|metadata| metadata.modified().ok())
.and_then(|modified| modified.elapsed().ok())
.map(|age| age >= Duration::from_millis(500))
.unwrap_or(false);
if !stable {
continue;
}
let label = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("spool-job")
.to_string();
match fs::read(&path) {
Ok(bytes) => {
let (forwarded, error) = forward_all(&config.forward_addr, &bytes);
process_payload(
&state,
&app,
"spool-folder",
label.clone(),
bytes,
forwarded,
error,
);
let target = unique_processed_path(&processed_dir, &label);
let _ = fs::rename(&path, target);
}
Err(error) => process_payload(
&state,
&app,
"spool-folder",
label,
vec![],
false,
Some(format!("spool file read failed: {error}")),
),
}
}
thread::sleep(Duration::from_millis(350));
}
}
fn is_supported_drop(path: &std::path::Path) -> bool {
matches!(
path.extension()
.and_then(|value| value.to_str())
.map(|value| value.to_ascii_lowercase())
.as_deref(),
Some("zpl" | "raw" | "prn" | "spl")
)
}
fn unique_processed_path(dir: &std::path::Path, filename: &str) -> PathBuf {
let safe = filename.replace('/', "_").replace('\\', "_");
dir.join(format!(
"{}-{}-{}",
chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ"),
Uuid::new_v4(),
safe
))
}
fn forward_all(address: &str, bytes: &[u8]) -> (bool, Option<String>) {
if address.trim().is_empty() {
return (false, None);
}
let socket = match address.parse() {
Ok(value) => value,
Err(error) => return (false, Some(format!("invalid forward address: {error}"))),
};
match TcpStream::connect_timeout(&socket, Duration::from_secs(3)) {
Ok(mut stream) => match stream.write_all(bytes) {
Ok(()) => (true, None),
Err(error) => (false, Some(format!("forward write failed: {error}"))),
},
Err(error) => (false, Some(format!("forward connect failed: {error}"))),
}
}
fn process_payload(
state: &SharedState,
app: &AppHandle,
source: &str,
label: String,
bytes: Vec<u8>,
forwarded: bool,
error: Option<String>,
) {
let id = Uuid::new_v4().to_string();
let barcodes = extract_barcodes(&bytes);
for barcode in &barcodes {
state.queue_scan(
barcode.value.clone(),
barcode.format.clone(),
"print".into(),
&id,
&label,
);
}
state.push_job(
app,
JobSummary {
id,
source: source.into(),
label,
bytes: bytes.len(),
barcodes,
captured_at: chrono::Utc::now().to_rfc3339(),
forwarded,
error,
},
);
}

View File

@ -0,0 +1,330 @@
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{anyhow, Context, Result};
use reqwest::blocking::{Client, Response};
use serde::Deserialize;
use serde_json::{json, Value};
use crate::{
model::{LoginPoll, LoginStart, ScanEvent, SyncResult},
state::SharedState,
};
#[derive(Debug, Deserialize)]
struct DeviceCodeResponse {
device_code: String,
user_code: String,
verification_uri: String,
verification_uri_complete: Option<String>,
expires_in: u64,
#[serde(default = "default_interval")]
interval: u64,
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
expires_in: i64,
}
#[derive(Debug, Deserialize)]
struct OAuthError {
error: String,
}
fn default_interval() -> u64 {
5
}
fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
fn client() -> Result<Client> {
Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.context("create HTTP client")
}
pub fn begin_login(state: &SharedState) -> Result<LoginStart> {
let config = state.config.lock().clone();
let endpoint = format!(
"{}/protocol/openid-connect/auth/device",
config.issuer.trim_end_matches('/')
);
let response = client()?
.post(endpoint)
.form(&[("client_id", config.client_id.as_str())])
.send()
.context("request Keycloak device code")?;
let response = ensure_success(response, "device authorization")?;
let body: DeviceCodeResponse = response.json().context("decode device authorization response")?;
{
let mut auth = state.auth.lock();
auth.device_code = Some(body.device_code);
auth.poll_interval = body.interval;
auth.access_token = None;
auth.refresh_token = None;
auth.expires_at_unix = None;
}
Ok(LoginStart {
user_code: body.user_code,
verification_uri: body.verification_uri,
verification_uri_complete: body.verification_uri_complete,
expires_in: body.expires_in,
interval: body.interval,
})
}
pub fn poll_login(state: &SharedState) -> Result<LoginPoll> {
let config = state.config.lock().clone();
let device_code = state
.auth
.lock()
.device_code
.clone()
.ok_or_else(|| anyhow!("no device login is active"))?;
let endpoint = format!(
"{}/protocol/openid-connect/token",
config.issuer.trim_end_matches('/')
);
let response = client()?
.post(endpoint)
.form(&[
("client_id", config.client_id.as_str()),
(
"grant_type",
"urn:ietf:params:oauth:grant-type:device_code",
),
("device_code", device_code.as_str()),
])
.send()
.context("poll Keycloak device login")?;
if response.status().is_success() {
let token: TokenResponse = response.json().context("decode token response")?;
let mut auth = state.auth.lock();
auth.access_token = Some(token.access_token);
auth.refresh_token = token.refresh_token;
auth.expires_at_unix = Some(now_unix() + token.expires_in);
auth.device_code = None;
return Ok(LoginPoll {
status: "authorized".into(),
});
}
let status = response.status();
let error = response
.json::<OAuthError>()
.map(|body| body.error)
.unwrap_or_else(|_| format!("http_{}", status.as_u16()));
let mapped = match error.as_str() {
"authorization_pending" | "slow_down" => "pending",
"expired_token" => "expired",
"access_denied" => "denied",
_ => return Err(anyhow!("device login failed: {error}")),
};
Ok(LoginPoll {
status: mapped.into(),
})
}
fn ensure_access_token(state: &SharedState) -> Result<String> {
{
let auth = state.auth.lock();
if auth.expires_at_unix.unwrap_or_default() > now_unix() + 30 {
if let Some(token) = &auth.access_token {
return Ok(token.clone());
}
}
}
refresh_access_token(state)
}
fn refresh_access_token(state: &SharedState) -> Result<String> {
let config = state.config.lock().clone();
let refresh_token = state
.auth
.lock()
.refresh_token
.clone()
.ok_or_else(|| anyhow!("not signed in to FieldLens"))?;
let endpoint = format!(
"{}/protocol/openid-connect/token",
config.issuer.trim_end_matches('/')
);
let response = client()?
.post(endpoint)
.form(&[
("client_id", config.client_id.as_str()),
("grant_type", "refresh_token"),
("refresh_token", refresh_token.as_str()),
])
.send()
.context("refresh FieldLens token")?;
let response = ensure_success(response, "token refresh")?;
let token: TokenResponse = response.json().context("decode refresh response")?;
let access_token = token.access_token.clone();
let mut auth = state.auth.lock();
auth.access_token = Some(token.access_token);
if token.refresh_token.is_some() {
auth.refresh_token = token.refresh_token;
}
auth.expires_at_unix = Some(now_unix() + token.expires_in);
Ok(access_token)
}
pub fn sync_pending(state: &SharedState) -> Result<SyncResult> {
let pending = state.pending.lock().clone();
if pending.is_empty() {
return Ok(SyncResult {
synced: 0,
pending: 0,
});
}
let access_token = ensure_access_token(state)?;
let client = client()?;
let device_id = ensure_device(&client, state, &access_token)?;
let batch: Vec<ScanEvent> = pending.into_iter().take(100).collect();
let events: Vec<Value> = batch
.iter()
.map(|scan| {
json!({
"id": scan.id,
"aggregateId": scan.id,
"eventType": "scan.captured",
"schemaVersion": 1,
"createdAt": scan.captured_at,
"payload": {
"id": scan.id,
"rawValue": scan.raw_value,
"format": scan.format,
"duplicate": scan.duplicate,
"capturedAt": scan.captured_at,
"source": scan.source,
"sourceMetadata": scan.source_metadata,
"schemaVersion": 1
}
})
})
.collect();
let config = state.config.lock().clone();
let response = client
.post(format!(
"{}/api/v1/sync",
config.api_base_url.trim_end_matches('/')
))
.bearer_auth(&access_token)
.json(&json!({
"deviceId": device_id,
"schemaVersion": 1,
"events": events
}))
.send()
.context("sync print scans")?;
let response = ensure_success(response, "FieldLens sync")?;
let body: Value = response.json().context("decode FieldLens sync response")?;
let accepted: std::collections::HashSet<String> = body
.get("acknowledgements")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|ack| {
let status = ack.get("status")?.as_str()?;
if status != "accepted" && status != "duplicate" {
return None;
}
ack.get("eventId")?.as_str().map(str::to_owned)
})
.collect();
let synced = accepted.len();
{
let mut queue = state.pending.lock();
queue.retain(|scan| !accepted.contains(&scan.id));
}
state.persist_pending()?;
Ok(SyncResult {
synced,
pending: state.pending_count(),
})
}
fn ensure_device(client: &Client, state: &SharedState, access_token: &str) -> Result<String> {
let config = state.config.lock().clone();
if let Some(device_id) = config.device_id.filter(|id| !id.trim().is_empty()) {
return Ok(device_id);
}
let mut body = json!({
"installationId": config.installation_id,
"label": "FieldLens Print",
"appVersion": env!("CARGO_PKG_VERSION"),
"schemaVersion": 1
});
if !config.station_id.trim().is_empty() {
body["stationId"] = Value::String(config.station_id.clone());
}
let response = client
.post(format!(
"{}/api/v1/devices/register",
config.api_base_url.trim_end_matches('/')
))
.bearer_auth(access_token)
.json(&body)
.send()
.context("register FieldLens Print device")?;
let response = ensure_success(response, "FieldLens device registration")?;
let body: Value = response.json().context("decode device registration")?;
let device_id = body
.get("id")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("FieldLens device registration returned no id"))?
.to_string();
state.config.lock().device_id = Some(device_id.clone());
state.persist_config()?;
Ok(device_id)
}
fn ensure_success(response: Response, operation: &str) -> Result<Response> {
if response.status().is_success() {
return Ok(response);
}
let status = response.status();
let detail = response.text().unwrap_or_default();
Err(anyhow!(
"{operation} failed with {}{}",
status.as_u16(),
if detail.is_empty() {
String::new()
} else {
format!(": {detail}")
}
))
}

View File

@ -0,0 +1,108 @@
mod capture;
mod fieldlens;
mod model;
mod state;
mod zpl;
use parking_lot::Mutex;
use tauri::{AppHandle, Manager, State};
use capture::CaptureRuntime;
use model::{AppConfig, CaptureStatus, JobSummary, LoginPoll, LoginStart, SyncResult};
use state::SharedState;
struct AppState {
shared: SharedState,
runtime: Mutex<Option<CaptureRuntime>>,
}
#[tauri::command]
fn get_config(state: State<'_, AppState>) -> AppConfig {
state.shared.config.lock().clone()
}
#[tauri::command]
fn save_config(config: AppConfig, state: State<'_, AppState>) -> Result<AppConfig, String> {
state
.shared
.replace_config(config)
.map_err(|error| error.to_string())
}
#[tauri::command]
fn get_status(state: State<'_, AppState>) -> CaptureStatus {
CaptureStatus {
running: state.runtime.lock().is_some(),
mode: state.shared.config.lock().mode.clone(),
pending: state.shared.pending_count(),
jobs: state.shared.jobs.lock().len(),
}
}
#[tauri::command]
fn recent_jobs(state: State<'_, AppState>) -> Vec<JobSummary> {
state.shared.jobs.lock().iter().cloned().collect()
}
#[tauri::command]
fn start_capture(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
let mut runtime = state.runtime.lock();
if runtime.is_some() {
return Ok(());
}
let config = state.shared.config.lock().clone();
let next = capture::start(config, state.shared.clone(), app).map_err(|error| error.to_string())?;
*runtime = Some(next);
Ok(())
}
#[tauri::command]
fn stop_capture(state: State<'_, AppState>) {
if let Some(runtime) = state.runtime.lock().take() {
runtime.stop();
}
}
#[tauri::command]
fn begin_login(state: State<'_, AppState>) -> Result<LoginStart, String> {
fieldlens::begin_login(&state.shared).map_err(|error| error.to_string())
}
#[tauri::command]
fn poll_login(state: State<'_, AppState>) -> Result<LoginPoll, String> {
fieldlens::poll_login(&state.shared).map_err(|error| error.to_string())
}
#[tauri::command]
fn sync_pending(state: State<'_, AppState>) -> Result<SyncResult, String> {
fieldlens::sync_pending(&state.shared).map_err(|error| error.to_string())
}
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.setup(|app| {
let data_dir = app.path().app_data_dir()?;
let shared = SharedState::load(data_dir)
.map_err(|error| std::io::Error::other(error.to_string()))?;
app.manage(AppState {
shared,
runtime: Mutex::new(None),
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_config,
save_config,
get_status,
recent_jobs,
start_capture,
stop_capture,
begin_login,
poll_login,
sync_pending
])
.run(tauri::generate_context!())
.expect("error while running FieldLens Print");
}

View File

@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
fieldlens_print_lib::run();
}

View File

@ -0,0 +1,121 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum CaptureMode {
RawProxy,
SpoolFolder,
Both,
}
impl Default for CaptureMode {
fn default() -> Self {
Self::RawProxy
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppConfig {
pub mode: CaptureMode,
pub listen_addr: String,
pub forward_addr: String,
pub spool_dir: String,
pub api_base_url: String,
pub issuer: String,
pub client_id: String,
pub station_id: String,
pub installation_id: String,
pub device_id: Option<String>,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
mode: CaptureMode::RawProxy,
listen_addr: "127.0.0.1:9100".into(),
forward_addr: String::new(),
spool_dir: String::new(),
api_base_url: "https://fieldlens.arrowstack.dev".into(),
issuer: "https://auth.arrowstack.dev/realms/dev".into(),
client_id: "fieldlens-print".into(),
station_id: String::new(),
installation_id: Uuid::new_v4().to_string(),
device_id: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DecodedBarcode {
pub format: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JobSummary {
pub id: String,
pub source: String,
pub label: String,
pub bytes: usize,
pub barcodes: Vec<DecodedBarcode>,
pub captured_at: String,
pub forwarded: bool,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScanEvent {
pub id: String,
pub raw_value: String,
pub format: String,
pub duplicate: bool,
pub captured_at: String,
pub source: String,
pub source_metadata: Value,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CaptureStatus {
pub running: bool,
pub mode: CaptureMode,
pub pending: usize,
pub jobs: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct AuthState {
pub device_code: Option<String>,
pub access_token: Option<String>,
pub refresh_token: Option<String>,
pub expires_at_unix: Option<i64>,
pub poll_interval: u64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LoginStart {
pub user_code: String,
pub verification_uri: String,
pub verification_uri_complete: Option<String>,
pub expires_in: u64,
pub interval: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct LoginPoll {
pub status: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct SyncResult {
pub synced: usize,
pub pending: usize,
}

View File

@ -0,0 +1,123 @@
use std::{
collections::VecDeque,
fs,
path::{Path, PathBuf},
sync::Arc,
};
use anyhow::{Context, Result};
use parking_lot::Mutex;
use serde::Serialize;
use serde_json::json;
use tauri::{AppHandle, Emitter};
use crate::model::{AppConfig, AuthState, JobSummary, ScanEvent};
const MAX_JOBS: usize = 200;
#[derive(Clone)]
pub struct SharedState {
pub config: Arc<Mutex<AppConfig>>,
pub jobs: Arc<Mutex<VecDeque<JobSummary>>>,
pub pending: Arc<Mutex<Vec<ScanEvent>>>,
pub auth: Arc<Mutex<AuthState>>,
pub data_dir: PathBuf,
}
impl SharedState {
pub fn load(data_dir: PathBuf) -> Result<Self> {
fs::create_dir_all(&data_dir).context("create FieldLens Print data directory")?;
let mut config = read_json::<AppConfig>(&data_dir.join("config.json")).unwrap_or_default();
if config.spool_dir.trim().is_empty() {
config.spool_dir = data_dir.join("spool").to_string_lossy().into_owned();
}
fs::create_dir_all(&config.spool_dir).ok();
let pending = read_json::<Vec<ScanEvent>>(&data_dir.join("pending.json")).unwrap_or_default();
let state = Self {
config: Arc::new(Mutex::new(config)),
jobs: Arc::new(Mutex::new(VecDeque::new())),
pending: Arc::new(Mutex::new(pending)),
auth: Arc::new(Mutex::new(AuthState::default())),
data_dir,
};
state.persist_config()?;
Ok(state)
}
pub fn persist_config(&self) -> Result<()> {
write_json_atomic(&self.data_dir.join("config.json"), &*self.config.lock())
}
pub fn persist_pending(&self) -> Result<()> {
write_json_atomic(&self.data_dir.join("pending.json"), &*self.pending.lock())
}
pub fn replace_config(&self, next: AppConfig) -> Result<AppConfig> {
fs::create_dir_all(&next.spool_dir).ok();
*self.config.lock() = next.clone();
self.persist_config()?;
Ok(next)
}
pub fn push_job(&self, app: &AppHandle, job: JobSummary) {
{
let mut jobs = self.jobs.lock();
jobs.push_front(job.clone());
while jobs.len() > MAX_JOBS {
jobs.pop_back();
}
}
let _ = app.emit("capture-job", &job);
}
pub fn queue_scan(
&self,
raw_value: String,
format: String,
source: String,
job_id: &str,
label: &str,
) -> ScanEvent {
let mut pending = self.pending.lock();
let duplicate = pending.iter().any(|event| event.raw_value == raw_value);
let event = ScanEvent {
id: uuid::Uuid::new_v4().to_string(),
raw_value,
format,
duplicate,
captured_at: chrono::Utc::now().to_rfc3339(),
source,
source_metadata: json!({
"jobId": job_id,
"label": label,
}),
};
pending.push(event.clone());
drop(pending);
let _ = self.persist_pending();
event
}
pub fn pending_count(&self) -> usize {
self.pending.lock().len()
}
}
fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Option<T> {
let bytes = fs::read(path).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn write_json_atomic<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<()> {
let bytes = serde_json::to_vec_pretty(value)?;
let temp = path.with_extension("tmp");
fs::write(&temp, bytes)?;
if path.exists() {
fs::remove_file(path)?;
}
fs::rename(&temp, path)?;
Ok(())
}

View File

@ -0,0 +1,127 @@
use crate::model::DecodedBarcode;
pub fn extract_barcodes(bytes: &[u8]) -> Vec<DecodedBarcode> {
let text = String::from_utf8_lossy(bytes);
extract_from_zpl(&text)
}
pub fn extract_from_zpl(input: &str) -> Vec<DecodedBarcode> {
let bytes = input.as_bytes();
let mut index = 0usize;
let mut pending_format: Option<String> = None;
let mut barcodes = Vec::new();
while index + 2 < bytes.len() {
if bytes[index] != b'^' && bytes[index] != b'~' {
index += 1;
continue;
}
let command_start = index;
let command = &input[index + 1..index + 3];
index += 3;
if let Some(format) = barcode_format(command) {
pending_format = Some(format.to_string());
continue;
}
if command == "FD" {
let end = input[index..]
.find("^FS")
.map(|offset| index + offset)
.unwrap_or(input.len());
if let Some(format) = pending_format.take() {
let raw = input[index..end].trim();
let value = normalize_field_data(raw);
if !value.is_empty() {
barcodes.push(DecodedBarcode { format, value });
}
}
index = end.saturating_add(3);
continue;
}
if command == "FS" || command == "XZ" {
pending_format = None;
}
if index <= command_start {
index = command_start + 1;
}
}
barcodes
}
fn barcode_format(command: &str) -> Option<&'static str> {
match command {
"BC" => Some("CODE_128"),
"B3" => Some("CODE_39"),
"BE" => Some("EAN_13"),
"B8" => Some("EAN_8"),
"BU" => Some("UPC_A"),
"B9" => Some("UPC_E"),
"B2" => Some("ITF"),
"BQ" => Some("QR_CODE"),
"BX" => Some("DATA_MATRIX"),
"B7" => Some("PDF_417"),
"BA" => Some("CODE_93"),
_ => None,
}
}
fn normalize_field_data(raw: &str) -> String {
let mut value = raw.to_string();
// Zebra Code 128 control sequences commonly select code sets or emit FNC1.
for prefix in [">;", ">:", ">9"] {
if value.starts_with(prefix) {
value = value[prefix.len()..].to_string();
break;
}
}
value = value.replace(">8", "\u{001d}");
// ^FH defaults to '_' as the hex escape character. Decode common escaped bytes.
let hex = regex::Regex::new(r"_([0-9A-Fa-f]{2})").expect("valid regex");
hex.replace_all(&value, |caps: &regex::Captures<'_>| {
u8::from_str_radix(&caps[1], 16)
.ok()
.map(char::from)
.unwrap_or('_')
.to_string()
})
.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_multiple_zpl_barcode_types() {
let zpl = "^XA^FO10,10^BCN,80,Y,N,N^FD>;0123456789012^FS^FO10,100^BQN,2,5^FDLA,HELLO^FS^XZ";
let found = extract_from_zpl(zpl);
assert_eq!(
found,
vec![
DecodedBarcode { format: "CODE_128".into(), value: "0123456789012".into() },
DecodedBarcode { format: "QR_CODE".into(), value: "LA,HELLO".into() },
]
);
}
#[test]
fn preserves_gs1_separator_for_fnc1() {
let zpl = "^XA^BCN,80,N,N,N^FD>801095011015300310ABC^FS^XZ";
let found = extract_from_zpl(zpl);
assert_eq!(found[0].value, "\u{001d}01095011015300310ABC");
}
#[test]
fn ignores_normal_text_fields() {
let zpl = "^XA^FO10,10^A0N,30,30^FDNOT-A-BARCODE^FS^XZ";
assert!(extract_from_zpl(zpl).is_empty());
}
}

View File

@ -0,0 +1,35 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "FieldLens Print",
"version": "0.1.0",
"identifier": "dev.arrowstack.fieldlens.print",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "FieldLens Print",
"width": 980,
"height": 760,
"minWidth": 720,
"minHeight": 560
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": ["nsis", "msi"],
"windows": {
"nsis": {
"installMode": "currentUser"
}
}
}
}

249
apps/print/src/main.ts Normal file
View File

@ -0,0 +1,249 @@
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { openUrl } from '@tauri-apps/plugin-opener'
import './styles.css'
type CaptureMode = 'raw-proxy' | 'spool-folder' | 'both'
type AppConfig = {
mode: CaptureMode
listenAddr: string
forwardAddr: string
spoolDir: string
apiBaseUrl: string
issuer: string
clientId: string
stationId: string
installationId: string
deviceId?: string | null
}
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
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 printer
<input id="forwardAddr" placeholder="10.0.0.50:9100" />
<span class="help">Leave blank for capture-only testing.</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="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>
</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(),
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<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 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('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)
})
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 refreshStatus()
window.setInterval(() => { void refreshStatus() }, 5000)

57
apps/print/src/styles.css Normal file
View File

@ -0,0 +1,57 @@
* { box-sizing: border-box; }
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #e5e7eb;
background: #0b1220;
font-synthesis: none;
}
body { margin: 0; min-width: 720px; min-height: 100vh; background: radial-gradient(circle at top left, #172554 0, #0b1220 38%, #070b13 100%); }
button, input, select { font: inherit; }
button { cursor: pointer; }
.shell { max-width: 1120px; margin: 0 auto; padding: 28px; }
header { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 22px; }
.brand { display: flex; align-items: center; gap: 13px; }
.mark { width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; background: linear-gradient(135deg,#22c55e,#0ea5e9); color: #07111b; font-weight: 900; }
h1 { margin: 0; font-size: 22px; letter-spacing: -.02em; }
.subtitle { margin: 3px 0 0; color: #94a3b8; font-size: 13px; }
.status { display: inline-flex; align-items: center; gap: 8px; border: 1px solid #334155; background: #111827cc; border-radius: 999px; padding: 8px 12px; font-size: 12px; color: #cbd5e1; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: #64748b; }
.status.live .dot { background: #22c55e; box-shadow: 0 0 0 4px #22c55e22; }
.grid { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(320px, .8fr); gap: 18px; }
.card { border: 1px solid #263449; border-radius: 18px; background: #111827d9; box-shadow: 0 18px 50px #0005; overflow: hidden; }
.card-head { padding: 18px 20px 14px; border-bottom: 1px solid #223047; display: flex; align-items: center; justify-content: space-between; }
.card-head h2 { font-size: 14px; margin: 0; }
.card-body { padding: 18px 20px 20px; }
.fields { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 14px; }
label { display: grid; gap: 7px; color: #94a3b8; font-size: 12px; }
label.full { grid-column: 1 / -1; }
input, select { width: 100%; border: 1px solid #334155; border-radius: 10px; background: #0b1220; color: #e5e7eb; padding: 10px 11px; outline: none; }
input:focus, select:focus { border-color: #38bdf8; box-shadow: 0 0 0 3px #38bdf820; }
.help { color: #64748b; font-size: 11px; line-height: 1.45; margin-top: 5px; }
.actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 18px; }
.btn { border: 1px solid #334155; border-radius: 10px; padding: 9px 13px; background: #182235; color: #e5e7eb; }
.btn.primary { background: #16a34a; border-color: #22c55e; color: white; }
.btn.stop { background: #3f1d24; border-color: #7f1d1d; color: #fecaca; }
.btn.blue { background: #0c4a6e; border-color: #0369a1; }
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 15px; }
.metric { border: 1px solid #263449; border-radius: 12px; padding: 12px; background: #0b1220aa; }
.metric b { display: block; font-size: 18px; margin-bottom: 3px; }
.metric span { color: #64748b; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
.login-box { border: 1px solid #263449; border-radius: 12px; padding: 13px; background: #0b1220aa; }
.login-code { font: 700 18px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .09em; }
.muted { color: #64748b; font-size: 12px; }
.jobs { display: grid; gap: 10px; max-height: 385px; overflow: auto; }
.job { border: 1px solid #263449; border-radius: 12px; padding: 12px; background: #0b1220aa; }
.job-top { display: flex; justify-content: space-between; gap: 10px; font-size: 11px; color: #64748b; }
.job strong { display: block; margin: 7px 0 4px; font-size: 13px; }
.codes { display: flex; flex-wrap: wrap; gap: 6px; }
.code { font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; background: #052e16; border: 1px solid #14532d; color: #bbf7d0; border-radius: 7px; padding: 4px 6px; }
.warn { color: #fbbf24; }
.error { color: #fca5a5; }
.small { font-size: 11px; }
@media (max-width: 860px) {
body { min-width: 0; }
.grid { grid-template-columns: 1fr; }
.fields { grid-template-columns: 1fr; }
label.full { grid-column: auto; }
}

18
apps/print/tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src"]
}

14
apps/print/vite.config.ts Normal file
View File

@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
const host = process.env.TAURI_DEV_HOST
export default defineConfig({
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host ? { protocol: 'ws', host, port: 1421 } : undefined,
watch: { ignored: ['**/src-tauri/**'] },
},
})

View File

@ -0,0 +1,81 @@
import { ArrowLeft, Download, Monitor, Terminal } from 'lucide-react'
const downloads = [
{
href: '/downloads/fieldlens-print/windows-x64',
label: 'Windows',
detail: 'x64 · NSIS installer · current-user install',
icon: Monitor,
},
{
href: '/downloads/fieldlens-print/linux-x64.deb',
label: 'Linux',
detail: 'x64 · Debian / Ubuntu .deb package',
icon: Terminal,
},
]
export function DesktopDownloadsLink() {
return (
<section className="settings-section desktop-downloads-link">
<header>
<span className="settings-icon"><Download /></span>
<div><h2>Desktop app</h2><p>Capture printed barcodes without a physical scan</p></div>
</header>
<a className="desktop-download-settings-link" href="/downloads">
<span>
<strong>Get the desktop app</strong>
<small>Windows and Linux installers</small>
</span>
<span className="desktop-download-action"><Download size={13} /> Downloads</span>
</a>
</section>
)
}
export function DesktopDownloadsPage() {
return (
<main className="downloads-page">
<div className="downloads-shell">
<a className="downloads-back" href="/"><ArrowLeft size={16} /> Back to FieldLens</a>
<header className="downloads-hero">
<span className="downloads-mark">FL</span>
<div>
<p className="downloads-kicker">FIELDLENS PRINT</p>
<h1>Get the desktop app</h1>
<p>Capture barcodes directly from print jobs and sync them into FieldLens without a physical scan.</p>
</div>
</header>
<section className="downloads-panel" aria-labelledby="downloads-title">
<div className="downloads-panel-heading">
<div>
<h2 id="downloads-title">Choose your platform</h2>
<p>FieldLens Print captures RAW/ZPL and spool output locally, extracts barcode values, then syncs them to FieldLens.</p>
</div>
</div>
<div className="desktop-download-grid">
{downloads.map(({ href, label, detail, icon: Icon }) => (
<a className="desktop-download-card" href={href} key={href}>
<span className="desktop-download-icon"><Icon size={21} /></span>
<span className="desktop-download-copy">
<strong>{label}</strong>
<small>{detail}</small>
</span>
<span className="desktop-download-action"><Download size={14} /> Download</span>
</a>
))}
</div>
<div className="downloads-notes">
<p><strong>Windows:</strong> installs for the current user, so the app itself does not require administrator elevation.</p>
<p><strong>Printer setup:</strong> changing a managed Windows printer queue or port can still require IT/admin permission.</p>
</div>
</section>
</div>
</main>
)
}

View File

@ -1,47 +1,3 @@
body:has(.workspace-view) .host-status {
display: none;
}
.host-status {
position: fixed;
z-index: 80;
top: calc(78px + env(safe-area-inset-top));
right: max(18px, env(safe-area-inset-right));
width: min(210px, calc(100vw - 36px));
font: 11px/1.25 system-ui, sans-serif;
}
.host-status-indicator {
display: grid;
grid-template-columns: 9px minmax(0, 1fr);
align-items: center;
gap: 9px;
padding: 8px 11px;
border: 1px solid rgba(255,255,255,.16);
border-radius: 8px;
color: white;
background: rgba(23,33,31,.82);
box-shadow: 0 5px 18px rgba(0,0,0,.16);
backdrop-filter: blur(12px);
}
.sync-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #87938e;
box-shadow: 0 0 0 3px rgba(135,147,142,.12);
}
.sync-dot.synced { background: #66cf9d; box-shadow: 0 0 0 3px rgba(102,207,157,.14); }
.sync-dot.pending { background: #f4d35e; box-shadow: 0 0 0 3px rgba(244,211,94,.14); }
.sync-dot.error { background: #ed736a; box-shadow: 0 0 0 3px rgba(237,115,106,.14); }
.host-status-copy { min-width: 0; display: grid; gap: 1px; }
.host-status-copy strong,
.host-status-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.host-status-copy strong { font-size: 11px; }
.host-status-copy small { color: #aebbb6; font-size: 9px; }
.account-settings { grid-column: 1 / -1; } .account-settings { grid-column: 1 / -1; }
.account-actions { .account-actions {
display: flex; display: flex;
@ -93,15 +49,189 @@ body:has(.workspace-view) .host-status {
.empty-requests { margin: 0; padding: 16px 18px; color: #75807b; font-size: 10px; } .empty-requests { margin: 0; padding: 16px 18px; color: #75807b; font-size: 10px; }
@media (max-width: 760px) { @media (max-width: 760px) {
.host-status {
top: calc(86px + env(safe-area-inset-top));
right: max(12px, env(safe-area-inset-right));
width: min(188px, calc(100vw - 24px));
}
.host-status-indicator { padding: 7px 10px; }
.account-actions { display: grid; grid-template-columns: 1fr 1fr; } .account-actions { display: grid; grid-template-columns: 1fr 1fr; }
.account-actions .primary-button, .account-actions .primary-button,
.account-actions .quiet-button { width: 100%; min-width: 0; } .account-actions .quiet-button { width: 100%; min-width: 0; }
.access-requests article { grid-template-columns: 1fr; } .access-requests article { grid-template-columns: 1fr; }
.access-requests article > div { display: grid; grid-template-columns: 1fr 1fr; } .access-requests article > div { display: grid; grid-template-columns: 1fr 1fr; }
} }
.desktop-downloads-link { grid-column: 1 / -1; }
.desktop-download-settings-link {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin: 16px 18px 18px;
padding: 14px 15px;
border: 1px solid var(--line);
border-radius: 12px;
color: inherit;
background: #fafaf6;
text-decoration: none;
transition: border-color .15s ease, transform .15s ease, background .15s ease;
}
.desktop-download-settings-link:hover {
border-color: rgba(31, 111, 78, .4);
background: #f4f8f4;
transform: translateY(-1px);
}
.desktop-download-settings-link strong,
.desktop-download-settings-link small { display: block; }
.desktop-download-settings-link strong { font-size: 11px; }
.desktop-download-settings-link small { margin-top: 3px; color: #75807b; font-size: 9px; }
.downloads-page {
min-height: 100vh;
padding: max(28px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) max(34px, env(safe-area-inset-bottom)) max(18px, env(safe-area-inset-left));
color: #17211f;
background:
radial-gradient(circle at 12% 0%, rgba(241, 205, 75, .22), transparent 30rem),
#f5f5ef;
}
.downloads-shell {
width: min(860px, 100%);
margin: 0 auto;
}
.downloads-back {
display: inline-flex;
align-items: center;
gap: 7px;
margin-bottom: 30px;
color: #52605b;
font-size: 12px;
font-weight: 800;
text-decoration: none;
}
.downloads-back:hover { color: var(--green); }
.downloads-hero {
display: grid;
grid-template-columns: 58px minmax(0, 1fr);
gap: 18px;
align-items: start;
margin-bottom: 24px;
}
.downloads-mark {
width: 58px;
height: 58px;
display: grid;
place-items: center;
border-radius: 16px;
color: #17211f;
background: #f4d35e;
box-shadow: inset 0 -2px 0 rgba(0,0,0,.08);
font-size: 18px;
font-weight: 950;
letter-spacing: -.04em;
}
.downloads-kicker {
margin: 1px 0 6px;
color: #69736f;
font-size: 10px;
font-weight: 900;
letter-spacing: .14em;
}
.downloads-hero h1 {
margin: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: clamp(27px, 6vw, 44px);
line-height: 1.03;
letter-spacing: -.055em;
}
.downloads-hero > div > p:last-child {
max-width: 650px;
margin: 12px 0 0;
color: #626c68;
font-size: 13px;
line-height: 1.6;
}
.downloads-panel {
overflow: hidden;
border: 1px solid #d9ddd7;
border-radius: 18px;
background: rgba(255,255,252,.94);
box-shadow: 0 16px 50px rgba(42,52,48,.08);
}
.downloads-panel-heading {
padding: 21px 22px 17px;
border-bottom: 1px solid #e4e7e1;
}
.downloads-panel-heading h2 { margin: 0; font-size: 15px; }
.downloads-panel-heading p {
max-width: 650px;
margin: 5px 0 0;
color: #75807b;
font-size: 10px;
line-height: 1.5;
}
.desktop-download-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
padding: 18px 20px;
}
.desktop-download-card {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
min-height: 76px;
padding: 13px 14px;
border: 1px solid #dfe3dc;
border-radius: 13px;
color: inherit;
background: #fafaf6;
text-decoration: none;
transition: border-color .15s ease, transform .15s ease, background .15s ease;
}
.desktop-download-card:hover {
border-color: rgba(31, 111, 78, .4);
background: #f4f8f4;
transform: translateY(-1px);
}
.desktop-download-icon {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 11px;
color: var(--green);
background: rgba(31, 111, 78, .09);
}
.desktop-download-copy { min-width: 0; }
.desktop-download-copy strong,
.desktop-download-copy small { display: block; }
.desktop-download-copy strong { font-size: 12px; }
.desktop-download-copy small { margin-top: 3px; color: #75807b; font-size: 9px; line-height: 1.35; }
.desktop-download-action {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--green);
font-size: 10px;
font-weight: 900;
white-space: nowrap;
}
.downloads-notes {
display: grid;
gap: 5px;
padding: 14px 20px 18px;
border-top: 1px solid #e4e7e1;
color: #75807b;
background: #fbfbf7;
}
.downloads-notes p { margin: 0; font-size: 9px; line-height: 1.5; }
.downloads-notes strong { color: #46514d; }
@media (max-width: 760px) {
.desktop-download-settings-link { align-items: flex-start; }
.downloads-page { padding-top: max(22px, env(safe-area-inset-top)); }
.downloads-back { margin-bottom: 24px; }
.downloads-hero { grid-template-columns: 48px minmax(0, 1fr); gap: 14px; }
.downloads-mark { width: 48px; height: 48px; border-radius: 14px; font-size: 16px; }
.desktop-download-grid { grid-template-columns: 1fr; padding: 14px; }
.desktop-download-card { grid-template-columns: 40px minmax(0, 1fr) auto; }
.downloads-panel-heading { padding: 18px 17px 15px; }
.downloads-notes { padding: 13px 16px 16px; }
}

View File

@ -11,7 +11,7 @@ import './host.css'
import CoreApp from '../../../packages/fieldlens-core/src/App' import CoreApp from '../../../packages/fieldlens-core/src/App'
import { startDeviceHoldTracking } from '../../../packages/fieldlens-core/src/lib/deviceOrientation' import { startDeviceHoldTracking } from '../../../packages/fieldlens-core/src/lib/deviceOrientation'
import { AccountSettings } from './AccountSettings' import { AccountSettings } from './AccountSettings'
import { HostStatus } from './HostStatus' import { DesktopDownloadsLink, DesktopDownloadsPage } from './DesktopDownloads'
import { registerArrowstackReferenceProvider } from './referenceProvider' import { registerArrowstackReferenceProvider } from './referenceProvider'
import { installSyncTriggers } from './sync' import { installSyncTriggers } from './sync'
@ -19,9 +19,15 @@ startDeviceHoldTracking()
registerArrowstackReferenceProvider() registerArrowstackReferenceProvider()
installSyncTriggers() installSyncTriggers()
createRoot(document.getElementById('root')!).render( const isDownloadsPage = window.location.pathname === '/downloads'
const root = createRoot(document.getElementById('root')!)
root.render(
<StrictMode> <StrictMode>
<CoreApp settingsAddon={<AccountSettings />} /> {isDownloadsPage ? (
<HostStatus /> <DesktopDownloadsPage />
) : (
<CoreApp settingsAddon={<><AccountSettings /><DesktopDownloadsLink /></>} />
)}
</StrictMode>, </StrictMode>,
) )

View File

@ -0,0 +1,5 @@
ALTER TABLE scans
ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'camera';
ALTER TABLE scans
ADD COLUMN IF NOT EXISTS source_metadata jsonb;

View File

@ -13,7 +13,8 @@
"lint": "npm run lint --workspace @fieldlens/core && npm run lint --workspace @fieldlens/web && npm run lint --workspace @fieldlens/api", "lint": "npm run lint --workspace @fieldlens/core && npm run lint --workspace @fieldlens/web && npm run lint --workspace @fieldlens/api",
"build": "npm run build --workspace @fieldlens/contracts && npm run build --workspace @fieldlens/core && npm run build --workspace @fieldlens/web && npm run build --workspace @fieldlens/api", "build": "npm run build --workspace @fieldlens/contracts && npm run build --workspace @fieldlens/core && npm run build --workspace @fieldlens/web && npm run build --workspace @fieldlens/api",
"migration:validate": "node scripts/validate-migrations.mjs", "migration:validate": "node scripts/validate-migrations.mjs",
"verify": "npm run test && npm run lint && npm run migration:validate && npm run build" "verify": "npm run test && npm run lint && npm run migration:validate && npm run build",
"build:print": "npm run build --workspace @fieldlens/print"
}, },
"engines": { "engines": {
"node": ">=24" "node": ">=24"

View File

@ -9,6 +9,8 @@ export type ScanRecord = {
duplicate: boolean duplicate: boolean
capturedAt: string capturedAt: string
schemaVersion: SchemaVersion schemaVersion: SchemaVersion
source?: 'camera' | 'print' | string
sourceMetadata?: Record<string, unknown>
} }
export type PalletSession = { export type PalletSession = {

View File

@ -2,6 +2,12 @@ import { BrowserMultiFormatReader, type IScannerControls } from '@zxing/browser'
import { CheckCircle2, RefreshCw, ScanLine, XCircle } from 'lucide-react' import { CheckCircle2, RefreshCw, ScanLine, XCircle } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import {
acquireCameraStream,
cameraPermissionHelp,
releaseCameraStream,
stopCameraStream,
} from '../../lib/cameraSession'
import { import {
finishEanPalletSession, finishEanPalletSession,
saveEanPalletScans, saveEanPalletScans,
@ -15,6 +21,7 @@ type Step = 'setup' | 'scan' | 'result'
export function EanPalletView() { export function EanPalletView() {
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const controlsRef = useRef<IScannerControls | null>(null) const controlsRef = useRef<IScannerControls | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const armedRef = useRef(false) const armedRef = useRef(false)
const sessionIdRef = useRef('') const sessionIdRef = useRef('')
const scansRef = useRef<string[]>([]) const scansRef = useRef<string[]>([])
@ -23,6 +30,7 @@ export function EanPalletView() {
const [scans, setScans] = useState<string[]>([]) const [scans, setScans] = useState<string[]>([])
const [armed, setArmed] = useState(false) const [armed, setArmed] = useState(false)
const [cameraError, setCameraError] = useState('') const [cameraError, setCameraError] = useState('')
const [cameraRetryKey, setCameraRetryKey] = useState(0)
const [message, setMessage] = useState('Ready') const [message, setMessage] = useState('Ready')
const summary = useMemo(() => summarizeEanScans(scans), [scans]) const summary = useMemo(() => summarizeEanScans(scans), [scans])
@ -32,58 +40,105 @@ export function EanPalletView() {
if (!video) return if (!video) return
let disposed = false let disposed = false
let activeStream: MediaStream | null = null
setCameraError('') setCameraError('')
const reader = new BrowserMultiFormatReader() const reader = new BrowserMultiFormatReader()
void reader.decodeFromConstraints(
{ audio: false, video: { facingMode: { ideal: 'environment' } } }, void acquireCameraStream()
video, .then(async (stream) => {
(result) => { if (disposed) {
if (!result || !armedRef.current) return releaseCameraStream()
const ean = extractEanFromScan(result.getText()) return null
if (!ean) { }
setMessage('Barcode read, but no valid EAN/UPC/GTIN was found.')
activeStream = stream
streamRef.current = stream
video.srcObject = stream
return reader.decodeFromVideoElement(
video,
(result) => {
if (!result || !armedRef.current) return
const ean = extractEanFromScan(result.getText())
if (!ean) {
setMessage('Barcode read, but no valid EAN/UPC/GTIN was found.')
return
}
armedRef.current = false
setArmed(false)
const nextScans = [...scansRef.current, ean]
scansRef.current = nextScans
setScans(nextScans)
setMessage(`Captured ${ean}`)
navigator.vibrate?.(45)
if (sessionIdRef.current) {
void saveEanPalletScans(sessionIdRef.current, nextScans).catch(() => {
setMessage('Local storage failed. Do not leave this screen until the pallet is recorded.')
})
}
if (nextScans.length >= targetCount) {
const nextSummary = summarizeEanScans(nextScans)
if (sessionIdRef.current) {
void finishEanPalletSession(sessionIdRef.current, {
conforms: nextSummary.conforms,
consensusEan: nextSummary.consensusEan,
uniqueEanCount: nextSummary.counts.length,
}).catch(() => undefined)
}
setStep('result')
}
},
)
})
.then((controls) => {
if (!controls) return
if (disposed) {
controls.stop()
return return
} }
controlsRef.current = controls
armedRef.current = false })
setArmed(false) .catch((error: unknown) => {
const nextScans = [...scansRef.current, ean] if (disposed) return
scansRef.current = nextScans const denied = error instanceof DOMException && error.name === 'NotAllowedError'
setScans(nextScans) setCameraError(denied ? cameraPermissionHelp() : error instanceof Error ? error.message : 'Camera could not be started.')
setMessage(`Captured ${ean}`) })
navigator.vibrate?.(45)
if (sessionIdRef.current) {
void saveEanPalletScans(sessionIdRef.current, nextScans).catch(() => {
setMessage('Local storage failed. Do not leave this screen until the pallet is recorded.')
})
}
if (nextScans.length >= targetCount) {
const nextSummary = summarizeEanScans(nextScans)
if (sessionIdRef.current) {
void finishEanPalletSession(sessionIdRef.current, {
conforms: nextSummary.conforms,
consensusEan: nextSummary.consensusEan,
uniqueEanCount: nextSummary.counts.length,
}).catch(() => undefined)
}
setStep('result')
}
},
).then((controls) => {
if (disposed) controls.stop()
else controlsRef.current = controls
}).catch((error) => {
if (!disposed) setCameraError(error instanceof Error ? error.message : 'Camera could not be started.')
})
return () => { return () => {
disposed = true disposed = true
controlsRef.current?.stop() controlsRef.current?.stop()
controlsRef.current = null controlsRef.current = null
if (video.srcObject === activeStream) video.srcObject = null
if (streamRef.current === activeStream) streamRef.current = null
releaseCameraStream()
} }
}, [step, targetCount]) }, [cameraRetryKey, step, targetCount])
useEffect(() => {
if (step !== 'scan') return
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
controlsRef.current?.stop()
controlsRef.current = null
if (videoRef.current) {
videoRef.current.pause()
videoRef.current.srcObject = null
}
streamRef.current = null
stopCameraStream()
return
}
setCameraRetryKey((value) => value + 1)
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
}, [step])
const start = async () => { const start = async () => {
const safeCount = Math.max(1, Math.min(500, Math.round(targetCount || 1))) const safeCount = Math.max(1, Math.min(500, Math.round(targetCount || 1)))
@ -154,7 +209,12 @@ export function EanPalletView() {
<strong>{scans.length} / {targetCount}</strong> <strong>{scans.length} / {targetCount}</strong>
<span>{message}</span> <span>{message}</span>
<div className="ean-progress-track"><i style={{ width: `${Math.min(100, scans.length / targetCount * 100)}%` }} /></div> <div className="ean-progress-track"><i style={{ width: `${Math.min(100, scans.length / targetCount * 100)}%` }} /></div>
{cameraError && <p className="ean-error">{cameraError}</p>} {cameraError && (
<div className="ean-error">
<span>{cameraError}</span>
<button type="button" className="ean-primary" onClick={() => setCameraRetryKey((value) => value + 1)}>Retry camera</button>
</div>
)}
{!armed && scans.length < targetCount && ( {!armed && scans.length < targetCount && (
<button type="button" className="ean-primary" onClick={armNext}> <button type="button" className="ean-primary" onClick={armNext}>
<ScanLine size={17} /> Scan next carton <ScanLine size={17} /> Scan next carton

View File

@ -11,6 +11,13 @@ import {
type CoverCoordinateSpace, type CoverCoordinateSpace,
type Homography, type Homography,
} from '../../lib/cartonArTracking' } from '../../lib/cartonArTracking'
import {
acquireCameraStream,
cameraPermissionHelp,
getCameraTrack,
releaseCameraStream,
stopCameraStream,
} from '../../lib/cameraSession'
import { import {
finishPalletLabelSession, finishPalletLabelSession,
savePalletLabelScans, savePalletLabelScans,
@ -496,29 +503,31 @@ export function LabelCheckView({ onOpenSettings }: { onOpenSettings: () => void
useEffect(() => { useEffect(() => {
if (step !== 'scan') return if (step !== 'scan') return
let disposed = false let disposed = false
let activeStream: MediaStream | null = null
const stopCamera = () => { const detachCamera = () => {
streamRef.current?.getTracks().forEach((track) => track.stop()) if (videoRef.current?.srcObject === activeStream) videoRef.current.srcObject = null
streamRef.current = null if (streamRef.current === activeStream) streamRef.current = null
if (videoRef.current) videoRef.current.srcObject = null releaseCameraStream()
} }
const startCamera = async () => { const startCamera = async () => {
stopCamera()
setCameraReady(false) setCameraReady(false)
setCameraError('') setCameraError('')
const stream = await navigator.mediaDevices.getUserMedia({ const stream = await acquireCameraStream()
audio: false, activeStream = stream
video: {
facingMode: { ideal: 'environment' }, const track = getCameraTrack(stream)
if (track) {
await track.applyConstraints({
width: { ideal: 1920 }, width: { ideal: 1920 },
height: { ideal: 1080 }, height: { ideal: 1080 },
}, }).catch(() => undefined)
}) }
if (disposed) { if (disposed) {
stream.getTracks().forEach((track) => track.stop()) releaseCameraStream()
return return
} }
@ -532,17 +541,40 @@ export function LabelCheckView({ onOpenSettings }: { onOpenSettings: () => void
void startCamera().catch((cameraStartError: unknown) => { void startCamera().catch((cameraStartError: unknown) => {
if (!disposed) { if (!disposed) {
setCameraError(cameraStartError instanceof Error ? cameraStartError.message : 'Camera access failed.') const denied = cameraStartError instanceof DOMException && cameraStartError.name === 'NotAllowedError'
setCameraError(denied ? cameraPermissionHelp() : cameraStartError instanceof Error ? cameraStartError.message : 'Camera access failed.')
setCameraReady(false) setCameraReady(false)
} }
}) })
return () => { return () => {
disposed = true disposed = true
stopCamera() detachCamera()
} }
}, [retryKey, step]) }, [retryKey, step])
useEffect(() => {
if (step !== 'scan') return
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
if (videoRef.current) {
videoRef.current.pause()
videoRef.current.srcObject = null
}
streamRef.current = null
setCameraReady(false)
stopCameraStream()
return
}
setRetryKey((value) => value + 1)
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
}, [step])
useEffect(() => { useEffect(() => {
if (step !== 'scan' || !cameraReady) return if (step !== 'scan' || !cameraReady) return
const video = videoRef.current const video = videoRef.current

View File

@ -3,12 +3,23 @@ import { BarcodeFormat, DecodeHintType } from '@zxing/library'
import { Camera, CheckCircle2, RefreshCw, Settings2, ShieldAlert, SwitchCamera, Volume2, VolumeX, Zap, ZapOff } from 'lucide-react' import { Camera, CheckCircle2, RefreshCw, Settings2, ShieldAlert, SwitchCamera, Volume2, VolumeX, Zap, ZapOff } from 'lucide-react'
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import {
acquireCameraStream,
cameraPermissionHelp,
cameraTrackHasTorch,
getCameraTrack,
readCameraPermission,
releaseCameraStream,
setCameraTorch,
shouldAutoStartCamera,
stopCameraStream,
} from '../../lib/cameraSession'
import { recordScan, type ScanRecord } from '../../lib/database' import { recordScan, type ScanRecord } from '../../lib/database'
import { detectionSpanPercent, formatName, formatsForMode, scanFormatOptions, validateScanPayload, type PayloadRules, type ScanFormatMode } from '../../lib/barcodes' import { detectionSpanPercent, formatName, formatsForMode, scanFormatOptions, validateScanPayload, type PayloadRules, type ScanFormatMode } from '../../lib/barcodes'
import { loadScannerPreferences, saveScannerPreferences } from '../../lib/preferences' import { loadScannerPreferences, saveScannerPreferences } from '../../lib/preferences'
import { disableScanAudio, enableScanAudio, getScanAudio } from '../../lib/scanAudio' import { disableScanAudio, enableScanAudio, getScanAudio } from '../../lib/scanAudio'
type ScannerStatus = 'starting' | 'detecting' | 'captured' | 'duplicate' | 'permission-denied' | 'camera-error' | 'storage-error' type ScannerStatus = 'starting' | 'permission-prompt' | 'suspended' | 'detecting' | 'captured' | 'duplicate' | 'permission-denied' | 'camera-error' | 'storage-error'
type DetectionPoint = { left: number; top: number } type DetectionPoint = { left: number; top: number }
type ConfirmationCandidate = { value: string; format: BarcodeFormat; count: number; lastSeen: number } type ConfirmationCandidate = { value: string; format: BarcodeFormat; count: number; lastSeen: number }
type PendingCapture = { rawValue: string; format: string } type PendingCapture = { rawValue: string; format: string }
@ -18,6 +29,7 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const stageRef = useRef<HTMLDivElement>(null) const stageRef = useRef<HTMLDivElement>(null)
const controlsRef = useRef<IScannerControls | null>(null) const controlsRef = useRef<IScannerControls | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const armedRef = useRef(true) const armedRef = useRef(true)
const autoAdvanceRef = useRef(initialPreferences.autoAdvance) const autoAdvanceRef = useRef(initialPreferences.autoAdvance)
const soundEnabledRef = useRef(initialPreferences.soundEnabled) const soundEnabledRef = useRef(initialPreferences.soundEnabled)
@ -30,6 +42,7 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
const lastReadRef = useRef({ value: '', time: 0 }) const lastReadRef = useRef({ value: '', time: 0 })
const [armed, setArmed] = useState(true) const [armed, setArmed] = useState(true)
const [status, setStatus] = useState<ScannerStatus>('starting') const [status, setStatus] = useState<ScannerStatus>('starting')
const [cameraGate, setCameraGate] = useState<'checking' | 'prompt' | 'ready' | 'blocked'>('checking')
const [cameraError, setCameraError] = useState('') const [cameraError, setCameraError] = useState('')
const [cameras, setCameras] = useState<MediaDeviceInfo[]>([]) const [cameras, setCameras] = useState<MediaDeviceInfo[]>([])
const [selectedCamera, setSelectedCamera] = useState('') const [selectedCamera, setSelectedCamera] = useState('')
@ -99,6 +112,35 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
} }
useEffect(() => { useEffect(() => {
let cancelled = false
void readCameraPermission().then((permission) => {
if (cancelled) return
if (permission === 'denied') {
setCameraGate('blocked')
setCameraError(cameraPermissionHelp())
setStatus('permission-denied')
return
}
if (shouldAutoStartCamera(permission)) {
setCameraGate('ready')
setStatus('starting')
return
}
setCameraGate('prompt')
setCameraError('')
setStatus('permission-prompt')
})
return () => { cancelled = true }
}, [])
useEffect(() => {
if (cameraGate !== 'ready') return
const video = videoRef.current const video = videoRef.current
if (!video) return if (!video) return
@ -113,15 +155,21 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
setCameraError('') setCameraError('')
setTorchAvailable(false) setTorchAvailable(false)
void reader.decodeFromConstraints( let activeStream: MediaStream | null = null
{
audio: false, void acquireCameraStream(requestedCamera)
video: requestedCamera .then(async (stream) => {
? { deviceId: { exact: requestedCamera } } if (disposed) {
: { facingMode: { ideal: 'environment' } }, releaseCameraStream()
}, return null
video, }
(result) => {
activeStream = stream
streamRef.current = stream
video.srcObject = stream
return reader.decodeFromVideoElement(
video,
(result) => {
if (!armedRef.current || qualitySettingsOpenRef.current) return if (!armedRef.current || qualitySettingsOpenRef.current) return
if (!result) { if (!result) {
if (candidateRef.current) { if (candidateRef.current) {
@ -181,34 +229,100 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
const capture = { rawValue, format } const capture = { rawValue, format }
setPendingCapture(capture) setPendingCapture(capture)
void saveCapture(capture) void saveCapture(capture)
}, },
).then((controls) => { )
if (disposed) controls.stop() })
else { .then((controls) => {
if (!controls) return
if (disposed) {
controls.stop()
return
}
controlsRef.current = controls controlsRef.current = controls
setTorchAvailable(Boolean(controls.switchTorch)) const activeTrack = getCameraTrack(activeStream)
setTorchAvailable(cameraTrackHasTorch(activeTrack))
setStatus('detecting') setStatus('detecting')
setCameraError('')
void BrowserMultiFormatReader.listVideoInputDevices().then((devices) => { void BrowserMultiFormatReader.listVideoInputDevices().then((devices) => {
if (disposed) return if (disposed) return
setCameras(devices) setCameras(devices)
const activeDevice = controls.streamVideoSettingsGet?.((track) => [track]).deviceId const activeDevice = activeTrack?.getSettings().deviceId
if (activeDevice) setSelectedCamera(activeDevice) if (activeDevice) setSelectedCamera(activeDevice)
}) })
} })
}).catch((error: unknown) => { .catch((error: unknown) => {
const message = error instanceof Error ? error.message : 'Camera access failed.' if (disposed) return
setCameraError(message) const denied = error instanceof DOMException && error.name === 'NotAllowedError'
setStatus(error instanceof DOMException && error.name === 'NotAllowedError' ? 'permission-denied' : 'camera-error') setCameraError(denied ? cameraPermissionHelp() : error instanceof Error ? error.message : 'Camera access failed.')
}) if (denied) setCameraGate('blocked')
setStatus(denied ? 'permission-denied' : 'camera-error')
})
return () => { return () => {
disposed = true disposed = true
controlsRef.current?.stop() controlsRef.current?.stop()
controlsRef.current = null controlsRef.current = null
if (video.srcObject === activeStream) video.srcObject = null
if (streamRef.current === activeStream) streamRef.current = null
releaseCameraStream()
setTorchOn(false) setTorchOn(false)
} }
}, [formatMode, requestedCamera, retryKey, saveCapture, setScannerArmed]) }, [cameraGate, formatMode, requestedCamera, retryKey, saveCapture, setScannerArmed])
useEffect(() => {
const suspendCamera = () => {
if (document.visibilityState !== 'hidden') return
controlsRef.current?.stop()
controlsRef.current = null
if (videoRef.current) {
videoRef.current.pause()
videoRef.current.srcObject = null
}
streamRef.current = null
stopCameraStream()
setTorchOn(false)
if (cameraGate === 'ready') setStatus('suspended')
}
const resumeCamera = async () => {
if (document.visibilityState !== 'visible') return
const permission = await readCameraPermission()
if (permission === 'denied') {
setCameraGate('blocked')
setCameraError(cameraPermissionHelp())
setStatus('permission-denied')
return
}
if (cameraGate === 'ready' || shouldAutoStartCamera(permission)) {
setCameraError('')
setCameraGate('ready')
setRetryKey((value) => value + 1)
} else {
setCameraGate('prompt')
setStatus('permission-prompt')
}
}
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') suspendCamera()
else void resumeCamera()
}
const handlePageShow = (event: PageTransitionEvent) => {
if (event.persisted) void resumeCamera()
}
document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('pageshow', handlePageShow)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('pageshow', handlePageShow)
}
}, [cameraGate])
useEffect(() => () => { useEffect(() => () => {
if (rearmTimerRef.current) window.clearTimeout(rearmTimerRef.current) if (rearmTimerRef.current) window.clearTimeout(rearmTimerRef.current)
@ -217,7 +331,7 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
const toggleTorch = async () => { const toggleTorch = async () => {
const nextValue = !torchOn const nextValue = !torchOn
try { try {
await controlsRef.current?.switchTorch?.(nextValue) await setCameraTorch(getCameraTrack(streamRef.current), nextValue)
setTorchOn(nextValue) setTorchOn(nextValue)
} catch { } catch {
setTorchAvailable(false) setTorchAvailable(false)
@ -276,13 +390,23 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
setQualityMessage('') setQualityMessage('')
} }
const retryCamera = () => { const enableCamera = () => {
setCameraError('')
setCameraGate('ready')
setStatus('starting')
setScannerArmed(true) setScannerArmed(true)
setRetryKey((value) => value + 1) setRetryKey((value) => value + 1)
} }
const retryCamera = () => {
stopCameraStream()
enableCamera()
}
const statusLabel = { const statusLabel = {
starting: 'STARTING CAMERA', starting: 'STARTING CAMERA',
'permission-prompt': 'CAMERA READY',
suspended: 'CAMERA PAUSED',
detecting: 'READY TO SCAN', detecting: 'READY TO SCAN',
captured: 'CAPTURED', captured: 'CAPTURED',
duplicate: 'DUPLICATE', duplicate: 'DUPLICATE',
@ -344,7 +468,11 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
<div className={`scan-frame ${latest && /QR|Data Matrix|Aztec|MaxiCode/i.test(latest.format) ? 'matrix' : ''}`} aria-hidden="true"><span /><span /><span /><span /></div> <div className={`scan-frame ${latest && /QR|Data Matrix|Aztec|MaxiCode/i.test(latest.format) ? 'matrix' : ''}`} aria-hidden="true"><span /><span /><span /><span /></div>
{detectionPoints.map((point, index) => <i className="detection-point" style={{ left: `${point.left}%`, top: `${point.top}%` }} key={index} />)} {detectionPoints.map((point, index) => <i className="detection-point" style={{ left: `${point.left}%`, top: `${point.top}%` }} key={index} />)}
<p className="camera-instruction" aria-live="polite"> <p className="camera-instruction" aria-live="polite">
{cameraError || (showQualitySettings ? 'Scanning paused while settings are open' : qualityMessage || (status === 'starting' ? 'Preparing the rear camera' : armed ? `Point the camera at a barcode${confirmationProgress ? ` · confirming ${confirmationProgress}/${confirmationFrames}` : ''}` : autoAdvance ? 'Captured · scanner will rearm automatically' : 'Captured · press Scan next when ready'))} {status === 'permission-prompt'
? 'Tap Enable camera to start scanning'
: status === 'suspended'
? 'Camera paused while FieldLens was in the background'
: cameraError || (showQualitySettings ? 'Scanning paused while settings are open' : qualityMessage || (status === 'starting' ? 'Preparing the rear camera' : armed ? `Point the camera at a barcode${confirmationProgress ? ` · confirming ${confirmationProgress}/${confirmationFrames}` : ''}` : autoAdvance ? 'Captured · scanner will rearm automatically' : 'Captured · press Scan next when ready'))}
</p> </p>
</div> </div>
@ -354,20 +482,52 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
</div> </div>
<div className="result-copy"> <div className="result-copy">
<span className="eyebrow"> <span className="eyebrow">
{status === 'storage-error' ? 'CAPTURE NOT SAVED' : cameraError ? 'CAMERA UNAVAILABLE' : latest?.isDuplicate ? 'DUPLICATE DETECTED' : latest ? 'CAPTURE SAVED' : 'SCANNER READY'} {status === 'storage-error'
? 'CAPTURE NOT SAVED'
: status === 'permission-prompt'
? 'CAMERA PERMISSION'
: status === 'suspended'
? 'CAMERA PAUSED'
: cameraError
? 'CAMERA UNAVAILABLE'
: latest?.isDuplicate
? 'DUPLICATE DETECTED'
: latest
? 'CAPTURE SAVED'
: 'SCANNER READY'}
</span> </span>
<strong>{status === 'storage-error' ? pendingCapture?.rawValue : cameraError ? 'Camera access required' : latest?.rawValue ?? 'Waiting for a code'}</strong> <strong>{status === 'storage-error'
<small>{status === 'storage-error' ? 'Local storage failed. Retry saving or discard this capture.' : cameraError ? (status === 'permission-denied' ? 'Allow camera access in browser settings, then try again.' : 'Check that a camera is available, then try again.') : latest ? `${latest.format} · saved locally first` : 'Captures are saved locally first and sync when signed in.'}</small> ? pendingCapture?.rawValue
: status === 'permission-prompt'
? 'Enable camera to scan'
: status === 'suspended'
? 'Resuming camera'
: cameraError
? 'Camera access required'
: latest?.rawValue ?? 'Waiting for a code'}</strong>
<small>{status === 'storage-error'
? 'Local storage failed. Retry saving or discard this capture.'
: status === 'permission-prompt'
? 'Chrome/Android can remember this permission. On iPhone, FieldLens will reuse access while the PWA stays open.'
: status === 'suspended'
? 'FieldLens releases the camera in the background and restores it when you return.'
: cameraError
? status === 'permission-denied' ? cameraError : 'Check that a camera is available, then try again.'
: latest
? `${latest.format} · saved locally first`
: 'Captures are saved locally first and sync when signed in.'}</small>
</div> </div>
{status === 'storage-error' && pendingCapture ? ( {status === 'storage-error' && pendingCapture ? (
<div className="result-actions"> <div className="result-actions">
<button className="quiet-button" type="button" onClick={discardPendingCapture}>Discard</button> <button className="quiet-button" type="button" onClick={discardPendingCapture}>Discard</button>
<button className="primary-button" type="button" onClick={() => void saveCapture(pendingCapture)}><RefreshCw size={17} /> Retry save</button> <button className="primary-button" type="button" onClick={() => void saveCapture(pendingCapture)}><RefreshCw size={17} /> Retry save</button>
</div> </div>
) : status === 'permission-prompt' ? (
<button className="primary-button" type="button" onClick={enableCamera}><Camera size={17} /> Enable camera</button>
) : cameraError ? ( ) : cameraError ? (
<button className="primary-button" type="button" onClick={retryCamera}><RefreshCw size={17} /> Try camera again</button> <button className="primary-button" type="button" onClick={retryCamera}><RefreshCw size={17} /> Check settings & retry</button>
) : ( ) : (
<button className="primary-button" type="button" disabled={armed} onClick={() => setScannerArmed(true)}> <button className="primary-button" type="button" disabled={armed || status === 'suspended'} onClick={() => setScannerArmed(true)}>
<RefreshCw size={17} /> Scan next <RefreshCw size={17} /> Scan next
</button> </button>
)} )}

View File

@ -0,0 +1,24 @@
import { describe, expect, it, vi } from 'vitest'
import { isIosDevice } from './cameraSession'
describe('camera session platform detection', () => {
it('detects iPhone and iPad user agents', () => {
expect(isIosDevice('Mozilla/5.0 (iPhone; CPU iPhone OS 26_0 like Mac OS X)', 'iPhone', 5)).toBe(true)
expect(isIosDevice('Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X)', 'iPad', 5)).toBe(true)
})
it('detects iPadOS desktop user agent mode', () => {
expect(isIosDevice('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)', 'MacIntel', 5)).toBe(true)
})
it('does not treat Android Chrome as iOS', () => {
expect(isIosDevice('Mozilla/5.0 (Linux; Android 16; Pixel 9) Chrome/140', 'Linux armv8l', 5)).toBe(false)
})
it('does not depend on mocked browser globals when explicit values are supplied', () => {
vi.stubGlobal('navigator', { userAgent: 'ignored', platform: 'ignored', maxTouchPoints: 0 })
expect(isIosDevice('Android', 'Linux', 1)).toBe(false)
vi.unstubAllGlobals()
})
})

View File

@ -0,0 +1,145 @@
const CAMERA_GRANTED_KEY = 'fieldlens.camera-granted'
const DEFAULT_RELEASE_DELAY_MS = 8_000
export type CameraPermissionState = PermissionState | 'unsupported'
let sharedStream: MediaStream | null = null
let releaseTimer: number | null = null
function cancelScheduledRelease() {
if (releaseTimer !== null) {
window.clearTimeout(releaseTimer)
releaseTimer = null
}
}
function liveVideoTrack(stream: MediaStream | null) {
return stream?.getVideoTracks().find((track) => track.readyState === 'live') ?? null
}
function stopStream(stream: MediaStream | null) {
stream?.getTracks().forEach((track) => track.stop())
}
export async function readCameraPermission(): Promise<CameraPermissionState> {
if (!navigator.permissions?.query) return 'unsupported'
try {
const status = await navigator.permissions.query({ name: 'camera' as PermissionName })
return status.state
} catch {
return 'unsupported'
}
}
export function rememberCameraGrant() {
try {
window.localStorage.setItem(CAMERA_GRANTED_KEY, '1')
} catch {
// Private browsing/storage policy should not prevent camera use.
}
}
export function hasRememberedCameraGrant() {
try {
return window.localStorage.getItem(CAMERA_GRANTED_KEY) === '1'
} catch {
return false
}
}
export function isIosDevice(userAgent = navigator.userAgent, platform = navigator.platform, maxTouchPoints = navigator.maxTouchPoints) {
return /iPad|iPhone|iPod/.test(userAgent) || (platform === 'MacIntel' && maxTouchPoints > 1)
}
export function isStandaloneDisplay() {
return window.matchMedia('(display-mode: standalone)').matches
|| Boolean((navigator as Navigator & { standalone?: boolean }).standalone)
}
export function shouldAutoStartCamera(permission: CameraPermissionState) {
if (permission === 'granted') return true
if (permission === 'denied' || permission === 'prompt') return false
// Safari/WebKit does not consistently expose camera through Permissions API.
// Reuse a previous grant in normal Safari, but require an explicit tap after a
// fresh iOS Home Screen PWA launch to avoid surprise permission prompts.
return hasRememberedCameraGrant() && !(isIosDevice() && isStandaloneDisplay())
}
export function cameraPermissionHelp() {
if (isIosDevice()) {
return 'Allow Camera for FieldLens in Safari website settings, then return here and retry.'
}
if (/Android/i.test(navigator.userAgent)) {
return 'In Chrome, open Site settings → Camera → Allow, then return here and retry.'
}
return 'Allow camera access for this site in your browser settings, then retry.'
}
export async function acquireCameraStream(deviceId = '') {
cancelScheduledRelease()
const existingTrack = liveVideoTrack(sharedStream)
if (existingTrack) {
const currentDeviceId = existingTrack.getSettings().deviceId ?? ''
if (!deviceId || !currentDeviceId || currentDeviceId === deviceId) {
return sharedStream!
}
stopStream(sharedStream)
sharedStream = null
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: deviceId
? { deviceId: { exact: deviceId } }
: { facingMode: { ideal: 'environment' } },
})
sharedStream = stream
rememberCameraGrant()
const track = liveVideoTrack(stream)
if (track) {
track.addEventListener('ended', () => {
if (sharedStream === stream) sharedStream = null
}, { once: true })
}
return stream
}
export function releaseCameraStream(delayMs = DEFAULT_RELEASE_DELAY_MS) {
cancelScheduledRelease()
if (!sharedStream) return
releaseTimer = window.setTimeout(() => {
stopStream(sharedStream)
sharedStream = null
releaseTimer = null
}, delayMs)
}
export function stopCameraStream() {
cancelScheduledRelease()
stopStream(sharedStream)
sharedStream = null
}
export function getCameraTrack(stream: MediaStream | null) {
return liveVideoTrack(stream)
}
export function cameraTrackHasTorch(track: MediaStreamTrack | null) {
if (!track?.getCapabilities) return false
const capabilities = track.getCapabilities() as MediaTrackCapabilities & { torch?: boolean }
return Boolean(capabilities.torch)
}
export async function setCameraTorch(track: MediaStreamTrack | null, enabled: boolean) {
if (!track) throw new Error('No active camera track')
await track.applyConstraints({
advanced: [{ torch: enabled } as MediaTrackConstraintSet],
})
}