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
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:
parent
c91c059b31
commit
386ca99889
@ -1,6 +1,7 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
apps/print
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
202
.gitea/workflows/desktop-release.yml
Normal file
202
.gitea/workflows/desktop-release.yml
Normal 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
|
||||
46
README.md
46
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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
}
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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,
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
13
apps/print/index.html
Normal file
13
apps/print/index.html
Normal 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
22
apps/print/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
25
apps/print/src-tauri/Cargo.toml
Normal file
25
apps/print/src-tauri/Cargo.toml
Normal 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"] }
|
||||
3
apps/print/src-tauri/build.rs
Normal file
3
apps/print/src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
10
apps/print/src-tauri/capabilities/default.json
Normal file
10
apps/print/src-tauri/capabilities/default.json
Normal 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"
|
||||
]
|
||||
}
|
||||
344
apps/print/src-tauri/src/capture.rs
Normal file
344
apps/print/src-tauri/src/capture.rs
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
330
apps/print/src-tauri/src/fieldlens.rs
Normal file
330
apps/print/src-tauri/src/fieldlens.rs
Normal 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}")
|
||||
}
|
||||
))
|
||||
}
|
||||
108
apps/print/src-tauri/src/lib.rs
Normal file
108
apps/print/src-tauri/src/lib.rs
Normal 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");
|
||||
}
|
||||
5
apps/print/src-tauri/src/main.rs
Normal file
5
apps/print/src-tauri/src/main.rs
Normal file
@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
fieldlens_print_lib::run();
|
||||
}
|
||||
121
apps/print/src-tauri/src/model.rs
Normal file
121
apps/print/src-tauri/src/model.rs
Normal 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,
|
||||
}
|
||||
123
apps/print/src-tauri/src/state.rs
Normal file
123
apps/print/src-tauri/src/state.rs
Normal 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(())
|
||||
}
|
||||
127
apps/print/src-tauri/src/zpl.rs
Normal file
127
apps/print/src-tauri/src/zpl.rs
Normal 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: ®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());
|
||||
}
|
||||
}
|
||||
35
apps/print/src-tauri/tauri.conf.json
Normal file
35
apps/print/src-tauri/tauri.conf.json
Normal 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
249
apps/print/src/main.ts
Normal 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
57
apps/print/src/styles.css
Normal 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
18
apps/print/tsconfig.json
Normal 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
14
apps/print/vite.config.ts
Normal 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/**'] },
|
||||
},
|
||||
})
|
||||
81
apps/web/src/DesktopDownloads.tsx
Normal file
81
apps/web/src/DesktopDownloads.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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(
|
||||
<StrictMode>
|
||||
<CoreApp settingsAddon={<AccountSettings />} />
|
||||
<HostStatus />
|
||||
{isDownloadsPage ? (
|
||||
<DesktopDownloadsPage />
|
||||
) : (
|
||||
<CoreApp settingsAddon={<><AccountSettings /><DesktopDownloadsLink /></>} />
|
||||
)}
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
5
migrations/004_scan_source.sql
Normal file
5
migrations/004_scan_source.sql
Normal 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;
|
||||
@ -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"
|
||||
|
||||
@ -9,6 +9,8 @@ export type ScanRecord = {
|
||||
duplicate: boolean
|
||||
capturedAt: string
|
||||
schemaVersion: SchemaVersion
|
||||
source?: 'camera' | 'print' | string
|
||||
sourceMetadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type PalletSession = {
|
||||
|
||||
@ -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<HTMLVideoElement>(null)
|
||||
const controlsRef = useRef<IScannerControls | null>(null)
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
const armedRef = useRef(false)
|
||||
const sessionIdRef = useRef('')
|
||||
const scansRef = useRef<string[]>([])
|
||||
@ -23,6 +30,7 @@ export function EanPalletView() {
|
||||
const [scans, setScans] = useState<string[]>([])
|
||||
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,10 +40,22 @@ 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' } } },
|
||||
|
||||
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
|
||||
@ -71,19 +91,54 @@ export function EanPalletView() {
|
||||
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.')
|
||||
)
|
||||
})
|
||||
.then((controls) => {
|
||||
if (!controls) return
|
||||
if (disposed) {
|
||||
controls.stop()
|
||||
return
|
||||
}
|
||||
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() {
|
||||
<strong>{scans.length} / {targetCount}</strong>
|
||||
<span>{message}</span>
|
||||
<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 && (
|
||||
<button type="button" className="ean-primary" onClick={armNext}>
|
||||
<ScanLine size={17} /> Scan next carton
|
||||
|
||||
@ -11,6 +11,13 @@ import {
|
||||
type CoverCoordinateSpace,
|
||||
type Homography,
|
||||
} from '../../lib/cartonArTracking'
|
||||
import {
|
||||
acquireCameraStream,
|
||||
cameraPermissionHelp,
|
||||
getCameraTrack,
|
||||
releaseCameraStream,
|
||||
stopCameraStream,
|
||||
} from '../../lib/cameraSession'
|
||||
import {
|
||||
finishPalletLabelSession,
|
||||
savePalletLabelScans,
|
||||
@ -496,29 +503,31 @@ export function LabelCheckView({ onOpenSettings }: { onOpenSettings: () => void
|
||||
useEffect(() => {
|
||||
if (step !== 'scan') return
|
||||
let disposed = false
|
||||
let activeStream: MediaStream | null = null
|
||||
|
||||
const stopCamera = () => {
|
||||
streamRef.current?.getTracks().forEach((track) => track.stop())
|
||||
streamRef.current = null
|
||||
if (videoRef.current) videoRef.current.srcObject = null
|
||||
const detachCamera = () => {
|
||||
if (videoRef.current?.srcObject === activeStream) videoRef.current.srcObject = null
|
||||
if (streamRef.current === activeStream) streamRef.current = null
|
||||
releaseCameraStream()
|
||||
}
|
||||
|
||||
const startCamera = async () => {
|
||||
stopCamera()
|
||||
setCameraReady(false)
|
||||
setCameraError('')
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
facingMode: { ideal: 'environment' },
|
||||
const stream = await acquireCameraStream()
|
||||
activeStream = stream
|
||||
|
||||
const track = getCameraTrack(stream)
|
||||
if (track) {
|
||||
await track.applyConstraints({
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 },
|
||||
},
|
||||
})
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
|
||||
if (disposed) {
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
releaseCameraStream()
|
||||
return
|
||||
}
|
||||
|
||||
@ -532,17 +541,40 @@ export function LabelCheckView({ onOpenSettings }: { onOpenSettings: () => void
|
||||
|
||||
void startCamera().catch((cameraStartError: unknown) => {
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
stopCamera()
|
||||
detachCamera()
|
||||
}
|
||||
}, [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(() => {
|
||||
if (step !== 'scan' || !cameraReady) return
|
||||
const video = videoRef.current
|
||||
|
||||
@ -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 { 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 { detectionSpanPercent, formatName, formatsForMode, scanFormatOptions, validateScanPayload, type PayloadRules, type ScanFormatMode } from '../../lib/barcodes'
|
||||
import { loadScannerPreferences, saveScannerPreferences } from '../../lib/preferences'
|
||||
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 ConfirmationCandidate = { value: string; format: BarcodeFormat; count: number; lastSeen: number }
|
||||
type PendingCapture = { rawValue: string; format: string }
|
||||
@ -18,6 +29,7 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const controlsRef = useRef<IScannerControls | null>(null)
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
const armedRef = useRef(true)
|
||||
const autoAdvanceRef = useRef(initialPreferences.autoAdvance)
|
||||
const soundEnabledRef = useRef(initialPreferences.soundEnabled)
|
||||
@ -30,6 +42,7 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
|
||||
const lastReadRef = useRef({ value: '', time: 0 })
|
||||
const [armed, setArmed] = useState(true)
|
||||
const [status, setStatus] = useState<ScannerStatus>('starting')
|
||||
const [cameraGate, setCameraGate] = useState<'checking' | 'prompt' | 'ready' | 'blocked'>('checking')
|
||||
const [cameraError, setCameraError] = useState('')
|
||||
const [cameras, setCameras] = useState<MediaDeviceInfo[]>([])
|
||||
const [selectedCamera, setSelectedCamera] = useState('')
|
||||
@ -99,6 +112,35 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
|
||||
}
|
||||
|
||||
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
|
||||
if (!video) return
|
||||
|
||||
@ -113,13 +155,19 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
|
||||
setCameraError('')
|
||||
setTorchAvailable(false)
|
||||
|
||||
void reader.decodeFromConstraints(
|
||||
{
|
||||
audio: false,
|
||||
video: requestedCamera
|
||||
? { deviceId: { exact: requestedCamera } }
|
||||
: { facingMode: { ideal: 'environment' } },
|
||||
},
|
||||
let activeStream: MediaStream | null = null
|
||||
|
||||
void acquireCameraStream(requestedCamera)
|
||||
.then(async (stream) => {
|
||||
if (disposed) {
|
||||
releaseCameraStream()
|
||||
return null
|
||||
}
|
||||
|
||||
activeStream = stream
|
||||
streamRef.current = stream
|
||||
video.srcObject = stream
|
||||
return reader.decodeFromVideoElement(
|
||||
video,
|
||||
(result) => {
|
||||
if (!armedRef.current || qualitySettingsOpenRef.current) return
|
||||
@ -182,33 +230,99 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
|
||||
setPendingCapture(capture)
|
||||
void saveCapture(capture)
|
||||
},
|
||||
).then((controls) => {
|
||||
if (disposed) controls.stop()
|
||||
else {
|
||||
)
|
||||
})
|
||||
.then((controls) => {
|
||||
if (!controls) return
|
||||
if (disposed) {
|
||||
controls.stop()
|
||||
return
|
||||
}
|
||||
|
||||
controlsRef.current = controls
|
||||
setTorchAvailable(Boolean(controls.switchTorch))
|
||||
const activeTrack = getCameraTrack(activeStream)
|
||||
setTorchAvailable(cameraTrackHasTorch(activeTrack))
|
||||
setStatus('detecting')
|
||||
setCameraError('')
|
||||
|
||||
void BrowserMultiFormatReader.listVideoInputDevices().then((devices) => {
|
||||
if (disposed) return
|
||||
setCameras(devices)
|
||||
const activeDevice = controls.streamVideoSettingsGet?.((track) => [track]).deviceId
|
||||
const activeDevice = activeTrack?.getSettings().deviceId
|
||||
if (activeDevice) setSelectedCamera(activeDevice)
|
||||
})
|
||||
}
|
||||
}).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : 'Camera access failed.'
|
||||
setCameraError(message)
|
||||
setStatus(error instanceof DOMException && error.name === 'NotAllowedError' ? 'permission-denied' : 'camera-error')
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (disposed) return
|
||||
const denied = error instanceof DOMException && error.name === 'NotAllowedError'
|
||||
setCameraError(denied ? cameraPermissionHelp() : error instanceof Error ? error.message : 'Camera access failed.')
|
||||
if (denied) setCameraGate('blocked')
|
||||
setStatus(denied ? 'permission-denied' : 'camera-error')
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
controlsRef.current?.stop()
|
||||
controlsRef.current = null
|
||||
if (video.srcObject === activeStream) video.srcObject = null
|
||||
if (streamRef.current === activeStream) streamRef.current = null
|
||||
releaseCameraStream()
|
||||
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(() => () => {
|
||||
if (rearmTimerRef.current) window.clearTimeout(rearmTimerRef.current)
|
||||
@ -217,7 +331,7 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
|
||||
const toggleTorch = async () => {
|
||||
const nextValue = !torchOn
|
||||
try {
|
||||
await controlsRef.current?.switchTorch?.(nextValue)
|
||||
await setCameraTorch(getCameraTrack(streamRef.current), nextValue)
|
||||
setTorchOn(nextValue)
|
||||
} catch {
|
||||
setTorchAvailable(false)
|
||||
@ -276,13 +390,23 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
|
||||
setQualityMessage('')
|
||||
}
|
||||
|
||||
const retryCamera = () => {
|
||||
const enableCamera = () => {
|
||||
setCameraError('')
|
||||
setCameraGate('ready')
|
||||
setStatus('starting')
|
||||
setScannerArmed(true)
|
||||
setRetryKey((value) => value + 1)
|
||||
}
|
||||
|
||||
const retryCamera = () => {
|
||||
stopCameraStream()
|
||||
enableCamera()
|
||||
}
|
||||
|
||||
const statusLabel = {
|
||||
starting: 'STARTING CAMERA',
|
||||
'permission-prompt': 'CAMERA READY',
|
||||
suspended: 'CAMERA PAUSED',
|
||||
detecting: 'READY TO SCAN',
|
||||
captured: 'CAPTURED',
|
||||
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>
|
||||
{detectionPoints.map((point, index) => <i className="detection-point" style={{ left: `${point.left}%`, top: `${point.top}%` }} key={index} />)}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -354,20 +482,52 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
|
||||
</div>
|
||||
<div className="result-copy">
|
||||
<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>
|
||||
<strong>{status === 'storage-error' ? pendingCapture?.rawValue : cameraError ? 'Camera access required' : latest?.rawValue ?? 'Waiting for a code'}</strong>
|
||||
<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>
|
||||
<strong>{status === 'storage-error'
|
||||
? 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>
|
||||
{status === 'storage-error' && pendingCapture ? (
|
||||
<div className="result-actions">
|
||||
<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>
|
||||
</div>
|
||||
) : status === 'permission-prompt' ? (
|
||||
<button className="primary-button" type="button" onClick={enableCamera}><Camera size={17} /> Enable camera</button>
|
||||
) : 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
|
||||
</button>
|
||||
)}
|
||||
|
||||
24
packages/fieldlens-core/src/lib/cameraSession.test.ts
Normal file
24
packages/fieldlens-core/src/lib/cameraSession.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
145
packages/fieldlens-core/src/lib/cameraSession.ts
Normal file
145
packages/fieldlens-core/src/lib/cameraSession.ts
Normal 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],
|
||||
})
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user