feat: initialize FieldLens Print desktop release mirror
Some checks failed
Desktop Release / Windows x64 NSIS (push) Has been cancelled
Desktop Release / Linux x64 DEB (push) Has been cancelled
Desktop Release / Publish public desktop release (push) Has been cancelled

This commit is contained in:
Admin 2026-09-23 22:43:52 +00:00
parent cde110ea68
commit a9c15b21f0
22 changed files with 1857 additions and 2 deletions

View 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
View File

@ -0,0 +1,4 @@
node_modules/
dist/
src-tauri/target/
src-tauri/icons/

View File

@ -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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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: &regex::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
View 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
View 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[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
View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

18
tsconfig.json Normal file
View 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
View 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/**'] },
},
})