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
|
||||||
**/node_modules
|
**/node_modules
|
||||||
**/dist
|
**/dist
|
||||||
|
apps/print
|
||||||
.git
|
.git
|
||||||
.env
|
.env
|
||||||
.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_JWKS_URI=https://auth.example.invalid/realms/example/protocol/openid-connect/certs
|
||||||
KEYCLOAK_AUDIENCE=fieldlens-api
|
KEYCLOAK_AUDIENCE=fieldlens-api
|
||||||
KEYCLOAK_BROWSER_CLIENT_ID=fieldlens
|
KEYCLOAK_BROWSER_CLIENT_ID=fieldlens
|
||||||
|
KEYCLOAK_DESKTOP_CLIENT_ID=fieldlens-print
|
||||||
KEYCLOAK_MACHINE_CLIENT_ID=fieldlens-integration
|
KEYCLOAK_MACHINE_CLIENT_ID=fieldlens-integration
|
||||||
FIELDLENS_TENANT_ID=fieldlens-production
|
FIELDLENS_TENANT_ID=fieldlens-production
|
||||||
WEB_ORIGIN=https://fieldlens.example.invalid
|
WEB_ORIGIN=https://fieldlens.example.invalid
|
||||||
|
FIELDLENS_PRINT_RELEASE_TAG=fieldlens-print-v0.1.0
|
||||||
|
FIELDLENS_PRINT_RELEASE_BASE_URL=https://git.example.invalid/owner/fieldlens/releases/download
|
||||||
|
|||||||
@ -18,4 +18,21 @@ jobs:
|
|||||||
- run: npm run lint
|
- run: npm run lint
|
||||||
- run: npm run migration:validate
|
- run: npm run migration:validate
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
- run: npm run build:print
|
||||||
- run: docker build -t fieldlens-ci .
|
- run: docker build -t fieldlens-ci .
|
||||||
|
|
||||||
|
print-rust:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install Tauri Linux prerequisites
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- name: Test FieldLens Print native core
|
||||||
|
working-directory: apps/print/src-tauri
|
||||||
|
run: cargo test
|
||||||
|
- name: Check FieldLens Print native core
|
||||||
|
working-directory: apps/print/src-tauri
|
||||||
|
run: cargo check
|
||||||
|
|||||||
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.
|
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_JWKS_URI=https://auth.example.invalid/realms/example/protocol/openid-connect/certs
|
||||||
KEYCLOAK_AUDIENCE=fieldlens-api
|
KEYCLOAK_AUDIENCE=fieldlens-api
|
||||||
KEYCLOAK_BROWSER_CLIENT_ID=fieldlens
|
KEYCLOAK_BROWSER_CLIENT_ID=fieldlens
|
||||||
|
KEYCLOAK_DESKTOP_CLIENT_ID=fieldlens-print
|
||||||
KEYCLOAK_MACHINE_CLIENT_ID=fieldlens-integration
|
KEYCLOAK_MACHINE_CLIENT_ID=fieldlens-integration
|
||||||
FIELDLENS_TENANT_ID=fieldlens-production
|
FIELDLENS_TENANT_ID=fieldlens-production
|
||||||
FIELDLENS_ENV=development
|
FIELDLENS_ENV=development
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
WEB_ORIGIN=http://localhost:5173
|
WEB_ORIGIN=http://localhost:5173
|
||||||
STATIC_DIR=
|
STATIC_DIR=
|
||||||
|
FIELDLENS_PRINT_RELEASE_TAG=fieldlens-print-v0.1.0
|
||||||
|
FIELDLENS_PRINT_RELEASE_BASE_URL=https://git.example.invalid/owner/fieldlens/releases/download
|
||||||
|
|||||||
@ -33,6 +33,7 @@ export function rolesFromPayload(payload: JWTPayload) {
|
|||||||
return [...new Set([
|
return [...new Set([
|
||||||
...strings(resourceAccess?.[config.audience]?.roles),
|
...strings(resourceAccess?.[config.audience]?.roles),
|
||||||
...strings(resourceAccess?.[config.browserClientId]?.roles),
|
...strings(resourceAccess?.[config.browserClientId]?.roles),
|
||||||
|
...strings(resourceAccess?.[config.desktopClientId]?.roles),
|
||||||
...strings(resourceAccess?.[config.machineClientId]?.roles),
|
...strings(resourceAccess?.[config.machineClientId]?.roles),
|
||||||
])]
|
])]
|
||||||
}
|
}
|
||||||
@ -47,6 +48,7 @@ function intendedForFieldLens(payload: JWTPayload) {
|
|||||||
const azp = typeof payload.azp === 'string' ? payload.azp : ''
|
const azp = typeof payload.azp === 'string' ? payload.azp : ''
|
||||||
return audience.includes(config.audience)
|
return audience.includes(config.audience)
|
||||||
|| azp === config.browserClientId
|
|| azp === config.browserClientId
|
||||||
|
|| azp === config.desktopClientId
|
||||||
|| azp === config.machineClientId
|
|| azp === config.machineClientId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,10 +11,13 @@ export const config = {
|
|||||||
jwksUri: (process.env.KEYCLOAK_JWKS_URI ?? '').trim(),
|
jwksUri: (process.env.KEYCLOAK_JWKS_URI ?? '').trim(),
|
||||||
audience: process.env.KEYCLOAK_AUDIENCE?.trim() || 'fieldlens-api',
|
audience: process.env.KEYCLOAK_AUDIENCE?.trim() || 'fieldlens-api',
|
||||||
browserClientId: process.env.KEYCLOAK_BROWSER_CLIENT_ID?.trim() || 'fieldlens',
|
browserClientId: process.env.KEYCLOAK_BROWSER_CLIENT_ID?.trim() || 'fieldlens',
|
||||||
|
desktopClientId: process.env.KEYCLOAK_DESKTOP_CLIENT_ID?.trim() || 'fieldlens-print',
|
||||||
machineClientId: process.env.KEYCLOAK_MACHINE_CLIENT_ID?.trim() || 'fieldlens-integration',
|
machineClientId: process.env.KEYCLOAK_MACHINE_CLIENT_ID?.trim() || 'fieldlens-integration',
|
||||||
tenantId: process.env.FIELDLENS_TENANT_ID?.trim() || 'fieldlens-production',
|
tenantId: process.env.FIELDLENS_TENANT_ID?.trim() || 'fieldlens-production',
|
||||||
environment: process.env.FIELDLENS_ENV?.trim() || 'development',
|
environment: process.env.FIELDLENS_ENV?.trim() || 'development',
|
||||||
logLevel: process.env.LOG_LEVEL?.trim() || 'info',
|
logLevel: process.env.LOG_LEVEL?.trim() || 'info',
|
||||||
webOrigin: process.env.WEB_ORIGIN?.trim() || 'http://localhost:5173',
|
webOrigin: process.env.WEB_ORIGIN?.trim() || 'http://localhost:5173',
|
||||||
staticDir: process.env.STATIC_DIR?.trim() || '',
|
staticDir: process.env.STATIC_DIR?.trim() || '',
|
||||||
|
printReleaseTag: process.env.FIELDLENS_PRINT_RELEASE_TAG?.trim() || 'fieldlens-print-v0.1.0',
|
||||||
|
printReleaseBaseUrl: process.env.FIELDLENS_PRINT_RELEASE_BASE_URL?.trim() || 'https://git.arrowstack.dev/Admin/fieldlens/releases/download',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,6 +61,16 @@ app.get('/api/health/live', (_req, res) => {
|
|||||||
res.json({ status: 'live', version: process.env.FIELDLENS_VERSION ?? 'dev' })
|
res.json({ status: 'live', version: process.env.FIELDLENS_VERSION ?? 'dev' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get('/downloads/fieldlens-print/windows-x64', (_req, res) => {
|
||||||
|
res.setHeader('Cache-Control', 'no-store')
|
||||||
|
res.redirect(302, `${config.printReleaseBaseUrl.replace(/\/$/, '')}/${encodeURIComponent(config.printReleaseTag)}/fieldlens-print-windows-x64-setup.exe`)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/downloads/fieldlens-print/linux-x64.deb', (_req, res) => {
|
||||||
|
res.setHeader('Cache-Control', 'no-store')
|
||||||
|
res.redirect(302, `${config.printReleaseBaseUrl.replace(/\/$/, '')}/${encodeURIComponent(config.printReleaseTag)}/fieldlens-print-linux-x64.deb`)
|
||||||
|
})
|
||||||
|
|
||||||
app.get('/api/health/ready', async (_req, res) => {
|
app.get('/api/health/ready', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
await databaseReady()
|
await databaseReady()
|
||||||
@ -225,7 +235,8 @@ app.get('/api/v1/scans', requireAccess('supervisor'), async (req, res) => {
|
|||||||
const limit = Math.max(1, Math.min(500, Number(req.query.limit ?? 100)))
|
const limit = Math.max(1, Math.min(500, Number(req.query.limit ?? 100)))
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
SELECT id, station_id AS "stationId", device_id AS "deviceId", raw_value AS "rawValue",
|
SELECT id, station_id AS "stationId", device_id AS "deviceId", raw_value AS "rawValue",
|
||||||
format, duplicate, captured_at AS "capturedAt", received_at AS "receivedAt"
|
format, duplicate, source, source_metadata AS "sourceMetadata",
|
||||||
|
captured_at AS "capturedAt", received_at AS "receivedAt"
|
||||||
FROM scans WHERE tenant_id = $1
|
FROM scans WHERE tenant_id = $1
|
||||||
ORDER BY captured_at DESC LIMIT $2
|
ORDER BY captured_at DESC LIMIT $2
|
||||||
`, [config.tenantId, limit])
|
`, [config.tenantId, limit])
|
||||||
|
|||||||
@ -9,13 +9,15 @@ async function insertScan(client: PoolClient, event: OutboxEvent, context: Devic
|
|||||||
await client.query(`
|
await client.query(`
|
||||||
INSERT INTO scans (
|
INSERT INTO scans (
|
||||||
id, tenant_id, site_id, station_id, device_id, operator_subject,
|
id, tenant_id, site_id, station_id, device_id, operator_subject,
|
||||||
raw_value, format, duplicate, captured_at, schema_version
|
raw_value, format, duplicate, captured_at, schema_version, source, source_metadata
|
||||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||||
ON CONFLICT (id) DO NOTHING
|
ON CONFLICT (id) DO NOTHING
|
||||||
`, [
|
`, [
|
||||||
payload.id, context.tenantId, context.siteId, context.stationId, context.deviceId,
|
payload.id, context.tenantId, context.siteId, context.stationId, context.deviceId,
|
||||||
identity.subject, payload.rawValue, payload.format, Boolean(payload.duplicate),
|
identity.subject, payload.rawValue, payload.format, Boolean(payload.duplicate),
|
||||||
payload.capturedAt, event.schemaVersion,
|
payload.capturedAt, event.schemaVersion,
|
||||||
|
typeof payload.source === 'string' ? payload.source : 'camera',
|
||||||
|
payload.sourceMetadata ? JSON.stringify(payload.sourceMetadata) : null,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
13
apps/print/index.html
Normal file
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-settings { grid-column: 1 / -1; }
|
||||||
.account-actions {
|
.account-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -93,15 +49,189 @@ body:has(.workspace-view) .host-status {
|
|||||||
.empty-requests { margin: 0; padding: 16px 18px; color: #75807b; font-size: 10px; }
|
.empty-requests { margin: 0; padding: 16px 18px; color: #75807b; font-size: 10px; }
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.host-status {
|
|
||||||
top: calc(86px + env(safe-area-inset-top));
|
|
||||||
right: max(12px, env(safe-area-inset-right));
|
|
||||||
width: min(188px, calc(100vw - 24px));
|
|
||||||
}
|
|
||||||
.host-status-indicator { padding: 7px 10px; }
|
|
||||||
.account-actions { display: grid; grid-template-columns: 1fr 1fr; }
|
.account-actions { display: grid; grid-template-columns: 1fr 1fr; }
|
||||||
.account-actions .primary-button,
|
.account-actions .primary-button,
|
||||||
.account-actions .quiet-button { width: 100%; min-width: 0; }
|
.account-actions .quiet-button { width: 100%; min-width: 0; }
|
||||||
.access-requests article { grid-template-columns: 1fr; }
|
.access-requests article { grid-template-columns: 1fr; }
|
||||||
.access-requests article > div { display: grid; grid-template-columns: 1fr 1fr; }
|
.access-requests article > div { display: grid; grid-template-columns: 1fr 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.desktop-downloads-link { grid-column: 1 / -1; }
|
||||||
|
.desktop-download-settings-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin: 16px 18px 18px;
|
||||||
|
padding: 14px 15px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: inherit;
|
||||||
|
background: #fafaf6;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color .15s ease, transform .15s ease, background .15s ease;
|
||||||
|
}
|
||||||
|
.desktop-download-settings-link:hover {
|
||||||
|
border-color: rgba(31, 111, 78, .4);
|
||||||
|
background: #f4f8f4;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.desktop-download-settings-link strong,
|
||||||
|
.desktop-download-settings-link small { display: block; }
|
||||||
|
.desktop-download-settings-link strong { font-size: 11px; }
|
||||||
|
.desktop-download-settings-link small { margin-top: 3px; color: #75807b; font-size: 9px; }
|
||||||
|
|
||||||
|
.downloads-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: max(28px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) max(34px, env(safe-area-inset-bottom)) max(18px, env(safe-area-inset-left));
|
||||||
|
color: #17211f;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 12% 0%, rgba(241, 205, 75, .22), transparent 30rem),
|
||||||
|
#f5f5ef;
|
||||||
|
}
|
||||||
|
.downloads-shell {
|
||||||
|
width: min(860px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.downloads-back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
color: #52605b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.downloads-back:hover { color: var(--green); }
|
||||||
|
.downloads-hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 58px minmax(0, 1fr);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: start;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.downloads-mark {
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 16px;
|
||||||
|
color: #17211f;
|
||||||
|
background: #f4d35e;
|
||||||
|
box-shadow: inset 0 -2px 0 rgba(0,0,0,.08);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 950;
|
||||||
|
letter-spacing: -.04em;
|
||||||
|
}
|
||||||
|
.downloads-kicker {
|
||||||
|
margin: 1px 0 6px;
|
||||||
|
color: #69736f;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: .14em;
|
||||||
|
}
|
||||||
|
.downloads-hero h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: clamp(27px, 6vw, 44px);
|
||||||
|
line-height: 1.03;
|
||||||
|
letter-spacing: -.055em;
|
||||||
|
}
|
||||||
|
.downloads-hero > div > p:last-child {
|
||||||
|
max-width: 650px;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: #626c68;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.downloads-panel {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #d9ddd7;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255,255,252,.94);
|
||||||
|
box-shadow: 0 16px 50px rgba(42,52,48,.08);
|
||||||
|
}
|
||||||
|
.downloads-panel-heading {
|
||||||
|
padding: 21px 22px 17px;
|
||||||
|
border-bottom: 1px solid #e4e7e1;
|
||||||
|
}
|
||||||
|
.downloads-panel-heading h2 { margin: 0; font-size: 15px; }
|
||||||
|
.downloads-panel-heading p {
|
||||||
|
max-width: 650px;
|
||||||
|
margin: 5px 0 0;
|
||||||
|
color: #75807b;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.desktop-download-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
.desktop-download-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 42px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 76px;
|
||||||
|
padding: 13px 14px;
|
||||||
|
border: 1px solid #dfe3dc;
|
||||||
|
border-radius: 13px;
|
||||||
|
color: inherit;
|
||||||
|
background: #fafaf6;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color .15s ease, transform .15s ease, background .15s ease;
|
||||||
|
}
|
||||||
|
.desktop-download-card:hover {
|
||||||
|
border-color: rgba(31, 111, 78, .4);
|
||||||
|
background: #f4f8f4;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.desktop-download-icon {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 11px;
|
||||||
|
color: var(--green);
|
||||||
|
background: rgba(31, 111, 78, .09);
|
||||||
|
}
|
||||||
|
.desktop-download-copy { min-width: 0; }
|
||||||
|
.desktop-download-copy strong,
|
||||||
|
.desktop-download-copy small { display: block; }
|
||||||
|
.desktop-download-copy strong { font-size: 12px; }
|
||||||
|
.desktop-download-copy small { margin-top: 3px; color: #75807b; font-size: 9px; line-height: 1.35; }
|
||||||
|
.desktop-download-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--green);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.downloads-notes {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 14px 20px 18px;
|
||||||
|
border-top: 1px solid #e4e7e1;
|
||||||
|
color: #75807b;
|
||||||
|
background: #fbfbf7;
|
||||||
|
}
|
||||||
|
.downloads-notes p { margin: 0; font-size: 9px; line-height: 1.5; }
|
||||||
|
.downloads-notes strong { color: #46514d; }
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.desktop-download-settings-link { align-items: flex-start; }
|
||||||
|
.downloads-page { padding-top: max(22px, env(safe-area-inset-top)); }
|
||||||
|
.downloads-back { margin-bottom: 24px; }
|
||||||
|
.downloads-hero { grid-template-columns: 48px minmax(0, 1fr); gap: 14px; }
|
||||||
|
.downloads-mark { width: 48px; height: 48px; border-radius: 14px; font-size: 16px; }
|
||||||
|
.desktop-download-grid { grid-template-columns: 1fr; padding: 14px; }
|
||||||
|
.desktop-download-card { grid-template-columns: 40px minmax(0, 1fr) auto; }
|
||||||
|
.downloads-panel-heading { padding: 18px 17px 15px; }
|
||||||
|
.downloads-notes { padding: 13px 16px 16px; }
|
||||||
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import './host.css'
|
|||||||
import CoreApp from '../../../packages/fieldlens-core/src/App'
|
import CoreApp from '../../../packages/fieldlens-core/src/App'
|
||||||
import { startDeviceHoldTracking } from '../../../packages/fieldlens-core/src/lib/deviceOrientation'
|
import { startDeviceHoldTracking } from '../../../packages/fieldlens-core/src/lib/deviceOrientation'
|
||||||
import { AccountSettings } from './AccountSettings'
|
import { AccountSettings } from './AccountSettings'
|
||||||
import { HostStatus } from './HostStatus'
|
import { DesktopDownloadsLink, DesktopDownloadsPage } from './DesktopDownloads'
|
||||||
import { registerArrowstackReferenceProvider } from './referenceProvider'
|
import { registerArrowstackReferenceProvider } from './referenceProvider'
|
||||||
import { installSyncTriggers } from './sync'
|
import { installSyncTriggers } from './sync'
|
||||||
|
|
||||||
@ -19,9 +19,15 @@ startDeviceHoldTracking()
|
|||||||
registerArrowstackReferenceProvider()
|
registerArrowstackReferenceProvider()
|
||||||
installSyncTriggers()
|
installSyncTriggers()
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
const isDownloadsPage = window.location.pathname === '/downloads'
|
||||||
|
const root = createRoot(document.getElementById('root')!)
|
||||||
|
|
||||||
|
root.render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<CoreApp settingsAddon={<AccountSettings />} />
|
{isDownloadsPage ? (
|
||||||
<HostStatus />
|
<DesktopDownloadsPage />
|
||||||
|
) : (
|
||||||
|
<CoreApp settingsAddon={<><AccountSettings /><DesktopDownloadsLink /></>} />
|
||||||
|
)}
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
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",
|
"lint": "npm run lint --workspace @fieldlens/core && npm run lint --workspace @fieldlens/web && npm run lint --workspace @fieldlens/api",
|
||||||
"build": "npm run build --workspace @fieldlens/contracts && npm run build --workspace @fieldlens/core && npm run build --workspace @fieldlens/web && npm run build --workspace @fieldlens/api",
|
"build": "npm run build --workspace @fieldlens/contracts && npm run build --workspace @fieldlens/core && npm run build --workspace @fieldlens/web && npm run build --workspace @fieldlens/api",
|
||||||
"migration:validate": "node scripts/validate-migrations.mjs",
|
"migration:validate": "node scripts/validate-migrations.mjs",
|
||||||
"verify": "npm run test && npm run lint && npm run migration:validate && npm run build"
|
"verify": "npm run test && npm run lint && npm run migration:validate && npm run build",
|
||||||
|
"build:print": "npm run build --workspace @fieldlens/print"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24"
|
"node": ">=24"
|
||||||
|
|||||||
@ -9,6 +9,8 @@ export type ScanRecord = {
|
|||||||
duplicate: boolean
|
duplicate: boolean
|
||||||
capturedAt: string
|
capturedAt: string
|
||||||
schemaVersion: SchemaVersion
|
schemaVersion: SchemaVersion
|
||||||
|
source?: 'camera' | 'print' | string
|
||||||
|
sourceMetadata?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PalletSession = {
|
export type PalletSession = {
|
||||||
|
|||||||
@ -2,6 +2,12 @@ import { BrowserMultiFormatReader, type IScannerControls } from '@zxing/browser'
|
|||||||
import { CheckCircle2, RefreshCw, ScanLine, XCircle } from 'lucide-react'
|
import { CheckCircle2, RefreshCw, ScanLine, XCircle } from 'lucide-react'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
acquireCameraStream,
|
||||||
|
cameraPermissionHelp,
|
||||||
|
releaseCameraStream,
|
||||||
|
stopCameraStream,
|
||||||
|
} from '../../lib/cameraSession'
|
||||||
import {
|
import {
|
||||||
finishEanPalletSession,
|
finishEanPalletSession,
|
||||||
saveEanPalletScans,
|
saveEanPalletScans,
|
||||||
@ -15,6 +21,7 @@ type Step = 'setup' | 'scan' | 'result'
|
|||||||
export function EanPalletView() {
|
export function EanPalletView() {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
const controlsRef = useRef<IScannerControls | null>(null)
|
const controlsRef = useRef<IScannerControls | null>(null)
|
||||||
|
const streamRef = useRef<MediaStream | null>(null)
|
||||||
const armedRef = useRef(false)
|
const armedRef = useRef(false)
|
||||||
const sessionIdRef = useRef('')
|
const sessionIdRef = useRef('')
|
||||||
const scansRef = useRef<string[]>([])
|
const scansRef = useRef<string[]>([])
|
||||||
@ -23,6 +30,7 @@ export function EanPalletView() {
|
|||||||
const [scans, setScans] = useState<string[]>([])
|
const [scans, setScans] = useState<string[]>([])
|
||||||
const [armed, setArmed] = useState(false)
|
const [armed, setArmed] = useState(false)
|
||||||
const [cameraError, setCameraError] = useState('')
|
const [cameraError, setCameraError] = useState('')
|
||||||
|
const [cameraRetryKey, setCameraRetryKey] = useState(0)
|
||||||
const [message, setMessage] = useState('Ready')
|
const [message, setMessage] = useState('Ready')
|
||||||
const summary = useMemo(() => summarizeEanScans(scans), [scans])
|
const summary = useMemo(() => summarizeEanScans(scans), [scans])
|
||||||
|
|
||||||
@ -32,10 +40,22 @@ export function EanPalletView() {
|
|||||||
if (!video) return
|
if (!video) return
|
||||||
|
|
||||||
let disposed = false
|
let disposed = false
|
||||||
|
let activeStream: MediaStream | null = null
|
||||||
setCameraError('')
|
setCameraError('')
|
||||||
const reader = new BrowserMultiFormatReader()
|
const reader = new BrowserMultiFormatReader()
|
||||||
void reader.decodeFromConstraints(
|
|
||||||
{ audio: false, video: { facingMode: { ideal: 'environment' } } },
|
void acquireCameraStream()
|
||||||
|
.then(async (stream) => {
|
||||||
|
if (disposed) {
|
||||||
|
releaseCameraStream()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
activeStream = stream
|
||||||
|
streamRef.current = stream
|
||||||
|
video.srcObject = stream
|
||||||
|
|
||||||
|
return reader.decodeFromVideoElement(
|
||||||
video,
|
video,
|
||||||
(result) => {
|
(result) => {
|
||||||
if (!result || !armedRef.current) return
|
if (!result || !armedRef.current) return
|
||||||
@ -71,19 +91,54 @@ export function EanPalletView() {
|
|||||||
setStep('result')
|
setStep('result')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
).then((controls) => {
|
)
|
||||||
if (disposed) controls.stop()
|
})
|
||||||
else controlsRef.current = controls
|
.then((controls) => {
|
||||||
}).catch((error) => {
|
if (!controls) return
|
||||||
if (!disposed) setCameraError(error instanceof Error ? error.message : 'Camera could not be started.')
|
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 () => {
|
return () => {
|
||||||
disposed = true
|
disposed = true
|
||||||
controlsRef.current?.stop()
|
controlsRef.current?.stop()
|
||||||
controlsRef.current = null
|
controlsRef.current = null
|
||||||
|
if (video.srcObject === activeStream) video.srcObject = null
|
||||||
|
if (streamRef.current === activeStream) streamRef.current = null
|
||||||
|
releaseCameraStream()
|
||||||
}
|
}
|
||||||
}, [step, targetCount])
|
}, [cameraRetryKey, step, targetCount])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step !== 'scan') return
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
controlsRef.current?.stop()
|
||||||
|
controlsRef.current = null
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.pause()
|
||||||
|
videoRef.current.srcObject = null
|
||||||
|
}
|
||||||
|
streamRef.current = null
|
||||||
|
stopCameraStream()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCameraRetryKey((value) => value + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
}, [step])
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
const safeCount = Math.max(1, Math.min(500, Math.round(targetCount || 1)))
|
const safeCount = Math.max(1, Math.min(500, Math.round(targetCount || 1)))
|
||||||
@ -154,7 +209,12 @@ export function EanPalletView() {
|
|||||||
<strong>{scans.length} / {targetCount}</strong>
|
<strong>{scans.length} / {targetCount}</strong>
|
||||||
<span>{message}</span>
|
<span>{message}</span>
|
||||||
<div className="ean-progress-track"><i style={{ width: `${Math.min(100, scans.length / targetCount * 100)}%` }} /></div>
|
<div className="ean-progress-track"><i style={{ width: `${Math.min(100, scans.length / targetCount * 100)}%` }} /></div>
|
||||||
{cameraError && <p className="ean-error">{cameraError}</p>}
|
{cameraError && (
|
||||||
|
<div className="ean-error">
|
||||||
|
<span>{cameraError}</span>
|
||||||
|
<button type="button" className="ean-primary" onClick={() => setCameraRetryKey((value) => value + 1)}>Retry camera</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!armed && scans.length < targetCount && (
|
{!armed && scans.length < targetCount && (
|
||||||
<button type="button" className="ean-primary" onClick={armNext}>
|
<button type="button" className="ean-primary" onClick={armNext}>
|
||||||
<ScanLine size={17} /> Scan next carton
|
<ScanLine size={17} /> Scan next carton
|
||||||
|
|||||||
@ -11,6 +11,13 @@ import {
|
|||||||
type CoverCoordinateSpace,
|
type CoverCoordinateSpace,
|
||||||
type Homography,
|
type Homography,
|
||||||
} from '../../lib/cartonArTracking'
|
} from '../../lib/cartonArTracking'
|
||||||
|
import {
|
||||||
|
acquireCameraStream,
|
||||||
|
cameraPermissionHelp,
|
||||||
|
getCameraTrack,
|
||||||
|
releaseCameraStream,
|
||||||
|
stopCameraStream,
|
||||||
|
} from '../../lib/cameraSession'
|
||||||
import {
|
import {
|
||||||
finishPalletLabelSession,
|
finishPalletLabelSession,
|
||||||
savePalletLabelScans,
|
savePalletLabelScans,
|
||||||
@ -496,29 +503,31 @@ export function LabelCheckView({ onOpenSettings }: { onOpenSettings: () => void
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (step !== 'scan') return
|
if (step !== 'scan') return
|
||||||
let disposed = false
|
let disposed = false
|
||||||
|
let activeStream: MediaStream | null = null
|
||||||
|
|
||||||
const stopCamera = () => {
|
const detachCamera = () => {
|
||||||
streamRef.current?.getTracks().forEach((track) => track.stop())
|
if (videoRef.current?.srcObject === activeStream) videoRef.current.srcObject = null
|
||||||
streamRef.current = null
|
if (streamRef.current === activeStream) streamRef.current = null
|
||||||
if (videoRef.current) videoRef.current.srcObject = null
|
releaseCameraStream()
|
||||||
}
|
}
|
||||||
|
|
||||||
const startCamera = async () => {
|
const startCamera = async () => {
|
||||||
stopCamera()
|
|
||||||
setCameraReady(false)
|
setCameraReady(false)
|
||||||
setCameraError('')
|
setCameraError('')
|
||||||
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
const stream = await acquireCameraStream()
|
||||||
audio: false,
|
activeStream = stream
|
||||||
video: {
|
|
||||||
facingMode: { ideal: 'environment' },
|
const track = getCameraTrack(stream)
|
||||||
|
if (track) {
|
||||||
|
await track.applyConstraints({
|
||||||
width: { ideal: 1920 },
|
width: { ideal: 1920 },
|
||||||
height: { ideal: 1080 },
|
height: { ideal: 1080 },
|
||||||
},
|
}).catch(() => undefined)
|
||||||
})
|
}
|
||||||
|
|
||||||
if (disposed) {
|
if (disposed) {
|
||||||
stream.getTracks().forEach((track) => track.stop())
|
releaseCameraStream()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -532,17 +541,40 @@ export function LabelCheckView({ onOpenSettings }: { onOpenSettings: () => void
|
|||||||
|
|
||||||
void startCamera().catch((cameraStartError: unknown) => {
|
void startCamera().catch((cameraStartError: unknown) => {
|
||||||
if (!disposed) {
|
if (!disposed) {
|
||||||
setCameraError(cameraStartError instanceof Error ? cameraStartError.message : 'Camera access failed.')
|
const denied = cameraStartError instanceof DOMException && cameraStartError.name === 'NotAllowedError'
|
||||||
|
setCameraError(denied ? cameraPermissionHelp() : cameraStartError instanceof Error ? cameraStartError.message : 'Camera access failed.')
|
||||||
setCameraReady(false)
|
setCameraReady(false)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true
|
disposed = true
|
||||||
stopCamera()
|
detachCamera()
|
||||||
}
|
}
|
||||||
}, [retryKey, step])
|
}, [retryKey, step])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step !== 'scan') return
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.pause()
|
||||||
|
videoRef.current.srcObject = null
|
||||||
|
}
|
||||||
|
streamRef.current = null
|
||||||
|
setCameraReady(false)
|
||||||
|
stopCameraStream()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setRetryKey((value) => value + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
}, [step])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (step !== 'scan' || !cameraReady) return
|
if (step !== 'scan' || !cameraReady) return
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
|
|||||||
@ -3,12 +3,23 @@ import { BarcodeFormat, DecodeHintType } from '@zxing/library'
|
|||||||
import { Camera, CheckCircle2, RefreshCw, Settings2, ShieldAlert, SwitchCamera, Volume2, VolumeX, Zap, ZapOff } from 'lucide-react'
|
import { Camera, CheckCircle2, RefreshCw, Settings2, ShieldAlert, SwitchCamera, Volume2, VolumeX, Zap, ZapOff } from 'lucide-react'
|
||||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
acquireCameraStream,
|
||||||
|
cameraPermissionHelp,
|
||||||
|
cameraTrackHasTorch,
|
||||||
|
getCameraTrack,
|
||||||
|
readCameraPermission,
|
||||||
|
releaseCameraStream,
|
||||||
|
setCameraTorch,
|
||||||
|
shouldAutoStartCamera,
|
||||||
|
stopCameraStream,
|
||||||
|
} from '../../lib/cameraSession'
|
||||||
import { recordScan, type ScanRecord } from '../../lib/database'
|
import { recordScan, type ScanRecord } from '../../lib/database'
|
||||||
import { detectionSpanPercent, formatName, formatsForMode, scanFormatOptions, validateScanPayload, type PayloadRules, type ScanFormatMode } from '../../lib/barcodes'
|
import { detectionSpanPercent, formatName, formatsForMode, scanFormatOptions, validateScanPayload, type PayloadRules, type ScanFormatMode } from '../../lib/barcodes'
|
||||||
import { loadScannerPreferences, saveScannerPreferences } from '../../lib/preferences'
|
import { loadScannerPreferences, saveScannerPreferences } from '../../lib/preferences'
|
||||||
import { disableScanAudio, enableScanAudio, getScanAudio } from '../../lib/scanAudio'
|
import { disableScanAudio, enableScanAudio, getScanAudio } from '../../lib/scanAudio'
|
||||||
|
|
||||||
type ScannerStatus = 'starting' | 'detecting' | 'captured' | 'duplicate' | 'permission-denied' | 'camera-error' | 'storage-error'
|
type ScannerStatus = 'starting' | 'permission-prompt' | 'suspended' | 'detecting' | 'captured' | 'duplicate' | 'permission-denied' | 'camera-error' | 'storage-error'
|
||||||
type DetectionPoint = { left: number; top: number }
|
type DetectionPoint = { left: number; top: number }
|
||||||
type ConfirmationCandidate = { value: string; format: BarcodeFormat; count: number; lastSeen: number }
|
type ConfirmationCandidate = { value: string; format: BarcodeFormat; count: number; lastSeen: number }
|
||||||
type PendingCapture = { rawValue: string; format: string }
|
type PendingCapture = { rawValue: string; format: string }
|
||||||
@ -18,6 +29,7 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
|
|||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
const stageRef = useRef<HTMLDivElement>(null)
|
const stageRef = useRef<HTMLDivElement>(null)
|
||||||
const controlsRef = useRef<IScannerControls | null>(null)
|
const controlsRef = useRef<IScannerControls | null>(null)
|
||||||
|
const streamRef = useRef<MediaStream | null>(null)
|
||||||
const armedRef = useRef(true)
|
const armedRef = useRef(true)
|
||||||
const autoAdvanceRef = useRef(initialPreferences.autoAdvance)
|
const autoAdvanceRef = useRef(initialPreferences.autoAdvance)
|
||||||
const soundEnabledRef = useRef(initialPreferences.soundEnabled)
|
const soundEnabledRef = useRef(initialPreferences.soundEnabled)
|
||||||
@ -30,6 +42,7 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
|
|||||||
const lastReadRef = useRef({ value: '', time: 0 })
|
const lastReadRef = useRef({ value: '', time: 0 })
|
||||||
const [armed, setArmed] = useState(true)
|
const [armed, setArmed] = useState(true)
|
||||||
const [status, setStatus] = useState<ScannerStatus>('starting')
|
const [status, setStatus] = useState<ScannerStatus>('starting')
|
||||||
|
const [cameraGate, setCameraGate] = useState<'checking' | 'prompt' | 'ready' | 'blocked'>('checking')
|
||||||
const [cameraError, setCameraError] = useState('')
|
const [cameraError, setCameraError] = useState('')
|
||||||
const [cameras, setCameras] = useState<MediaDeviceInfo[]>([])
|
const [cameras, setCameras] = useState<MediaDeviceInfo[]>([])
|
||||||
const [selectedCamera, setSelectedCamera] = useState('')
|
const [selectedCamera, setSelectedCamera] = useState('')
|
||||||
@ -99,6 +112,35 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
void readCameraPermission().then((permission) => {
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
if (permission === 'denied') {
|
||||||
|
setCameraGate('blocked')
|
||||||
|
setCameraError(cameraPermissionHelp())
|
||||||
|
setStatus('permission-denied')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldAutoStartCamera(permission)) {
|
||||||
|
setCameraGate('ready')
|
||||||
|
setStatus('starting')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCameraGate('prompt')
|
||||||
|
setCameraError('')
|
||||||
|
setStatus('permission-prompt')
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cameraGate !== 'ready') return
|
||||||
|
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!video) return
|
if (!video) return
|
||||||
|
|
||||||
@ -113,13 +155,19 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
|
|||||||
setCameraError('')
|
setCameraError('')
|
||||||
setTorchAvailable(false)
|
setTorchAvailable(false)
|
||||||
|
|
||||||
void reader.decodeFromConstraints(
|
let activeStream: MediaStream | null = null
|
||||||
{
|
|
||||||
audio: false,
|
void acquireCameraStream(requestedCamera)
|
||||||
video: requestedCamera
|
.then(async (stream) => {
|
||||||
? { deviceId: { exact: requestedCamera } }
|
if (disposed) {
|
||||||
: { facingMode: { ideal: 'environment' } },
|
releaseCameraStream()
|
||||||
},
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
activeStream = stream
|
||||||
|
streamRef.current = stream
|
||||||
|
video.srcObject = stream
|
||||||
|
return reader.decodeFromVideoElement(
|
||||||
video,
|
video,
|
||||||
(result) => {
|
(result) => {
|
||||||
if (!armedRef.current || qualitySettingsOpenRef.current) return
|
if (!armedRef.current || qualitySettingsOpenRef.current) return
|
||||||
@ -182,33 +230,99 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
|
|||||||
setPendingCapture(capture)
|
setPendingCapture(capture)
|
||||||
void saveCapture(capture)
|
void saveCapture(capture)
|
||||||
},
|
},
|
||||||
).then((controls) => {
|
)
|
||||||
if (disposed) controls.stop()
|
})
|
||||||
else {
|
.then((controls) => {
|
||||||
|
if (!controls) return
|
||||||
|
if (disposed) {
|
||||||
|
controls.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
controlsRef.current = controls
|
controlsRef.current = controls
|
||||||
setTorchAvailable(Boolean(controls.switchTorch))
|
const activeTrack = getCameraTrack(activeStream)
|
||||||
|
setTorchAvailable(cameraTrackHasTorch(activeTrack))
|
||||||
setStatus('detecting')
|
setStatus('detecting')
|
||||||
|
setCameraError('')
|
||||||
|
|
||||||
void BrowserMultiFormatReader.listVideoInputDevices().then((devices) => {
|
void BrowserMultiFormatReader.listVideoInputDevices().then((devices) => {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
setCameras(devices)
|
setCameras(devices)
|
||||||
const activeDevice = controls.streamVideoSettingsGet?.((track) => [track]).deviceId
|
const activeDevice = activeTrack?.getSettings().deviceId
|
||||||
if (activeDevice) setSelectedCamera(activeDevice)
|
if (activeDevice) setSelectedCamera(activeDevice)
|
||||||
})
|
})
|
||||||
}
|
})
|
||||||
}).catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
const message = error instanceof Error ? error.message : 'Camera access failed.'
|
if (disposed) return
|
||||||
setCameraError(message)
|
const denied = error instanceof DOMException && error.name === 'NotAllowedError'
|
||||||
setStatus(error instanceof DOMException && error.name === 'NotAllowedError' ? 'permission-denied' : 'camera-error')
|
setCameraError(denied ? cameraPermissionHelp() : error instanceof Error ? error.message : 'Camera access failed.')
|
||||||
|
if (denied) setCameraGate('blocked')
|
||||||
|
setStatus(denied ? 'permission-denied' : 'camera-error')
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true
|
disposed = true
|
||||||
controlsRef.current?.stop()
|
controlsRef.current?.stop()
|
||||||
controlsRef.current = null
|
controlsRef.current = null
|
||||||
|
if (video.srcObject === activeStream) video.srcObject = null
|
||||||
|
if (streamRef.current === activeStream) streamRef.current = null
|
||||||
|
releaseCameraStream()
|
||||||
setTorchOn(false)
|
setTorchOn(false)
|
||||||
}
|
}
|
||||||
}, [formatMode, requestedCamera, retryKey, saveCapture, setScannerArmed])
|
}, [cameraGate, formatMode, requestedCamera, retryKey, saveCapture, setScannerArmed])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const suspendCamera = () => {
|
||||||
|
if (document.visibilityState !== 'hidden') return
|
||||||
|
controlsRef.current?.stop()
|
||||||
|
controlsRef.current = null
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.pause()
|
||||||
|
videoRef.current.srcObject = null
|
||||||
|
}
|
||||||
|
streamRef.current = null
|
||||||
|
stopCameraStream()
|
||||||
|
setTorchOn(false)
|
||||||
|
if (cameraGate === 'ready') setStatus('suspended')
|
||||||
|
}
|
||||||
|
|
||||||
|
const resumeCamera = async () => {
|
||||||
|
if (document.visibilityState !== 'visible') return
|
||||||
|
|
||||||
|
const permission = await readCameraPermission()
|
||||||
|
if (permission === 'denied') {
|
||||||
|
setCameraGate('blocked')
|
||||||
|
setCameraError(cameraPermissionHelp())
|
||||||
|
setStatus('permission-denied')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cameraGate === 'ready' || shouldAutoStartCamera(permission)) {
|
||||||
|
setCameraError('')
|
||||||
|
setCameraGate('ready')
|
||||||
|
setRetryKey((value) => value + 1)
|
||||||
|
} else {
|
||||||
|
setCameraGate('prompt')
|
||||||
|
setStatus('permission-prompt')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'hidden') suspendCamera()
|
||||||
|
else void resumeCamera()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageShow = (event: PageTransitionEvent) => {
|
||||||
|
if (event.persisted) void resumeCamera()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
window.addEventListener('pageshow', handlePageShow)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
window.removeEventListener('pageshow', handlePageShow)
|
||||||
|
}
|
||||||
|
}, [cameraGate])
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
if (rearmTimerRef.current) window.clearTimeout(rearmTimerRef.current)
|
if (rearmTimerRef.current) window.clearTimeout(rearmTimerRef.current)
|
||||||
@ -217,7 +331,7 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
|
|||||||
const toggleTorch = async () => {
|
const toggleTorch = async () => {
|
||||||
const nextValue = !torchOn
|
const nextValue = !torchOn
|
||||||
try {
|
try {
|
||||||
await controlsRef.current?.switchTorch?.(nextValue)
|
await setCameraTorch(getCameraTrack(streamRef.current), nextValue)
|
||||||
setTorchOn(nextValue)
|
setTorchOn(nextValue)
|
||||||
} catch {
|
} catch {
|
||||||
setTorchAvailable(false)
|
setTorchAvailable(false)
|
||||||
@ -276,13 +390,23 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
|
|||||||
setQualityMessage('')
|
setQualityMessage('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const retryCamera = () => {
|
const enableCamera = () => {
|
||||||
|
setCameraError('')
|
||||||
|
setCameraGate('ready')
|
||||||
|
setStatus('starting')
|
||||||
setScannerArmed(true)
|
setScannerArmed(true)
|
||||||
setRetryKey((value) => value + 1)
|
setRetryKey((value) => value + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const retryCamera = () => {
|
||||||
|
stopCameraStream()
|
||||||
|
enableCamera()
|
||||||
|
}
|
||||||
|
|
||||||
const statusLabel = {
|
const statusLabel = {
|
||||||
starting: 'STARTING CAMERA',
|
starting: 'STARTING CAMERA',
|
||||||
|
'permission-prompt': 'CAMERA READY',
|
||||||
|
suspended: 'CAMERA PAUSED',
|
||||||
detecting: 'READY TO SCAN',
|
detecting: 'READY TO SCAN',
|
||||||
captured: 'CAPTURED',
|
captured: 'CAPTURED',
|
||||||
duplicate: 'DUPLICATE',
|
duplicate: 'DUPLICATE',
|
||||||
@ -344,7 +468,11 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
|
|||||||
<div className={`scan-frame ${latest && /QR|Data Matrix|Aztec|MaxiCode/i.test(latest.format) ? 'matrix' : ''}`} aria-hidden="true"><span /><span /><span /><span /></div>
|
<div className={`scan-frame ${latest && /QR|Data Matrix|Aztec|MaxiCode/i.test(latest.format) ? 'matrix' : ''}`} aria-hidden="true"><span /><span /><span /><span /></div>
|
||||||
{detectionPoints.map((point, index) => <i className="detection-point" style={{ left: `${point.left}%`, top: `${point.top}%` }} key={index} />)}
|
{detectionPoints.map((point, index) => <i className="detection-point" style={{ left: `${point.left}%`, top: `${point.top}%` }} key={index} />)}
|
||||||
<p className="camera-instruction" aria-live="polite">
|
<p className="camera-instruction" aria-live="polite">
|
||||||
{cameraError || (showQualitySettings ? 'Scanning paused while settings are open' : qualityMessage || (status === 'starting' ? 'Preparing the rear camera' : armed ? `Point the camera at a barcode${confirmationProgress ? ` · confirming ${confirmationProgress}/${confirmationFrames}` : ''}` : autoAdvance ? 'Captured · scanner will rearm automatically' : 'Captured · press Scan next when ready'))}
|
{status === 'permission-prompt'
|
||||||
|
? 'Tap Enable camera to start scanning'
|
||||||
|
: status === 'suspended'
|
||||||
|
? 'Camera paused while FieldLens was in the background'
|
||||||
|
: cameraError || (showQualitySettings ? 'Scanning paused while settings are open' : qualityMessage || (status === 'starting' ? 'Preparing the rear camera' : armed ? `Point the camera at a barcode${confirmationProgress ? ` · confirming ${confirmationProgress}/${confirmationFrames}` : ''}` : autoAdvance ? 'Captured · scanner will rearm automatically' : 'Captured · press Scan next when ready'))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -354,20 +482,52 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="result-copy">
|
<div className="result-copy">
|
||||||
<span className="eyebrow">
|
<span className="eyebrow">
|
||||||
{status === 'storage-error' ? 'CAPTURE NOT SAVED' : cameraError ? 'CAMERA UNAVAILABLE' : latest?.isDuplicate ? 'DUPLICATE DETECTED' : latest ? 'CAPTURE SAVED' : 'SCANNER READY'}
|
{status === 'storage-error'
|
||||||
|
? 'CAPTURE NOT SAVED'
|
||||||
|
: status === 'permission-prompt'
|
||||||
|
? 'CAMERA PERMISSION'
|
||||||
|
: status === 'suspended'
|
||||||
|
? 'CAMERA PAUSED'
|
||||||
|
: cameraError
|
||||||
|
? 'CAMERA UNAVAILABLE'
|
||||||
|
: latest?.isDuplicate
|
||||||
|
? 'DUPLICATE DETECTED'
|
||||||
|
: latest
|
||||||
|
? 'CAPTURE SAVED'
|
||||||
|
: 'SCANNER READY'}
|
||||||
</span>
|
</span>
|
||||||
<strong>{status === 'storage-error' ? pendingCapture?.rawValue : cameraError ? 'Camera access required' : latest?.rawValue ?? 'Waiting for a code'}</strong>
|
<strong>{status === 'storage-error'
|
||||||
<small>{status === 'storage-error' ? 'Local storage failed. Retry saving or discard this capture.' : cameraError ? (status === 'permission-denied' ? 'Allow camera access in browser settings, then try again.' : 'Check that a camera is available, then try again.') : latest ? `${latest.format} · saved locally first` : 'Captures are saved locally first and sync when signed in.'}</small>
|
? pendingCapture?.rawValue
|
||||||
|
: status === 'permission-prompt'
|
||||||
|
? 'Enable camera to scan'
|
||||||
|
: status === 'suspended'
|
||||||
|
? 'Resuming camera'
|
||||||
|
: cameraError
|
||||||
|
? 'Camera access required'
|
||||||
|
: latest?.rawValue ?? 'Waiting for a code'}</strong>
|
||||||
|
<small>{status === 'storage-error'
|
||||||
|
? 'Local storage failed. Retry saving or discard this capture.'
|
||||||
|
: status === 'permission-prompt'
|
||||||
|
? 'Chrome/Android can remember this permission. On iPhone, FieldLens will reuse access while the PWA stays open.'
|
||||||
|
: status === 'suspended'
|
||||||
|
? 'FieldLens releases the camera in the background and restores it when you return.'
|
||||||
|
: cameraError
|
||||||
|
? status === 'permission-denied' ? cameraError : 'Check that a camera is available, then try again.'
|
||||||
|
: latest
|
||||||
|
? `${latest.format} · saved locally first`
|
||||||
|
: 'Captures are saved locally first and sync when signed in.'}</small>
|
||||||
</div>
|
</div>
|
||||||
{status === 'storage-error' && pendingCapture ? (
|
{status === 'storage-error' && pendingCapture ? (
|
||||||
<div className="result-actions">
|
<div className="result-actions">
|
||||||
<button className="quiet-button" type="button" onClick={discardPendingCapture}>Discard</button>
|
<button className="quiet-button" type="button" onClick={discardPendingCapture}>Discard</button>
|
||||||
<button className="primary-button" type="button" onClick={() => void saveCapture(pendingCapture)}><RefreshCw size={17} /> Retry save</button>
|
<button className="primary-button" type="button" onClick={() => void saveCapture(pendingCapture)}><RefreshCw size={17} /> Retry save</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : status === 'permission-prompt' ? (
|
||||||
|
<button className="primary-button" type="button" onClick={enableCamera}><Camera size={17} /> Enable camera</button>
|
||||||
) : cameraError ? (
|
) : cameraError ? (
|
||||||
<button className="primary-button" type="button" onClick={retryCamera}><RefreshCw size={17} /> Try camera again</button>
|
<button className="primary-button" type="button" onClick={retryCamera}><RefreshCw size={17} /> Check settings & retry</button>
|
||||||
) : (
|
) : (
|
||||||
<button className="primary-button" type="button" disabled={armed} onClick={() => setScannerArmed(true)}>
|
<button className="primary-button" type="button" disabled={armed || status === 'suspended'} onClick={() => setScannerArmed(true)}>
|
||||||
<RefreshCw size={17} /> Scan next
|
<RefreshCw size={17} /> Scan next
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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