diff --git a/.dockerignore b/.dockerignore
index 474095d..e129542 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,6 +1,7 @@
node_modules
**/node_modules
**/dist
+apps/print
.git
.env
.env.*
diff --git a/.env.example b/.env.example
index 5394315..99210da 100644
--- a/.env.example
+++ b/.env.example
@@ -6,6 +6,9 @@ KEYCLOAK_ISSUER=https://auth.example.invalid/realms/example
KEYCLOAK_JWKS_URI=https://auth.example.invalid/realms/example/protocol/openid-connect/certs
KEYCLOAK_AUDIENCE=fieldlens-api
KEYCLOAK_BROWSER_CLIENT_ID=fieldlens
+KEYCLOAK_DESKTOP_CLIENT_ID=fieldlens-print
KEYCLOAK_MACHINE_CLIENT_ID=fieldlens-integration
FIELDLENS_TENANT_ID=fieldlens-production
WEB_ORIGIN=https://fieldlens.example.invalid
+FIELDLENS_PRINT_RELEASE_TAG=fieldlens-print-v0.1.0
+FIELDLENS_PRINT_RELEASE_BASE_URL=https://git.example.invalid/owner/fieldlens/releases/download
diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 2042b16..8c6ae1e 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -18,4 +18,21 @@ jobs:
- run: npm run lint
- run: npm run migration:validate
- run: npm run build
+ - run: npm run build:print
- run: docker build -t fieldlens-ci .
+
+ print-rust:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install Tauri Linux prerequisites
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
+ - uses: dtolnay/rust-toolchain@stable
+ - name: Test FieldLens Print native core
+ working-directory: apps/print/src-tauri
+ run: cargo test
+ - name: Check FieldLens Print native core
+ working-directory: apps/print/src-tauri
+ run: cargo check
diff --git a/.gitea/workflows/desktop-release.yml b/.gitea/workflows/desktop-release.yml
new file mode 100644
index 0000000..f9546c9
--- /dev/null
+++ b/.gitea/workflows/desktop-release.yml
@@ -0,0 +1,202 @@
+name: Desktop Release
+
+on:
+ push:
+ branches: [main]
+ tags:
+ - "fieldlens-print-v*"
+ paths:
+ - "apps/print/**"
+ - ".gitea/workflows/desktop-release.yml"
+ pull_request:
+ paths:
+ - "apps/print/**"
+ - ".gitea/workflows/desktop-release.yml"
+ workflow_dispatch:
+
+permissions:
+ code: read
+ releases: read
+
+jobs:
+ windows-nsis:
+ name: Windows x64 NSIS
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: x86_64-pc-windows-msvc
+
+ - name: Install Windows cross-build tools
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y nsis lld llvm clang
+ cargo install --locked cargo-xwin
+
+ - name: Install Node dependencies
+ run: npm install
+
+ - name: Build Windows NSIS installer
+ run: npm run bundle:windows --workspace @fieldlens/print
+ env:
+ XWIN_CACHE_DIR: ${{ runner.temp }}/xwin-cache
+
+ - name: Stage Windows installer
+ shell: bash
+ run: |
+ set -euo pipefail
+ source_file="$(find apps/print/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis -maxdepth 1 -type f -name '*-setup.exe' -print -quit)"
+ test -n "$source_file"
+ mkdir -p dist/desktop
+ cp "$source_file" dist/desktop/fieldlens-print-windows-x64-setup.exe
+
+ - name: Upload Windows installer
+ uses: actions/upload-artifact@v3
+ with:
+ name: fieldlens-print-windows-x64
+ path: dist/desktop/fieldlens-print-windows-x64-setup.exe
+ if-no-files-found: error
+ retention-days: 14
+
+ linux-deb:
+ name: Linux x64 DEB
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+
+ - uses: dtolnay/rust-toolchain@stable
+
+ - name: Install Tauri Linux prerequisites
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ build-essential \
+ curl \
+ wget \
+ file \
+ libwebkit2gtk-4.1-dev \
+ libxdo-dev \
+ libssl-dev \
+ libayatana-appindicator3-dev \
+ librsvg2-dev \
+ patchelf
+
+ - name: Install Node dependencies
+ run: npm install
+
+ - name: Build Debian package
+ run: npm run bundle:deb --workspace @fieldlens/print
+
+ - name: Stage Debian package
+ shell: bash
+ run: |
+ set -euo pipefail
+ source_file="$(find apps/print/src-tauri/target/release/bundle/deb -maxdepth 1 -type f -name '*.deb' -print -quit)"
+ test -n "$source_file"
+ mkdir -p dist/desktop
+ cp "$source_file" dist/desktop/fieldlens-print-linux-x64.deb
+
+ - name: Upload Debian package
+ uses: actions/upload-artifact@v3
+ with:
+ name: fieldlens-print-linux-x64-deb
+ path: dist/desktop/fieldlens-print-linux-x64.deb
+ if-no-files-found: error
+ retention-days: 14
+
+ publish-release:
+ name: Publish tagged desktop release
+ if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/fieldlens-print-v')
+ needs: [windows-nsis, linux-deb]
+ runs-on: ubuntu-latest
+ permissions:
+ code: read
+ releases: write
+ steps:
+ - name: Download Windows installer
+ uses: actions/download-artifact@v3
+ with:
+ name: fieldlens-print-windows-x64
+ path: release-assets
+
+ - name: Download Debian package
+ uses: actions/download-artifact@v3
+ with:
+ name: fieldlens-print-linux-x64-deb
+ path: release-assets
+
+ - name: Publish Gitea release assets
+ shell: bash
+ env:
+ GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
+ GITEA_SERVER_URL: ${{ gitea.server_url }}
+ GITEA_REPOSITORY: ${{ gitea.repository }}
+ GITEA_REF: ${{ gitea.ref }}
+ GITEA_REF_NAME: ${{ gitea.ref_name }}
+ GITEA_SHA: ${{ gitea.sha }}
+ run: |
+ set -euo pipefail
+
+ api="${GITEA_SERVER_URL%/}/api/v1/repos/${GITEA_REPOSITORY}"
+ auth_header="Authorization: token ${GITEA_TOKEN}"
+ release_tag="$GITEA_REF_NAME"
+ if [ "$GITEA_REF" = "refs/heads/main" ]; then
+ release_tag="fieldlens-print-v0.1.0"
+ fi
+
+ status="$(curl -sS -o /tmp/release.json -w '%{http_code}' \
+ -H "$auth_header" \
+ "$api/releases/tags/$release_tag")"
+
+ if [ "$status" = "404" ]; then
+ jq -n \
+ --arg tag "$release_tag" \
+ --arg sha "$GITEA_SHA" \
+ '{
+ tag_name: $tag,
+ target_commitish: $sha,
+ name: ("FieldLens Print " + ($tag | sub("^fieldlens-print-"; ""))),
+ body: "FieldLens Print desktop installers.",
+ draft: false,
+ prerelease: false
+ }' > /tmp/create-release.json
+
+ curl -fsS \
+ -X POST \
+ -H "$auth_header" \
+ -H "Content-Type: application/json" \
+ --data-binary @/tmp/create-release.json \
+ "$api/releases" > /tmp/release.json
+ elif [ "$status" != "200" ]; then
+ cat /tmp/release.json
+ exit 1
+ fi
+
+ release_id="$(jq -r '.id' /tmp/release.json)"
+ test "$release_id" != "null"
+
+ curl -fsS -H "$auth_header" "$api/releases/$release_id/assets" > /tmp/assets.json
+
+ for file in release-assets/fieldlens-print-windows-x64-setup.exe release-assets/fieldlens-print-linux-x64.deb; do
+ name="$(basename "$file")"
+ existing_id="$(jq -r --arg name "$name" '.[] | select(.name == $name) | .id' /tmp/assets.json | head -n 1)"
+ if [ -n "$existing_id" ] && [ "$existing_id" != "null" ]; then
+ curl -fsS -X DELETE -H "$auth_header" "$api/releases/$release_id/assets/$existing_id"
+ fi
+
+ curl -fsS \
+ -X POST \
+ -H "$auth_header" \
+ -F "attachment=@$file" \
+ "$api/releases/$release_id/assets?name=$name"
+ done
diff --git a/README.md b/README.md
index cb5665b..f8f04af 100644
--- a/README.md
+++ b/README.md
@@ -33,3 +33,49 @@ docker build -t fieldlens .
```
For Arrowstack `database-per-tenant` deployments, the restricted application runtime identity owns DDL inside its dedicated database. The API applies versioned migrations at startup under a PostgreSQL advisory lock before it begins listening; applied migration filenames and checksums are recorded so startup is idempotent and changed historical migrations fail closed.
+
+
+## FieldLens Print desktop companion
+
+`apps/print` is a Tauri desktop companion that turns an outgoing print stream into FieldLens scan events without requiring a physical barcode scan.
+
+Capture routes:
+
+- **RAW 9100 proxy** — listen locally (default `127.0.0.1:9100`), extract supported barcode fields from ZPL, and forward the original bytes unchanged to the real printer.
+- **Spool / virtual-printer folder** — watch a local folder for `.zpl`, `.raw`, `.prn`, or `.spl` jobs produced by a print-to-file or virtual-printer route. Parsed jobs can also be forwarded to the physical printer.
+- **Both** — run both adapters at the same time.
+
+The first parser handles ZPL barcode commands for Code 128, Code 39, Code 93, EAN-8/13, UPC-A/E, ITF, QR, Data Matrix, and PDF417. Non-ZPL raster/EMF/XPS rendering is intentionally a later fallback; unsupported jobs are retained in recent-job diagnostics instead of being treated as successful scans.
+
+Authentication uses Keycloak OAuth Device Authorization Grant with the public `fieldlens-print` client. No client secret is embedded in the desktop executable. Access and refresh tokens are kept in memory only; unsynced barcode events are stored locally under the Tauri app-data directory.
+
+### Development
+
+```bash
+npm install
+npm run build --workspace @fieldlens/print
+npm run tauri --workspace @fieldlens/print -- dev
+```
+
+The Windows NSIS bundle is configured for `currentUser` installation, so installing FieldLens Print itself does not require elevation. Configuring or changing a Windows printer queue/port may still require IT/admin rights depending on machine policy.
+
+
+### Desktop release artifacts
+
+The `.gitea/workflows/desktop-release.yml` workflow packages FieldLens Print on changes to the desktop app, on `main`, and on `fieldlens-print-v*` tags.
+
+Artifacts:
+
+- **Windows x64:** NSIS current-user installer from `apps/print/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe`
+- **Linux x64:** Debian package from `apps/print/src-tauri/target/release/bundle/deb/*.deb`
+
+After a successful Gitea Actions run, download the corresponding `fieldlens-print-windows-x64` or `fieldlens-print-linux-x64-deb` artifact from the workflow run.
+
+Local packaging commands:
+
+```bash
+npm run bundle:windows --workspace @fieldlens/print
+npm run bundle:deb --workspace @fieldlens/print
+```
+
+The Windows package is cross-compiled with `cargo-xwin` and bundled with NSIS. MSI packaging is intentionally not part of the Linux runner workflow because WiX/MSI bundling requires Windows.
diff --git a/apps/api/.env.example b/apps/api/.env.example
index 8b663bb..30014fa 100644
--- a/apps/api/.env.example
+++ b/apps/api/.env.example
@@ -4,9 +4,12 @@ KEYCLOAK_ISSUER=https://auth.example.invalid/realms/example
KEYCLOAK_JWKS_URI=https://auth.example.invalid/realms/example/protocol/openid-connect/certs
KEYCLOAK_AUDIENCE=fieldlens-api
KEYCLOAK_BROWSER_CLIENT_ID=fieldlens
+KEYCLOAK_DESKTOP_CLIENT_ID=fieldlens-print
KEYCLOAK_MACHINE_CLIENT_ID=fieldlens-integration
FIELDLENS_TENANT_ID=fieldlens-production
FIELDLENS_ENV=development
LOG_LEVEL=info
WEB_ORIGIN=http://localhost:5173
STATIC_DIR=
+FIELDLENS_PRINT_RELEASE_TAG=fieldlens-print-v0.1.0
+FIELDLENS_PRINT_RELEASE_BASE_URL=https://git.example.invalid/owner/fieldlens/releases/download
diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts
index 4c9a043..d0afe20 100644
--- a/apps/api/src/auth.ts
+++ b/apps/api/src/auth.ts
@@ -33,6 +33,7 @@ export function rolesFromPayload(payload: JWTPayload) {
return [...new Set([
...strings(resourceAccess?.[config.audience]?.roles),
...strings(resourceAccess?.[config.browserClientId]?.roles),
+ ...strings(resourceAccess?.[config.desktopClientId]?.roles),
...strings(resourceAccess?.[config.machineClientId]?.roles),
])]
}
@@ -47,6 +48,7 @@ function intendedForFieldLens(payload: JWTPayload) {
const azp = typeof payload.azp === 'string' ? payload.azp : ''
return audience.includes(config.audience)
|| azp === config.browserClientId
+ || azp === config.desktopClientId
|| azp === config.machineClientId
}
diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts
index ee2fe6e..a12cf3d 100644
--- a/apps/api/src/config.ts
+++ b/apps/api/src/config.ts
@@ -11,10 +11,13 @@ export const config = {
jwksUri: (process.env.KEYCLOAK_JWKS_URI ?? '').trim(),
audience: process.env.KEYCLOAK_AUDIENCE?.trim() || 'fieldlens-api',
browserClientId: process.env.KEYCLOAK_BROWSER_CLIENT_ID?.trim() || 'fieldlens',
+ desktopClientId: process.env.KEYCLOAK_DESKTOP_CLIENT_ID?.trim() || 'fieldlens-print',
machineClientId: process.env.KEYCLOAK_MACHINE_CLIENT_ID?.trim() || 'fieldlens-integration',
tenantId: process.env.FIELDLENS_TENANT_ID?.trim() || 'fieldlens-production',
environment: process.env.FIELDLENS_ENV?.trim() || 'development',
logLevel: process.env.LOG_LEVEL?.trim() || 'info',
webOrigin: process.env.WEB_ORIGIN?.trim() || 'http://localhost:5173',
staticDir: process.env.STATIC_DIR?.trim() || '',
+ printReleaseTag: process.env.FIELDLENS_PRINT_RELEASE_TAG?.trim() || 'fieldlens-print-v0.1.0',
+ printReleaseBaseUrl: process.env.FIELDLENS_PRINT_RELEASE_BASE_URL?.trim() || 'https://git.arrowstack.dev/Admin/fieldlens/releases/download',
}
diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts
index c0cd5cf..8f02971 100644
--- a/apps/api/src/server.ts
+++ b/apps/api/src/server.ts
@@ -61,6 +61,16 @@ app.get('/api/health/live', (_req, res) => {
res.json({ status: 'live', version: process.env.FIELDLENS_VERSION ?? 'dev' })
})
+app.get('/downloads/fieldlens-print/windows-x64', (_req, res) => {
+ res.setHeader('Cache-Control', 'no-store')
+ res.redirect(302, `${config.printReleaseBaseUrl.replace(/\/$/, '')}/${encodeURIComponent(config.printReleaseTag)}/fieldlens-print-windows-x64-setup.exe`)
+})
+
+app.get('/downloads/fieldlens-print/linux-x64.deb', (_req, res) => {
+ res.setHeader('Cache-Control', 'no-store')
+ res.redirect(302, `${config.printReleaseBaseUrl.replace(/\/$/, '')}/${encodeURIComponent(config.printReleaseTag)}/fieldlens-print-linux-x64.deb`)
+})
+
app.get('/api/health/ready', async (_req, res) => {
try {
await databaseReady()
@@ -225,7 +235,8 @@ app.get('/api/v1/scans', requireAccess('supervisor'), async (req, res) => {
const limit = Math.max(1, Math.min(500, Number(req.query.limit ?? 100)))
const result = await pool.query(`
SELECT id, station_id AS "stationId", device_id AS "deviceId", raw_value AS "rawValue",
- format, duplicate, captured_at AS "capturedAt", received_at AS "receivedAt"
+ format, duplicate, source, source_metadata AS "sourceMetadata",
+ captured_at AS "capturedAt", received_at AS "receivedAt"
FROM scans WHERE tenant_id = $1
ORDER BY captured_at DESC LIMIT $2
`, [config.tenantId, limit])
diff --git a/apps/api/src/sync.ts b/apps/api/src/sync.ts
index f140e06..d393362 100644
--- a/apps/api/src/sync.ts
+++ b/apps/api/src/sync.ts
@@ -9,13 +9,15 @@ async function insertScan(client: PoolClient, event: OutboxEvent, context: Devic
await client.query(`
INSERT INTO scans (
id, tenant_id, site_id, station_id, device_id, operator_subject,
- raw_value, format, duplicate, captured_at, schema_version
- ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
+ raw_value, format, duplicate, captured_at, schema_version, source, source_metadata
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
ON CONFLICT (id) DO NOTHING
`, [
payload.id, context.tenantId, context.siteId, context.stationId, context.deviceId,
identity.subject, payload.rawValue, payload.format, Boolean(payload.duplicate),
payload.capturedAt, event.schemaVersion,
+ typeof payload.source === 'string' ? payload.source : 'camera',
+ payload.sourceMetadata ? JSON.stringify(payload.sourceMetadata) : null,
])
}
diff --git a/apps/print/index.html b/apps/print/index.html
new file mode 100644
index 0000000..37a6a91
--- /dev/null
+++ b/apps/print/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ FieldLens Print
+
+
+
+
+
+
diff --git a/apps/print/package.json b/apps/print/package.json
new file mode 100644
index 0000000..030aaad
--- /dev/null
+++ b/apps/print/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@fieldlens/print",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite --port 1420",
+ "build": "tsc --noEmit && vite build",
+ "tauri": "tauri",
+ "bundle:windows": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis",
+ "bundle:deb": "tauri build --bundles deb"
+ },
+ "dependencies": {
+ "@tauri-apps/api": "^2.0.0",
+ "@tauri-apps/plugin-opener": "^2.0.0"
+ },
+ "devDependencies": {
+ "@tauri-apps/cli": "^2.0.0",
+ "typescript": "~6.0.2",
+ "vite": "^8.2.0"
+ }
+}
diff --git a/apps/print/src-tauri/Cargo.toml b/apps/print/src-tauri/Cargo.toml
new file mode 100644
index 0000000..57428b7
--- /dev/null
+++ b/apps/print/src-tauri/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "fieldlens-print"
+version = "0.1.0"
+description = "FieldLens print-stream barcode capture companion"
+authors = ["Arrowstack"]
+edition = "2021"
+
+[lib]
+name = "fieldlens_print_lib"
+crate-type = ["staticlib", "cdylib", "rlib"]
+
+[build-dependencies]
+tauri-build = { version = "2", features = [] }
+
+[dependencies]
+anyhow = "1"
+chrono = { version = "0.4", features = ["serde", "clock"] }
+parking_lot = "0.12"
+regex = "1"
+reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+tauri = { version = "2", features = ["tray-icon"] }
+tauri-plugin-opener = "2"
+uuid = { version = "1", features = ["v4", "serde"] }
diff --git a/apps/print/src-tauri/build.rs b/apps/print/src-tauri/build.rs
new file mode 100644
index 0000000..d860e1e
--- /dev/null
+++ b/apps/print/src-tauri/build.rs
@@ -0,0 +1,3 @@
+fn main() {
+ tauri_build::build()
+}
diff --git a/apps/print/src-tauri/capabilities/default.json b/apps/print/src-tauri/capabilities/default.json
new file mode 100644
index 0000000..52241d5
--- /dev/null
+++ b/apps/print/src-tauri/capabilities/default.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "../gen/schemas/desktop-schema.json",
+ "identifier": "default",
+ "description": "FieldLens Print desktop permissions",
+ "windows": ["main"],
+ "permissions": [
+ "core:default",
+ "opener:default"
+ ]
+}
diff --git a/apps/print/src-tauri/src/capture.rs b/apps/print/src-tauri/src/capture.rs
new file mode 100644
index 0000000..3839d43
--- /dev/null
+++ b/apps/print/src-tauri/src/capture.rs
@@ -0,0 +1,344 @@
+use std::{
+ fs,
+ io::{ErrorKind, Read, Write},
+ net::{TcpListener, TcpStream},
+ path::PathBuf,
+ sync::{
+ atomic::{AtomicBool, Ordering},
+ Arc,
+ },
+ thread::{self, JoinHandle},
+ time::Duration,
+};
+
+use anyhow::{anyhow, Context, Result};
+use tauri::AppHandle;
+use uuid::Uuid;
+
+use crate::{
+ model::{AppConfig, CaptureMode, JobSummary},
+ state::SharedState,
+ zpl::extract_barcodes,
+};
+
+pub struct CaptureRuntime {
+ stop: Arc,
+ handles: Vec>,
+}
+
+impl CaptureRuntime {
+ pub fn stop(mut self) {
+ self.stop.store(true, Ordering::SeqCst);
+ for handle in self.handles.drain(..) {
+ let _ = handle.join();
+ }
+ }
+}
+
+pub fn start(config: AppConfig, state: SharedState, app: AppHandle) -> Result {
+ let stop = Arc::new(AtomicBool::new(false));
+ let mut handles = Vec::new();
+
+ if matches!(config.mode, CaptureMode::RawProxy | CaptureMode::Both) {
+ let listener = TcpListener::bind(&config.listen_addr)
+ .with_context(|| format!("bind RAW capture listener {}", config.listen_addr))?;
+ listener.set_nonblocking(true)?;
+
+ let worker_stop = stop.clone();
+ let worker_state = state.clone();
+ let worker_app = app.clone();
+ let worker_config = config.clone();
+ handles.push(thread::spawn(move || {
+ raw_proxy_loop(listener, worker_config, worker_state, worker_app, worker_stop)
+ }));
+ }
+
+ if matches!(config.mode, CaptureMode::SpoolFolder | CaptureMode::Both) {
+ let spool_dir = PathBuf::from(&config.spool_dir);
+ fs::create_dir_all(&spool_dir)
+ .with_context(|| format!("create spool capture directory {}", spool_dir.display()))?;
+
+ let worker_stop = stop.clone();
+ let worker_state = state.clone();
+ let worker_app = app.clone();
+ let worker_config = config.clone();
+ handles.push(thread::spawn(move || {
+ spool_folder_loop(worker_config, worker_state, worker_app, worker_stop)
+ }));
+ }
+
+ if handles.is_empty() {
+ return Err(anyhow!("no capture route enabled"));
+ }
+
+ Ok(CaptureRuntime { stop, handles })
+}
+
+fn raw_proxy_loop(
+ listener: TcpListener,
+ config: AppConfig,
+ state: SharedState,
+ app: AppHandle,
+ stop: Arc,
+) {
+ while !stop.load(Ordering::SeqCst) {
+ match listener.accept() {
+ Ok((stream, peer)) => {
+ let state = state.clone();
+ let app = app.clone();
+ let config = config.clone();
+ let label = peer.to_string();
+ thread::spawn(move || handle_raw_connection(stream, &config, &state, &app, label));
+ }
+ Err(error) if error.kind() == ErrorKind::WouldBlock => {
+ thread::sleep(Duration::from_millis(80));
+ }
+ Err(error) => {
+ let job = JobSummary {
+ id: Uuid::new_v4().to_string(),
+ source: "raw-proxy".into(),
+ label: "listener".into(),
+ bytes: 0,
+ barcodes: vec![],
+ captured_at: chrono::Utc::now().to_rfc3339(),
+ forwarded: false,
+ error: Some(format!("accept failed: {error}")),
+ };
+ state.push_job(&app, job);
+ thread::sleep(Duration::from_millis(500));
+ }
+ }
+ }
+}
+
+fn handle_raw_connection(
+ mut source: TcpStream,
+ config: &AppConfig,
+ state: &SharedState,
+ app: &AppHandle,
+ label: String,
+) {
+ let _ = source.set_read_timeout(Some(Duration::from_millis(750)));
+ let mut forward_error: Option = None;
+ let mut destination = if config.forward_addr.trim().is_empty() {
+ None
+ } else {
+ match config.forward_addr.parse() {
+ Ok(addr) => match TcpStream::connect_timeout(&addr, Duration::from_secs(3)) {
+ Ok(stream) => Some(stream),
+ Err(error) => {
+ forward_error = Some(format!("forward connect failed: {error}"));
+ None
+ }
+ },
+ Err(error) => {
+ forward_error = Some(format!("invalid forward address: {error}"));
+ None
+ }
+ }
+ };
+
+ let mut bytes = Vec::new();
+ let mut buffer = [0u8; 16 * 1024];
+
+ loop {
+ match source.read(&mut buffer) {
+ Ok(0) => break,
+ Ok(read) => {
+ let chunk = &buffer[..read];
+ bytes.extend_from_slice(chunk);
+ if let Some(target) = destination.as_mut() {
+ if let Err(error) = target.write_all(chunk) {
+ forward_error = Some(format!("forward write failed: {error}"));
+ destination = None;
+ }
+ }
+ }
+ Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {
+ if !bytes.is_empty() {
+ break;
+ }
+ }
+ Err(error) => {
+ forward_error = Some(format!("capture read failed: {error}"));
+ break;
+ }
+ }
+ }
+
+ if bytes.is_empty() && forward_error.is_none() {
+ return;
+ }
+
+ process_payload(
+ state,
+ app,
+ "raw-proxy",
+ label,
+ bytes,
+ !config.forward_addr.trim().is_empty() && forward_error.is_none(),
+ forward_error,
+ );
+}
+
+fn spool_folder_loop(
+ config: AppConfig,
+ state: SharedState,
+ app: AppHandle,
+ stop: Arc,
+) {
+ let spool_dir = PathBuf::from(&config.spool_dir);
+ let processed_dir = spool_dir.join("processed");
+ let _ = fs::create_dir_all(&processed_dir);
+ while !stop.load(Ordering::SeqCst) {
+ let entries = match fs::read_dir(&spool_dir) {
+ Ok(entries) => entries,
+ Err(error) => {
+ state.push_job(
+ &app,
+ JobSummary {
+ id: Uuid::new_v4().to_string(),
+ source: "spool-folder".into(),
+ label: spool_dir.display().to_string(),
+ bytes: 0,
+ barcodes: vec![],
+ captured_at: chrono::Utc::now().to_rfc3339(),
+ forwarded: false,
+ error: Some(format!("spool read failed: {error}")),
+ },
+ );
+ thread::sleep(Duration::from_secs(1));
+ continue;
+ }
+ };
+
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.is_dir() || !is_supported_drop(&path) {
+ continue;
+ }
+
+ let stable = entry
+ .metadata()
+ .ok()
+ .and_then(|metadata| metadata.modified().ok())
+ .and_then(|modified| modified.elapsed().ok())
+ .map(|age| age >= Duration::from_millis(500))
+ .unwrap_or(false);
+ if !stable {
+ continue;
+ }
+
+ let label = path
+ .file_name()
+ .and_then(|value| value.to_str())
+ .unwrap_or("spool-job")
+ .to_string();
+
+ match fs::read(&path) {
+ Ok(bytes) => {
+ let (forwarded, error) = forward_all(&config.forward_addr, &bytes);
+ process_payload(
+ &state,
+ &app,
+ "spool-folder",
+ label.clone(),
+ bytes,
+ forwarded,
+ error,
+ );
+
+ let target = unique_processed_path(&processed_dir, &label);
+ let _ = fs::rename(&path, target);
+ }
+ Err(error) => process_payload(
+ &state,
+ &app,
+ "spool-folder",
+ label,
+ vec![],
+ false,
+ Some(format!("spool file read failed: {error}")),
+ ),
+ }
+ }
+
+ thread::sleep(Duration::from_millis(350));
+ }
+}
+
+fn is_supported_drop(path: &std::path::Path) -> bool {
+ matches!(
+ path.extension()
+ .and_then(|value| value.to_str())
+ .map(|value| value.to_ascii_lowercase())
+ .as_deref(),
+ Some("zpl" | "raw" | "prn" | "spl")
+ )
+}
+
+fn unique_processed_path(dir: &std::path::Path, filename: &str) -> PathBuf {
+ let safe = filename.replace('/', "_").replace('\\', "_");
+ dir.join(format!(
+ "{}-{}-{}",
+ chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ"),
+ Uuid::new_v4(),
+ safe
+ ))
+}
+
+fn forward_all(address: &str, bytes: &[u8]) -> (bool, Option) {
+ if address.trim().is_empty() {
+ return (false, None);
+ }
+
+ let socket = match address.parse() {
+ Ok(value) => value,
+ Err(error) => return (false, Some(format!("invalid forward address: {error}"))),
+ };
+
+ match TcpStream::connect_timeout(&socket, Duration::from_secs(3)) {
+ Ok(mut stream) => match stream.write_all(bytes) {
+ Ok(()) => (true, None),
+ Err(error) => (false, Some(format!("forward write failed: {error}"))),
+ },
+ Err(error) => (false, Some(format!("forward connect failed: {error}"))),
+ }
+}
+
+fn process_payload(
+ state: &SharedState,
+ app: &AppHandle,
+ source: &str,
+ label: String,
+ bytes: Vec,
+ forwarded: bool,
+ error: Option,
+) {
+ let id = Uuid::new_v4().to_string();
+ let barcodes = extract_barcodes(&bytes);
+
+ for barcode in &barcodes {
+ state.queue_scan(
+ barcode.value.clone(),
+ barcode.format.clone(),
+ "print".into(),
+ &id,
+ &label,
+ );
+ }
+
+ state.push_job(
+ app,
+ JobSummary {
+ id,
+ source: source.into(),
+ label,
+ bytes: bytes.len(),
+ barcodes,
+ captured_at: chrono::Utc::now().to_rfc3339(),
+ forwarded,
+ error,
+ },
+ );
+}
diff --git a/apps/print/src-tauri/src/fieldlens.rs b/apps/print/src-tauri/src/fieldlens.rs
new file mode 100644
index 0000000..5f01fee
--- /dev/null
+++ b/apps/print/src-tauri/src/fieldlens.rs
@@ -0,0 +1,330 @@
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use anyhow::{anyhow, Context, Result};
+use reqwest::blocking::{Client, Response};
+use serde::Deserialize;
+use serde_json::{json, Value};
+
+use crate::{
+ model::{LoginPoll, LoginStart, ScanEvent, SyncResult},
+ state::SharedState,
+};
+
+#[derive(Debug, Deserialize)]
+struct DeviceCodeResponse {
+ device_code: String,
+ user_code: String,
+ verification_uri: String,
+ verification_uri_complete: Option,
+ expires_in: u64,
+ #[serde(default = "default_interval")]
+ interval: u64,
+}
+
+#[derive(Debug, Deserialize)]
+struct TokenResponse {
+ access_token: String,
+ #[serde(default)]
+ refresh_token: Option,
+ expires_in: i64,
+}
+
+#[derive(Debug, Deserialize)]
+struct OAuthError {
+ error: String,
+}
+
+fn default_interval() -> u64 {
+ 5
+}
+
+fn now_unix() -> i64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs() as i64
+}
+
+fn client() -> Result {
+ Client::builder()
+ .timeout(std::time::Duration::from_secs(15))
+ .build()
+ .context("create HTTP client")
+}
+
+pub fn begin_login(state: &SharedState) -> Result {
+ let config = state.config.lock().clone();
+ let endpoint = format!(
+ "{}/protocol/openid-connect/auth/device",
+ config.issuer.trim_end_matches('/')
+ );
+
+ let response = client()?
+ .post(endpoint)
+ .form(&[("client_id", config.client_id.as_str())])
+ .send()
+ .context("request Keycloak device code")?;
+
+ let response = ensure_success(response, "device authorization")?;
+ let body: DeviceCodeResponse = response.json().context("decode device authorization response")?;
+
+ {
+ let mut auth = state.auth.lock();
+ auth.device_code = Some(body.device_code);
+ auth.poll_interval = body.interval;
+ auth.access_token = None;
+ auth.refresh_token = None;
+ auth.expires_at_unix = None;
+ }
+
+ Ok(LoginStart {
+ user_code: body.user_code,
+ verification_uri: body.verification_uri,
+ verification_uri_complete: body.verification_uri_complete,
+ expires_in: body.expires_in,
+ interval: body.interval,
+ })
+}
+
+pub fn poll_login(state: &SharedState) -> Result {
+ let config = state.config.lock().clone();
+ let device_code = state
+ .auth
+ .lock()
+ .device_code
+ .clone()
+ .ok_or_else(|| anyhow!("no device login is active"))?;
+
+ let endpoint = format!(
+ "{}/protocol/openid-connect/token",
+ config.issuer.trim_end_matches('/')
+ );
+
+ let response = client()?
+ .post(endpoint)
+ .form(&[
+ ("client_id", config.client_id.as_str()),
+ (
+ "grant_type",
+ "urn:ietf:params:oauth:grant-type:device_code",
+ ),
+ ("device_code", device_code.as_str()),
+ ])
+ .send()
+ .context("poll Keycloak device login")?;
+
+ if response.status().is_success() {
+ let token: TokenResponse = response.json().context("decode token response")?;
+ let mut auth = state.auth.lock();
+ auth.access_token = Some(token.access_token);
+ auth.refresh_token = token.refresh_token;
+ auth.expires_at_unix = Some(now_unix() + token.expires_in);
+ auth.device_code = None;
+ return Ok(LoginPoll {
+ status: "authorized".into(),
+ });
+ }
+
+ let status = response.status();
+ let error = response
+ .json::()
+ .map(|body| body.error)
+ .unwrap_or_else(|_| format!("http_{}", status.as_u16()));
+
+ let mapped = match error.as_str() {
+ "authorization_pending" | "slow_down" => "pending",
+ "expired_token" => "expired",
+ "access_denied" => "denied",
+ _ => return Err(anyhow!("device login failed: {error}")),
+ };
+
+ Ok(LoginPoll {
+ status: mapped.into(),
+ })
+}
+
+fn ensure_access_token(state: &SharedState) -> Result {
+ {
+ let auth = state.auth.lock();
+ if auth.expires_at_unix.unwrap_or_default() > now_unix() + 30 {
+ if let Some(token) = &auth.access_token {
+ return Ok(token.clone());
+ }
+ }
+ }
+
+ refresh_access_token(state)
+}
+
+fn refresh_access_token(state: &SharedState) -> Result {
+ let config = state.config.lock().clone();
+ let refresh_token = state
+ .auth
+ .lock()
+ .refresh_token
+ .clone()
+ .ok_or_else(|| anyhow!("not signed in to FieldLens"))?;
+
+ let endpoint = format!(
+ "{}/protocol/openid-connect/token",
+ config.issuer.trim_end_matches('/')
+ );
+ let response = client()?
+ .post(endpoint)
+ .form(&[
+ ("client_id", config.client_id.as_str()),
+ ("grant_type", "refresh_token"),
+ ("refresh_token", refresh_token.as_str()),
+ ])
+ .send()
+ .context("refresh FieldLens token")?;
+
+ let response = ensure_success(response, "token refresh")?;
+ let token: TokenResponse = response.json().context("decode refresh response")?;
+
+ let access_token = token.access_token.clone();
+ let mut auth = state.auth.lock();
+ auth.access_token = Some(token.access_token);
+ if token.refresh_token.is_some() {
+ auth.refresh_token = token.refresh_token;
+ }
+ auth.expires_at_unix = Some(now_unix() + token.expires_in);
+ Ok(access_token)
+}
+
+pub fn sync_pending(state: &SharedState) -> Result {
+ let pending = state.pending.lock().clone();
+ if pending.is_empty() {
+ return Ok(SyncResult {
+ synced: 0,
+ pending: 0,
+ });
+ }
+
+ let access_token = ensure_access_token(state)?;
+ let client = client()?;
+ let device_id = ensure_device(&client, state, &access_token)?;
+
+ let batch: Vec = pending.into_iter().take(100).collect();
+ let events: Vec = batch
+ .iter()
+ .map(|scan| {
+ json!({
+ "id": scan.id,
+ "aggregateId": scan.id,
+ "eventType": "scan.captured",
+ "schemaVersion": 1,
+ "createdAt": scan.captured_at,
+ "payload": {
+ "id": scan.id,
+ "rawValue": scan.raw_value,
+ "format": scan.format,
+ "duplicate": scan.duplicate,
+ "capturedAt": scan.captured_at,
+ "source": scan.source,
+ "sourceMetadata": scan.source_metadata,
+ "schemaVersion": 1
+ }
+ })
+ })
+ .collect();
+
+ let config = state.config.lock().clone();
+ let response = client
+ .post(format!(
+ "{}/api/v1/sync",
+ config.api_base_url.trim_end_matches('/')
+ ))
+ .bearer_auth(&access_token)
+ .json(&json!({
+ "deviceId": device_id,
+ "schemaVersion": 1,
+ "events": events
+ }))
+ .send()
+ .context("sync print scans")?;
+
+ let response = ensure_success(response, "FieldLens sync")?;
+ let body: Value = response.json().context("decode FieldLens sync response")?;
+ let accepted: std::collections::HashSet = body
+ .get("acknowledgements")
+ .and_then(Value::as_array)
+ .into_iter()
+ .flatten()
+ .filter_map(|ack| {
+ let status = ack.get("status")?.as_str()?;
+ if status != "accepted" && status != "duplicate" {
+ return None;
+ }
+ ack.get("eventId")?.as_str().map(str::to_owned)
+ })
+ .collect();
+
+ let synced = accepted.len();
+ {
+ let mut queue = state.pending.lock();
+ queue.retain(|scan| !accepted.contains(&scan.id));
+ }
+ state.persist_pending()?;
+
+ Ok(SyncResult {
+ synced,
+ pending: state.pending_count(),
+ })
+}
+
+fn ensure_device(client: &Client, state: &SharedState, access_token: &str) -> Result {
+ let config = state.config.lock().clone();
+ if let Some(device_id) = config.device_id.filter(|id| !id.trim().is_empty()) {
+ return Ok(device_id);
+ }
+
+ let mut body = json!({
+ "installationId": config.installation_id,
+ "label": "FieldLens Print",
+ "appVersion": env!("CARGO_PKG_VERSION"),
+ "schemaVersion": 1
+ });
+ if !config.station_id.trim().is_empty() {
+ body["stationId"] = Value::String(config.station_id.clone());
+ }
+
+ let response = client
+ .post(format!(
+ "{}/api/v1/devices/register",
+ config.api_base_url.trim_end_matches('/')
+ ))
+ .bearer_auth(access_token)
+ .json(&body)
+ .send()
+ .context("register FieldLens Print device")?;
+
+ let response = ensure_success(response, "FieldLens device registration")?;
+ let body: Value = response.json().context("decode device registration")?;
+ let device_id = body
+ .get("id")
+ .and_then(Value::as_str)
+ .ok_or_else(|| anyhow!("FieldLens device registration returned no id"))?
+ .to_string();
+
+ state.config.lock().device_id = Some(device_id.clone());
+ state.persist_config()?;
+ Ok(device_id)
+}
+
+fn ensure_success(response: Response, operation: &str) -> Result {
+ if response.status().is_success() {
+ return Ok(response);
+ }
+ let status = response.status();
+ let detail = response.text().unwrap_or_default();
+ Err(anyhow!(
+ "{operation} failed with {}{}",
+ status.as_u16(),
+ if detail.is_empty() {
+ String::new()
+ } else {
+ format!(": {detail}")
+ }
+ ))
+}
diff --git a/apps/print/src-tauri/src/lib.rs b/apps/print/src-tauri/src/lib.rs
new file mode 100644
index 0000000..f2e9f94
--- /dev/null
+++ b/apps/print/src-tauri/src/lib.rs
@@ -0,0 +1,108 @@
+mod capture;
+mod fieldlens;
+mod model;
+mod state;
+mod zpl;
+
+use parking_lot::Mutex;
+use tauri::{AppHandle, Manager, State};
+
+use capture::CaptureRuntime;
+use model::{AppConfig, CaptureStatus, JobSummary, LoginPoll, LoginStart, SyncResult};
+use state::SharedState;
+
+struct AppState {
+ shared: SharedState,
+ runtime: Mutex