From 386ca9988991ac448d00d464dabe9a302e0c4e6a Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 23 Sep 2026 21:31:42 +0000 Subject: [PATCH] deploy: sync FieldLens Print and camera lifecycle --- .dockerignore | 1 + .env.example | 3 + .gitea/workflows/ci.yml | 17 + .gitea/workflows/desktop-release.yml | 202 ++++++++++ README.md | 46 +++ apps/api/.env.example | 3 + apps/api/src/auth.ts | 2 + apps/api/src/config.ts | 3 + apps/api/src/server.ts | 13 +- apps/api/src/sync.ts | 6 +- apps/print/index.html | 13 + apps/print/package.json | 22 ++ apps/print/src-tauri/Cargo.toml | 25 ++ apps/print/src-tauri/build.rs | 3 + .../print/src-tauri/capabilities/default.json | 10 + apps/print/src-tauri/src/capture.rs | 344 ++++++++++++++++++ apps/print/src-tauri/src/fieldlens.rs | 330 +++++++++++++++++ apps/print/src-tauri/src/lib.rs | 108 ++++++ apps/print/src-tauri/src/main.rs | 5 + apps/print/src-tauri/src/model.rs | 121 ++++++ apps/print/src-tauri/src/state.rs | 123 +++++++ apps/print/src-tauri/src/zpl.rs | 127 +++++++ apps/print/src-tauri/tauri.conf.json | 35 ++ apps/print/src/main.ts | 249 +++++++++++++ apps/print/src/styles.css | 57 +++ apps/print/tsconfig.json | 18 + apps/print/vite.config.ts | 14 + apps/web/src/DesktopDownloads.tsx | 81 +++++ apps/web/src/host.css | 230 +++++++++--- apps/web/src/main.tsx | 14 +- migrations/004_scan_source.sql | 5 + package.json | 3 +- packages/contracts/src/index.ts | 2 + .../src/features/ean-pallet/EanPalletView.tsx | 146 +++++--- .../features/label-check/LabelCheckView.tsx | 60 ++- .../src/features/scanner/ScannerView.tsx | 222 +++++++++-- .../src/lib/cameraSession.test.ts | 24 ++ .../fieldlens-core/src/lib/cameraSession.ts | 145 ++++++++ 38 files changed, 2686 insertions(+), 146 deletions(-) create mode 100644 .gitea/workflows/desktop-release.yml create mode 100644 apps/print/index.html create mode 100644 apps/print/package.json create mode 100644 apps/print/src-tauri/Cargo.toml create mode 100644 apps/print/src-tauri/build.rs create mode 100644 apps/print/src-tauri/capabilities/default.json create mode 100644 apps/print/src-tauri/src/capture.rs create mode 100644 apps/print/src-tauri/src/fieldlens.rs create mode 100644 apps/print/src-tauri/src/lib.rs create mode 100644 apps/print/src-tauri/src/main.rs create mode 100644 apps/print/src-tauri/src/model.rs create mode 100644 apps/print/src-tauri/src/state.rs create mode 100644 apps/print/src-tauri/src/zpl.rs create mode 100644 apps/print/src-tauri/tauri.conf.json create mode 100644 apps/print/src/main.ts create mode 100644 apps/print/src/styles.css create mode 100644 apps/print/tsconfig.json create mode 100644 apps/print/vite.config.ts create mode 100644 apps/web/src/DesktopDownloads.tsx create mode 100644 migrations/004_scan_source.sql create mode 100644 packages/fieldlens-core/src/lib/cameraSession.test.ts create mode 100644 packages/fieldlens-core/src/lib/cameraSession.ts diff --git a/.dockerignore b/.dockerignore index 474095d..e129542 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,7 @@ node_modules **/node_modules **/dist +apps/print .git .env .env.* diff --git a/.env.example b/.env.example index 5394315..99210da 100644 --- a/.env.example +++ b/.env.example @@ -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_AUDIENCE=fieldlens-api KEYCLOAK_BROWSER_CLIENT_ID=fieldlens +KEYCLOAK_DESKTOP_CLIENT_ID=fieldlens-print KEYCLOAK_MACHINE_CLIENT_ID=fieldlens-integration FIELDLENS_TENANT_ID=fieldlens-production 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 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 2042b16..8c6ae1e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -18,4 +18,21 @@ jobs: - run: npm run lint - run: npm run migration:validate - run: npm run build + - run: npm run build:print - 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 diff --git a/.gitea/workflows/desktop-release.yml b/.gitea/workflows/desktop-release.yml new file mode 100644 index 0000000..f9546c9 --- /dev/null +++ b/.gitea/workflows/desktop-release.yml @@ -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 diff --git a/README.md b/README.md index cb5665b..f8f04af 100644 --- a/README.md +++ b/README.md @@ -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. + + +## 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. diff --git a/apps/api/.env.example b/apps/api/.env.example index 8b663bb..30014fa 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -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_AUDIENCE=fieldlens-api KEYCLOAK_BROWSER_CLIENT_ID=fieldlens +KEYCLOAK_DESKTOP_CLIENT_ID=fieldlens-print KEYCLOAK_MACHINE_CLIENT_ID=fieldlens-integration FIELDLENS_TENANT_ID=fieldlens-production FIELDLENS_ENV=development LOG_LEVEL=info WEB_ORIGIN=http://localhost:5173 STATIC_DIR= +FIELDLENS_PRINT_RELEASE_TAG=fieldlens-print-v0.1.0 +FIELDLENS_PRINT_RELEASE_BASE_URL=https://git.example.invalid/owner/fieldlens/releases/download diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index 4c9a043..d0afe20 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -33,6 +33,7 @@ export function rolesFromPayload(payload: JWTPayload) { return [...new Set([ ...strings(resourceAccess?.[config.audience]?.roles), ...strings(resourceAccess?.[config.browserClientId]?.roles), + ...strings(resourceAccess?.[config.desktopClientId]?.roles), ...strings(resourceAccess?.[config.machineClientId]?.roles), ])] } @@ -47,6 +48,7 @@ function intendedForFieldLens(payload: JWTPayload) { const azp = typeof payload.azp === 'string' ? payload.azp : '' return audience.includes(config.audience) || azp === config.browserClientId + || azp === config.desktopClientId || azp === config.machineClientId } diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index ee2fe6e..a12cf3d 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -11,10 +11,13 @@ export const config = { jwksUri: (process.env.KEYCLOAK_JWKS_URI ?? '').trim(), audience: process.env.KEYCLOAK_AUDIENCE?.trim() || 'fieldlens-api', 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', tenantId: process.env.FIELDLENS_TENANT_ID?.trim() || 'fieldlens-production', environment: process.env.FIELDLENS_ENV?.trim() || 'development', logLevel: process.env.LOG_LEVEL?.trim() || 'info', webOrigin: process.env.WEB_ORIGIN?.trim() || 'http://localhost:5173', 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', } diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index c0cd5cf..8f02971 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -61,6 +61,16 @@ app.get('/api/health/live', (_req, res) => { 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) => { try { 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 result = await pool.query(` 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 ORDER BY captured_at DESC LIMIT $2 `, [config.tenantId, limit]) diff --git a/apps/api/src/sync.ts b/apps/api/src/sync.ts index f140e06..d393362 100644 --- a/apps/api/src/sync.ts +++ b/apps/api/src/sync.ts @@ -9,13 +9,15 @@ async function insertScan(client: PoolClient, event: OutboxEvent, context: Devic await client.query(` INSERT INTO scans ( id, tenant_id, site_id, station_id, device_id, operator_subject, - raw_value, format, duplicate, captured_at, schema_version - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + raw_value, format, duplicate, captured_at, schema_version, source, source_metadata + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT (id) DO NOTHING `, [ payload.id, context.tenantId, context.siteId, context.stationId, context.deviceId, identity.subject, payload.rawValue, payload.format, Boolean(payload.duplicate), payload.capturedAt, event.schemaVersion, + typeof payload.source === 'string' ? payload.source : 'camera', + payload.sourceMetadata ? JSON.stringify(payload.sourceMetadata) : null, ]) } diff --git a/apps/print/index.html b/apps/print/index.html new file mode 100644 index 0000000..37a6a91 --- /dev/null +++ b/apps/print/index.html @@ -0,0 +1,13 @@ + + + + + + + FieldLens Print + + +
+ + + diff --git a/apps/print/package.json b/apps/print/package.json new file mode 100644 index 0000000..030aaad --- /dev/null +++ b/apps/print/package.json @@ -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" + } +} diff --git a/apps/print/src-tauri/Cargo.toml b/apps/print/src-tauri/Cargo.toml new file mode 100644 index 0000000..57428b7 --- /dev/null +++ b/apps/print/src-tauri/Cargo.toml @@ -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"] } diff --git a/apps/print/src-tauri/build.rs b/apps/print/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/apps/print/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/apps/print/src-tauri/capabilities/default.json b/apps/print/src-tauri/capabilities/default.json new file mode 100644 index 0000000..52241d5 --- /dev/null +++ b/apps/print/src-tauri/capabilities/default.json @@ -0,0 +1,10 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "FieldLens Print desktop permissions", + "windows": ["main"], + "permissions": [ + "core:default", + "opener:default" + ] +} diff --git a/apps/print/src-tauri/src/capture.rs b/apps/print/src-tauri/src/capture.rs new file mode 100644 index 0000000..3839d43 --- /dev/null +++ b/apps/print/src-tauri/src/capture.rs @@ -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, + handles: Vec>, +} + +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 { + 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, +) { + 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 = 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, +) { + 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) { + 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, + forwarded: bool, + error: Option, +) { + 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, + }, + ); +} diff --git a/apps/print/src-tauri/src/fieldlens.rs b/apps/print/src-tauri/src/fieldlens.rs new file mode 100644 index 0000000..5f01fee --- /dev/null +++ b/apps/print/src-tauri/src/fieldlens.rs @@ -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, + expires_in: u64, + #[serde(default = "default_interval")] + interval: u64, +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + #[serde(default)] + refresh_token: Option, + 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::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .context("create HTTP client") +} + +pub fn begin_login(state: &SharedState) -> Result { + 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 { + 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::() + .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 { + { + 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 { + 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 { + 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 = pending.into_iter().take(100).collect(); + let events: Vec = 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 = 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 { + 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 { + 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}") + } + )) +} diff --git a/apps/print/src-tauri/src/lib.rs b/apps/print/src-tauri/src/lib.rs new file mode 100644 index 0000000..f2e9f94 --- /dev/null +++ b/apps/print/src-tauri/src/lib.rs @@ -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>, +} + +#[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 { + 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 { + 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 { + fieldlens::begin_login(&state.shared).map_err(|error| error.to_string()) +} + +#[tauri::command] +fn poll_login(state: State<'_, AppState>) -> Result { + fieldlens::poll_login(&state.shared).map_err(|error| error.to_string()) +} + +#[tauri::command] +fn sync_pending(state: State<'_, AppState>) -> Result { + 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"); +} diff --git a/apps/print/src-tauri/src/main.rs b/apps/print/src-tauri/src/main.rs new file mode 100644 index 0000000..5e4dd91 --- /dev/null +++ b/apps/print/src-tauri/src/main.rs @@ -0,0 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + fieldlens_print_lib::run(); +} diff --git a/apps/print/src-tauri/src/model.rs b/apps/print/src-tauri/src/model.rs new file mode 100644 index 0000000..71913a5 --- /dev/null +++ b/apps/print/src-tauri/src/model.rs @@ -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, +} + +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, + pub captured_at: String, + pub forwarded: bool, + pub error: Option, +} + +#[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, + pub access_token: Option, + pub refresh_token: Option, + pub expires_at_unix: Option, + 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, + 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, +} diff --git a/apps/print/src-tauri/src/state.rs b/apps/print/src-tauri/src/state.rs new file mode 100644 index 0000000..d534216 --- /dev/null +++ b/apps/print/src-tauri/src/state.rs @@ -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>, + pub jobs: Arc>>, + pub pending: Arc>>, + pub auth: Arc>, + pub data_dir: PathBuf, +} + +impl SharedState { + pub fn load(data_dir: PathBuf) -> Result { + fs::create_dir_all(&data_dir).context("create FieldLens Print data directory")?; + + let mut config = read_json::(&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::>(&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 { + 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(path: &Path) -> Option { + let bytes = fs::read(path).ok()?; + serde_json::from_slice(&bytes).ok() +} + +fn write_json_atomic(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(()) +} diff --git a/apps/print/src-tauri/src/zpl.rs b/apps/print/src-tauri/src/zpl.rs new file mode 100644 index 0000000..d1ac0b6 --- /dev/null +++ b/apps/print/src-tauri/src/zpl.rs @@ -0,0 +1,127 @@ +use crate::model::DecodedBarcode; + +pub fn extract_barcodes(bytes: &[u8]) -> Vec { + let text = String::from_utf8_lossy(bytes); + extract_from_zpl(&text) +} + +pub fn extract_from_zpl(input: &str) -> Vec { + let bytes = input.as_bytes(); + let mut index = 0usize; + let mut pending_format: Option = 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: ®ex::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()); + } +} diff --git a/apps/print/src-tauri/tauri.conf.json b/apps/print/src-tauri/tauri.conf.json new file mode 100644 index 0000000..00188b4 --- /dev/null +++ b/apps/print/src-tauri/tauri.conf.json @@ -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" + } + } + } +} diff --git a/apps/print/src/main.ts b/apps/print/src/main.ts new file mode 100644 index 0000000..7f2ad5a --- /dev/null +++ b/apps/print/src/main.ts @@ -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('#app')! +let config: AppConfig +let jobs: Job[] = [] +let status: Status +let loginTimer: number | undefined + +app.innerHTML = ` +
+
+
+
FL
+

FieldLens Print

Digital barcode capture before the label leaves the printer.

+
+
Loading
+
+ +
+
+

Capture

RAW + spool routes
+
+
+ + + + +
+
+ + + +
+
+
+ +
+

FieldLens

Device login
+
+
+
0Jobs
+
0Codes
+
0Pending
+
+ +
+ Connection settings +
+ + + + +
+
+
+
+ +
+

Recent print jobs

Waiting for capture
+
+
+
+
+` + +function el(id: string) { + return document.getElementById(id) as T +} + +function readForm(): AppConfig { + return { + ...config, + mode: el('mode').value as CaptureMode, + listenAddr: el('listenAddr').value.trim(), + forwardAddr: el('forwardAddr').value.trim(), + spoolDir: el('spoolDir').value.trim(), + apiBaseUrl: el('apiBaseUrl').value.trim(), + issuer: el('issuer').value.trim(), + clientId: el('clientId').value.trim(), + stationId: el('stationId').value.trim(), + } +} + +function writeForm(next: AppConfig) { + el('mode').value = next.mode + el('listenAddr').value = next.listenAddr + el('forwardAddr').value = next.forwardAddr + el('spoolDir').value = next.spoolDir + el('apiBaseUrl').value = next.apiBaseUrl + el('issuer').value = next.issuer + el('clientId').value = next.clientId + el('stationId').value = next.stationId +} + +function render() { + const pill = el('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) => ` +
+
${escapeHtml(job.source)} · ${escapeHtml(job.label)}${new Date(job.capturedAt).toLocaleTimeString()}
+ ${job.barcodes.length ? `${job.barcodes.length} barcode${job.barcodes.length === 1 ? '' : 's'} detected` : 'No supported barcode command detected'} +
${job.barcodes.map((code) => `${escapeHtml(code.format)} · ${escapeHtml(code.value)}`).join('')}
+
${job.bytes.toLocaleString()} bytes · ${job.forwarded ? 'forwarded' : 'not forwarded'}${job.error ? ` · ${escapeHtml(job.error)}` : ''}
+
+ `).join('') : '
No jobs captured yet.
' +} + +function escapeHtml(value: string) { + return value.replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]!)) +} + +async function refreshStatus() { + status = await invoke('get_status') + jobs = await invoke('recent_jobs') + render() +} + +el('save').addEventListener('click', async () => { + config = await invoke('save_config', { config: readForm() }) + writeForm(config) + await refreshStatus() +}) + +el('start').addEventListener('click', async () => { + config = await invoke('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('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('capture-job', (event) => { + jobs = [event.payload, ...jobs.filter((job) => job.id !== event.payload.id)].slice(0, 200) + void refreshStatus() +}) + +config = await invoke('get_config') +writeForm(config) +await refreshStatus() +window.setInterval(() => { void refreshStatus() }, 5000) diff --git a/apps/print/src/styles.css b/apps/print/src/styles.css new file mode 100644 index 0000000..981c5fe --- /dev/null +++ b/apps/print/src/styles.css @@ -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; } +} diff --git a/apps/print/tsconfig.json b/apps/print/tsconfig.json new file mode 100644 index 0000000..ed0c191 --- /dev/null +++ b/apps/print/tsconfig.json @@ -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"] +} diff --git a/apps/print/vite.config.ts b/apps/print/vite.config.ts new file mode 100644 index 0000000..cf3cd6f --- /dev/null +++ b/apps/print/vite.config.ts @@ -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/**'] }, + }, +}) diff --git a/apps/web/src/DesktopDownloads.tsx b/apps/web/src/DesktopDownloads.tsx new file mode 100644 index 0000000..ddb6a0b --- /dev/null +++ b/apps/web/src/DesktopDownloads.tsx @@ -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 ( +
+
+ +

Desktop app

Capture printed barcodes without a physical scan

+
+ + + + Get the desktop app + Windows and Linux installers + + Downloads + +
+ ) +} + +export function DesktopDownloadsPage() { + return ( +
+
+ Back to FieldLens + +
+ FL +
+

FIELDLENS PRINT

+

Get the desktop app

+

Capture barcodes directly from print jobs and sync them into FieldLens without a physical scan.

+
+
+ +
+
+
+

Choose your platform

+

FieldLens Print captures RAW/ZPL and spool output locally, extracts barcode values, then syncs them to FieldLens.

+
+
+ +
+ {downloads.map(({ href, label, detail, icon: Icon }) => ( + + + + {label} + {detail} + + Download + + ))} +
+ +
+

Windows: installs for the current user, so the app itself does not require administrator elevation.

+

Printer setup: changing a managed Windows printer queue or port can still require IT/admin permission.

+
+
+
+
+ ) +} diff --git a/apps/web/src/host.css b/apps/web/src/host.css index e78def9..47e6135 100644 --- a/apps/web/src/host.css +++ b/apps/web/src/host.css @@ -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-actions { display: flex; @@ -93,15 +49,189 @@ body:has(.workspace-view) .host-status { .empty-requests { margin: 0; padding: 16px 18px; color: #75807b; font-size: 10px; } @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 .primary-button, .account-actions .quiet-button { width: 100%; min-width: 0; } .access-requests article { grid-template-columns: 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; } +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index af62380..fd4ea26 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -11,7 +11,7 @@ import './host.css' import CoreApp from '../../../packages/fieldlens-core/src/App' import { startDeviceHoldTracking } from '../../../packages/fieldlens-core/src/lib/deviceOrientation' import { AccountSettings } from './AccountSettings' -import { HostStatus } from './HostStatus' +import { DesktopDownloadsLink, DesktopDownloadsPage } from './DesktopDownloads' import { registerArrowstackReferenceProvider } from './referenceProvider' import { installSyncTriggers } from './sync' @@ -19,9 +19,15 @@ startDeviceHoldTracking() registerArrowstackReferenceProvider() installSyncTriggers() -createRoot(document.getElementById('root')!).render( +const isDownloadsPage = window.location.pathname === '/downloads' +const root = createRoot(document.getElementById('root')!) + +root.render( - } /> - + {isDownloadsPage ? ( + + ) : ( + } /> + )} , ) diff --git a/migrations/004_scan_source.sql b/migrations/004_scan_source.sql new file mode 100644 index 0000000..5ed11e8 --- /dev/null +++ b/migrations/004_scan_source.sql @@ -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; diff --git a/package.json b/package.json index 9a311fa..4b3cc33 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "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", "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": { "node": ">=24" diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c9ffc58..18283a5 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -9,6 +9,8 @@ export type ScanRecord = { duplicate: boolean capturedAt: string schemaVersion: SchemaVersion + source?: 'camera' | 'print' | string + sourceMetadata?: Record } export type PalletSession = { diff --git a/packages/fieldlens-core/src/features/ean-pallet/EanPalletView.tsx b/packages/fieldlens-core/src/features/ean-pallet/EanPalletView.tsx index 6cc7202..12e185a 100644 --- a/packages/fieldlens-core/src/features/ean-pallet/EanPalletView.tsx +++ b/packages/fieldlens-core/src/features/ean-pallet/EanPalletView.tsx @@ -2,6 +2,12 @@ import { BrowserMultiFormatReader, type IScannerControls } from '@zxing/browser' import { CheckCircle2, RefreshCw, ScanLine, XCircle } from 'lucide-react' import { useEffect, useMemo, useRef, useState } from 'react' +import { + acquireCameraStream, + cameraPermissionHelp, + releaseCameraStream, + stopCameraStream, +} from '../../lib/cameraSession' import { finishEanPalletSession, saveEanPalletScans, @@ -15,6 +21,7 @@ type Step = 'setup' | 'scan' | 'result' export function EanPalletView() { const videoRef = useRef(null) const controlsRef = useRef(null) + const streamRef = useRef(null) const armedRef = useRef(false) const sessionIdRef = useRef('') const scansRef = useRef([]) @@ -23,6 +30,7 @@ export function EanPalletView() { const [scans, setScans] = useState([]) const [armed, setArmed] = useState(false) const [cameraError, setCameraError] = useState('') + const [cameraRetryKey, setCameraRetryKey] = useState(0) const [message, setMessage] = useState('Ready') const summary = useMemo(() => summarizeEanScans(scans), [scans]) @@ -32,58 +40,105 @@ export function EanPalletView() { if (!video) return let disposed = false + let activeStream: MediaStream | null = null setCameraError('') const reader = new BrowserMultiFormatReader() - void reader.decodeFromConstraints( - { audio: false, video: { facingMode: { ideal: 'environment' } } }, - 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.') + + void acquireCameraStream() + .then(async (stream) => { + if (disposed) { + releaseCameraStream() + return null + } + + 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 } - - 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 (disposed) controls.stop() - else controlsRef.current = controls - }).catch((error) => { - if (!disposed) setCameraError(error instanceof Error ? error.message : 'Camera could not be started.') - }) + controlsRef.current = controls + }) + .catch((error: unknown) => { + if (disposed) return + const denied = error instanceof DOMException && error.name === 'NotAllowedError' + setCameraError(denied ? cameraPermissionHelp() : error instanceof Error ? error.message : 'Camera could not be started.') + }) return () => { disposed = true controlsRef.current?.stop() 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 safeCount = Math.max(1, Math.min(500, Math.round(targetCount || 1))) @@ -154,7 +209,12 @@ export function EanPalletView() { {scans.length} / {targetCount} {message}
- {cameraError &&

{cameraError}

} + {cameraError && ( +
+ {cameraError} + +
+ )} {!armed && scans.length < targetCount && ( + ) : status === 'permission-prompt' ? ( + ) : cameraError ? ( - + ) : ( - )} diff --git a/packages/fieldlens-core/src/lib/cameraSession.test.ts b/packages/fieldlens-core/src/lib/cameraSession.test.ts new file mode 100644 index 0000000..8d7dafb --- /dev/null +++ b/packages/fieldlens-core/src/lib/cameraSession.test.ts @@ -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() + }) +}) diff --git a/packages/fieldlens-core/src/lib/cameraSession.ts b/packages/fieldlens-core/src/lib/cameraSession.ts new file mode 100644 index 0000000..29304ec --- /dev/null +++ b/packages/fieldlens-core/src/lib/cameraSession.ts @@ -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 { + 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], + }) +}