feat: initialize FieldLens Print desktop release mirror
This commit is contained in:
parent
cde110ea68
commit
a9c15b21f0
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:
|
||||||
|
- "v*"
|
||||||
|
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 installer
|
||||||
|
run: npm run bundle:windows
|
||||||
|
env:
|
||||||
|
XWIN_CACHE_DIR: ${{ runner.temp }}/xwin-cache
|
||||||
|
|
||||||
|
- name: Stage Windows installer
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source_file="$(find 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
|
||||||
|
cp "$source_file" dist/fieldlens-print-windows-x64-setup.exe
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: fieldlens-print-windows-x64
|
||||||
|
path: dist/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
|
||||||
|
|
||||||
|
- name: Stage Debian package
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source_file="$(find src-tauri/target/release/bundle/deb -maxdepth 1 -type f -name '*.deb' -print -quit)"
|
||||||
|
test -n "$source_file"
|
||||||
|
mkdir -p dist
|
||||||
|
cp "$source_file" dist/fieldlens-print-linux-x64.deb
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: fieldlens-print-linux-x64-deb
|
||||||
|
path: dist/fieldlens-print-linux-x64.deb
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
publish-release:
|
||||||
|
name: Publish public desktop release
|
||||||
|
needs: [windows-nsis, linux-deb]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
code: read
|
||||||
|
releases: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Resolve release tag
|
||||||
|
id: release
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [[ "${GITEA_REF}" == refs/tags/* ]]; then
|
||||||
|
tag="${GITEA_REF_NAME}"
|
||||||
|
else
|
||||||
|
version="$(node -p "require('./package.json').version")"
|
||||||
|
tag="v${version}"
|
||||||
|
fi
|
||||||
|
echo "tag=${tag}" >> "$GITEA_OUTPUT"
|
||||||
|
env:
|
||||||
|
GITEA_REF: ${{ gitea.ref }}
|
||||||
|
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: fieldlens-print-windows-x64
|
||||||
|
path: release-assets
|
||||||
|
|
||||||
|
- 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_SHA: ${{ gitea.sha }}
|
||||||
|
RELEASE_TAG: ${{ steps.release.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
api="${GITEA_SERVER_URL%/}/api/v1/repos/${GITEA_REPOSITORY}"
|
||||||
|
auth_header="Authorization: token ${GITEA_TOKEN}"
|
||||||
|
|
||||||
|
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),
|
||||||
|
body: "Public 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
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
src-tauri/target/
|
||||||
|
src-tauri/icons/
|
||||||
30
README.md
30
README.md
@ -1,3 +1,29 @@
|
|||||||
# fieldlens-desktop
|
# FieldLens Desktop
|
||||||
|
|
||||||
FieldLens Print desktop client builds and public release assets
|
Public build and release mirror for **FieldLens Print**.
|
||||||
|
|
||||||
|
The private `Admin/fieldlens` repository remains the source of truth. This repository contains only the desktop client needed to build public Windows and Linux installers.
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
A push to `main` builds:
|
||||||
|
|
||||||
|
- `fieldlens-print-windows-x64-setup.exe`
|
||||||
|
- `fieldlens-print-linux-x64.deb`
|
||||||
|
|
||||||
|
The workflow publishes them to the Gitea release matching the version in `package.json`.
|
||||||
|
|
||||||
|
## Local build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
npm run bundle:deb
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows cross-build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo install --locked cargo-xwin
|
||||||
|
npm run bundle:windows
|
||||||
|
```
|
||||||
|
|||||||
17
app-icon.svg
Normal file
17
app-icon.svg
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<rect width="1024" height="1024" rx="190" fill="#F4D35E"/>
|
||||||
|
<g fill="none" stroke="#1F6F4E" stroke-width="58" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M190 390V240a50 50 0 0 1 50-50h150"/>
|
||||||
|
<path d="M634 190h150a50 50 0 0 1 50 50v150"/>
|
||||||
|
<path d="M834 634v150a50 50 0 0 1-50 50H634"/>
|
||||||
|
<path d="M390 834H240a50 50 0 0 1-50-50V634"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#17211F">
|
||||||
|
<rect x="304" y="346" width="42" height="332" rx="14"/>
|
||||||
|
<rect x="372" y="346" width="24" height="332" rx="10"/>
|
||||||
|
<rect x="424" y="346" width="54" height="332" rx="14"/>
|
||||||
|
<rect x="510" y="346" width="28" height="332" rx="10"/>
|
||||||
|
<rect x="566" y="346" width="68" height="332" rx="14"/>
|
||||||
|
<rect x="662" y="346" width="34" height="332" rx="12"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 832 B |
13
index.html
Normal file
13
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>
|
||||||
23
package.json
Normal file
23
package.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"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": "npm run icons && tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis",
|
||||||
|
"bundle:deb": "npm run icons && tauri build --bundles deb",
|
||||||
|
"icons": "tauri icon app-icon.svg"
|
||||||
|
},
|
||||||
|
"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
src-tauri/Cargo.toml
Normal file
25
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
src-tauri/build.rs
Normal file
3
src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
10
src-tauri/capabilities/default.json
Normal file
10
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
src-tauri/src/capture.rs
Normal file
344
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
src-tauri/src/fieldlens.rs
Normal file
330
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
src-tauri/src/lib.rs
Normal file
108
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
src-tauri/src/main.rs
Normal file
5
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
src-tauri/src/model.rs
Normal file
121
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
src-tauri/src/state.rs
Normal file
123
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
src-tauri/src/zpl.rs
Normal file
127
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
src-tauri/tauri.conf.json
Normal file
35
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
src/main.ts
Normal file
249
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
src/styles.css
Normal file
57
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; }
|
||||||
|
}
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
18
tsconfig.json
Normal file
18
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
vite.config.ts
Normal file
14
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/**'] },
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user