From a9c15b21f0b308fa9f6a75bc81b84a5fd19a2e90 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 23 Sep 2026 22:43:52 +0000 Subject: [PATCH] feat: initialize FieldLens Print desktop release mirror --- .gitea/workflows/desktop-release.yml | 202 ++++++++++++++++ .gitignore | 4 + README.md | 30 ++- app-icon.svg | 17 ++ index.html | 13 + package.json | 23 ++ src-tauri/Cargo.toml | 25 ++ src-tauri/build.rs | 3 + src-tauri/capabilities/default.json | 10 + src-tauri/src/capture.rs | 344 +++++++++++++++++++++++++++ src-tauri/src/fieldlens.rs | 330 +++++++++++++++++++++++++ src-tauri/src/lib.rs | 108 +++++++++ src-tauri/src/main.rs | 5 + src-tauri/src/model.rs | 121 ++++++++++ src-tauri/src/state.rs | 123 ++++++++++ src-tauri/src/zpl.rs | 127 ++++++++++ src-tauri/tauri.conf.json | 35 +++ src/main.ts | 249 +++++++++++++++++++ src/styles.css | 57 +++++ src/vite-env.d.ts | 1 + tsconfig.json | 18 ++ vite.config.ts | 14 ++ 22 files changed, 1857 insertions(+), 2 deletions(-) create mode 100644 .gitea/workflows/desktop-release.yml create mode 100644 .gitignore create mode 100644 app-icon.svg create mode 100644 index.html create mode 100644 package.json create mode 100644 src-tauri/Cargo.toml create mode 100644 src-tauri/build.rs create mode 100644 src-tauri/capabilities/default.json create mode 100644 src-tauri/src/capture.rs create mode 100644 src-tauri/src/fieldlens.rs create mode 100644 src-tauri/src/lib.rs create mode 100644 src-tauri/src/main.rs create mode 100644 src-tauri/src/model.rs create mode 100644 src-tauri/src/state.rs create mode 100644 src-tauri/src/zpl.rs create mode 100644 src-tauri/tauri.conf.json create mode 100644 src/main.ts create mode 100644 src/styles.css create mode 100644 src/vite-env.d.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitea/workflows/desktop-release.yml b/.gitea/workflows/desktop-release.yml new file mode 100644 index 0000000..05bc891 --- /dev/null +++ b/.gitea/workflows/desktop-release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b579482 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +src-tauri/target/ +src-tauri/icons/ diff --git a/README.md b/README.md index 832f528..aedf742 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,29 @@ -# fieldlens-desktop +# FieldLens Desktop -FieldLens Print desktop client builds and public release assets \ No newline at end of file +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 +``` diff --git a/app-icon.svg b/app-icon.svg new file mode 100644 index 0000000..13af2f7 --- /dev/null +++ b/app-icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..37a6a91 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + FieldLens Print + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..92e7c49 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..57428b7 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "fieldlens-print" +version = "0.1.0" +description = "FieldLens print-stream barcode capture companion" +authors = ["Arrowstack"] +edition = "2021" + +[lib] +name = "fieldlens_print_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +anyhow = "1" +chrono = { version = "0.4", features = ["serde", "clock"] } +parking_lot = "0.12" +regex = "1" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tauri = { version = "2", features = ["tray-icon"] } +tauri-plugin-opener = "2" +uuid = { version = "1", features = ["v4", "serde"] } diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..52241d5 --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,10 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "FieldLens Print desktop permissions", + "windows": ["main"], + "permissions": [ + "core:default", + "opener:default" + ] +} diff --git a/src-tauri/src/capture.rs b/src-tauri/src/capture.rs new file mode 100644 index 0000000..3839d43 --- /dev/null +++ b/src-tauri/src/capture.rs @@ -0,0 +1,344 @@ +use std::{ + fs, + io::{ErrorKind, Read, Write}, + net::{TcpListener, TcpStream}, + path::PathBuf, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread::{self, JoinHandle}, + time::Duration, +}; + +use anyhow::{anyhow, Context, Result}; +use tauri::AppHandle; +use uuid::Uuid; + +use crate::{ + model::{AppConfig, CaptureMode, JobSummary}, + state::SharedState, + zpl::extract_barcodes, +}; + +pub struct CaptureRuntime { + stop: Arc, + handles: Vec>, +} + +impl CaptureRuntime { + pub fn stop(mut self) { + self.stop.store(true, Ordering::SeqCst); + for handle in self.handles.drain(..) { + let _ = handle.join(); + } + } +} + +pub fn start(config: AppConfig, state: SharedState, app: AppHandle) -> Result { + let stop = Arc::new(AtomicBool::new(false)); + let mut handles = Vec::new(); + + if matches!(config.mode, CaptureMode::RawProxy | CaptureMode::Both) { + let listener = TcpListener::bind(&config.listen_addr) + .with_context(|| format!("bind RAW capture listener {}", config.listen_addr))?; + listener.set_nonblocking(true)?; + + let worker_stop = stop.clone(); + let worker_state = state.clone(); + let worker_app = app.clone(); + let worker_config = config.clone(); + handles.push(thread::spawn(move || { + raw_proxy_loop(listener, worker_config, worker_state, worker_app, worker_stop) + })); + } + + if matches!(config.mode, CaptureMode::SpoolFolder | CaptureMode::Both) { + let spool_dir = PathBuf::from(&config.spool_dir); + fs::create_dir_all(&spool_dir) + .with_context(|| format!("create spool capture directory {}", spool_dir.display()))?; + + let worker_stop = stop.clone(); + let worker_state = state.clone(); + let worker_app = app.clone(); + let worker_config = config.clone(); + handles.push(thread::spawn(move || { + spool_folder_loop(worker_config, worker_state, worker_app, worker_stop) + })); + } + + if handles.is_empty() { + return Err(anyhow!("no capture route enabled")); + } + + Ok(CaptureRuntime { stop, handles }) +} + +fn raw_proxy_loop( + listener: TcpListener, + config: AppConfig, + state: SharedState, + app: AppHandle, + stop: Arc, +) { + while !stop.load(Ordering::SeqCst) { + match listener.accept() { + Ok((stream, peer)) => { + let state = state.clone(); + let app = app.clone(); + let config = config.clone(); + let label = peer.to_string(); + thread::spawn(move || handle_raw_connection(stream, &config, &state, &app, label)); + } + Err(error) if error.kind() == ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(80)); + } + Err(error) => { + let job = JobSummary { + id: Uuid::new_v4().to_string(), + source: "raw-proxy".into(), + label: "listener".into(), + bytes: 0, + barcodes: vec![], + captured_at: chrono::Utc::now().to_rfc3339(), + forwarded: false, + error: Some(format!("accept failed: {error}")), + }; + state.push_job(&app, job); + thread::sleep(Duration::from_millis(500)); + } + } + } +} + +fn handle_raw_connection( + mut source: TcpStream, + config: &AppConfig, + state: &SharedState, + app: &AppHandle, + label: String, +) { + let _ = source.set_read_timeout(Some(Duration::from_millis(750))); + let mut forward_error: Option = None; + let mut destination = if config.forward_addr.trim().is_empty() { + None + } else { + match config.forward_addr.parse() { + Ok(addr) => match TcpStream::connect_timeout(&addr, Duration::from_secs(3)) { + Ok(stream) => Some(stream), + Err(error) => { + forward_error = Some(format!("forward connect failed: {error}")); + None + } + }, + Err(error) => { + forward_error = Some(format!("invalid forward address: {error}")); + None + } + } + }; + + let mut bytes = Vec::new(); + let mut buffer = [0u8; 16 * 1024]; + + loop { + match source.read(&mut buffer) { + Ok(0) => break, + Ok(read) => { + let chunk = &buffer[..read]; + bytes.extend_from_slice(chunk); + if let Some(target) = destination.as_mut() { + if let Err(error) = target.write_all(chunk) { + forward_error = Some(format!("forward write failed: {error}")); + destination = None; + } + } + } + Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => { + if !bytes.is_empty() { + break; + } + } + Err(error) => { + forward_error = Some(format!("capture read failed: {error}")); + break; + } + } + } + + if bytes.is_empty() && forward_error.is_none() { + return; + } + + process_payload( + state, + app, + "raw-proxy", + label, + bytes, + !config.forward_addr.trim().is_empty() && forward_error.is_none(), + forward_error, + ); +} + +fn spool_folder_loop( + config: AppConfig, + state: SharedState, + app: AppHandle, + stop: Arc, +) { + let spool_dir = PathBuf::from(&config.spool_dir); + let processed_dir = spool_dir.join("processed"); + let _ = fs::create_dir_all(&processed_dir); + while !stop.load(Ordering::SeqCst) { + let entries = match fs::read_dir(&spool_dir) { + Ok(entries) => entries, + Err(error) => { + state.push_job( + &app, + JobSummary { + id: Uuid::new_v4().to_string(), + source: "spool-folder".into(), + label: spool_dir.display().to_string(), + bytes: 0, + barcodes: vec![], + captured_at: chrono::Utc::now().to_rfc3339(), + forwarded: false, + error: Some(format!("spool read failed: {error}")), + }, + ); + thread::sleep(Duration::from_secs(1)); + continue; + } + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() || !is_supported_drop(&path) { + continue; + } + + let stable = entry + .metadata() + .ok() + .and_then(|metadata| metadata.modified().ok()) + .and_then(|modified| modified.elapsed().ok()) + .map(|age| age >= Duration::from_millis(500)) + .unwrap_or(false); + if !stable { + continue; + } + + let label = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("spool-job") + .to_string(); + + match fs::read(&path) { + Ok(bytes) => { + let (forwarded, error) = forward_all(&config.forward_addr, &bytes); + process_payload( + &state, + &app, + "spool-folder", + label.clone(), + bytes, + forwarded, + error, + ); + + let target = unique_processed_path(&processed_dir, &label); + let _ = fs::rename(&path, target); + } + Err(error) => process_payload( + &state, + &app, + "spool-folder", + label, + vec![], + false, + Some(format!("spool file read failed: {error}")), + ), + } + } + + thread::sleep(Duration::from_millis(350)); + } +} + +fn is_supported_drop(path: &std::path::Path) -> bool { + matches!( + path.extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .as_deref(), + Some("zpl" | "raw" | "prn" | "spl") + ) +} + +fn unique_processed_path(dir: &std::path::Path, filename: &str) -> PathBuf { + let safe = filename.replace('/', "_").replace('\\', "_"); + dir.join(format!( + "{}-{}-{}", + chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ"), + Uuid::new_v4(), + safe + )) +} + +fn forward_all(address: &str, bytes: &[u8]) -> (bool, Option) { + if address.trim().is_empty() { + return (false, None); + } + + let socket = match address.parse() { + Ok(value) => value, + Err(error) => return (false, Some(format!("invalid forward address: {error}"))), + }; + + match TcpStream::connect_timeout(&socket, Duration::from_secs(3)) { + Ok(mut stream) => match stream.write_all(bytes) { + Ok(()) => (true, None), + Err(error) => (false, Some(format!("forward write failed: {error}"))), + }, + Err(error) => (false, Some(format!("forward connect failed: {error}"))), + } +} + +fn process_payload( + state: &SharedState, + app: &AppHandle, + source: &str, + label: String, + bytes: Vec, + forwarded: bool, + error: Option, +) { + let id = Uuid::new_v4().to_string(); + let barcodes = extract_barcodes(&bytes); + + for barcode in &barcodes { + state.queue_scan( + barcode.value.clone(), + barcode.format.clone(), + "print".into(), + &id, + &label, + ); + } + + state.push_job( + app, + JobSummary { + id, + source: source.into(), + label, + bytes: bytes.len(), + barcodes, + captured_at: chrono::Utc::now().to_rfc3339(), + forwarded, + error, + }, + ); +} diff --git a/src-tauri/src/fieldlens.rs b/src-tauri/src/fieldlens.rs new file mode 100644 index 0000000..5f01fee --- /dev/null +++ b/src-tauri/src/fieldlens.rs @@ -0,0 +1,330 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, Context, Result}; +use reqwest::blocking::{Client, Response}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::{ + model::{LoginPoll, LoginStart, ScanEvent, SyncResult}, + state::SharedState, +}; + +#[derive(Debug, Deserialize)] +struct DeviceCodeResponse { + device_code: String, + user_code: String, + verification_uri: String, + verification_uri_complete: Option, + expires_in: u64, + #[serde(default = "default_interval")] + interval: u64, +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + #[serde(default)] + refresh_token: Option, + expires_in: i64, +} + +#[derive(Debug, Deserialize)] +struct OAuthError { + error: String, +} + +fn default_interval() -> u64 { + 5 +} + +fn now_unix() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +fn client() -> Result { + Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .context("create HTTP client") +} + +pub fn begin_login(state: &SharedState) -> Result { + let config = state.config.lock().clone(); + let endpoint = format!( + "{}/protocol/openid-connect/auth/device", + config.issuer.trim_end_matches('/') + ); + + let response = client()? + .post(endpoint) + .form(&[("client_id", config.client_id.as_str())]) + .send() + .context("request Keycloak device code")?; + + let response = ensure_success(response, "device authorization")?; + let body: DeviceCodeResponse = response.json().context("decode device authorization response")?; + + { + let mut auth = state.auth.lock(); + auth.device_code = Some(body.device_code); + auth.poll_interval = body.interval; + auth.access_token = None; + auth.refresh_token = None; + auth.expires_at_unix = None; + } + + Ok(LoginStart { + user_code: body.user_code, + verification_uri: body.verification_uri, + verification_uri_complete: body.verification_uri_complete, + expires_in: body.expires_in, + interval: body.interval, + }) +} + +pub fn poll_login(state: &SharedState) -> Result { + let config = state.config.lock().clone(); + let device_code = state + .auth + .lock() + .device_code + .clone() + .ok_or_else(|| anyhow!("no device login is active"))?; + + let endpoint = format!( + "{}/protocol/openid-connect/token", + config.issuer.trim_end_matches('/') + ); + + let response = client()? + .post(endpoint) + .form(&[ + ("client_id", config.client_id.as_str()), + ( + "grant_type", + "urn:ietf:params:oauth:grant-type:device_code", + ), + ("device_code", device_code.as_str()), + ]) + .send() + .context("poll Keycloak device login")?; + + if response.status().is_success() { + let token: TokenResponse = response.json().context("decode token response")?; + let mut auth = state.auth.lock(); + auth.access_token = Some(token.access_token); + auth.refresh_token = token.refresh_token; + auth.expires_at_unix = Some(now_unix() + token.expires_in); + auth.device_code = None; + return Ok(LoginPoll { + status: "authorized".into(), + }); + } + + let status = response.status(); + let error = response + .json::() + .map(|body| body.error) + .unwrap_or_else(|_| format!("http_{}", status.as_u16())); + + let mapped = match error.as_str() { + "authorization_pending" | "slow_down" => "pending", + "expired_token" => "expired", + "access_denied" => "denied", + _ => return Err(anyhow!("device login failed: {error}")), + }; + + Ok(LoginPoll { + status: mapped.into(), + }) +} + +fn ensure_access_token(state: &SharedState) -> Result { + { + let auth = state.auth.lock(); + if auth.expires_at_unix.unwrap_or_default() > now_unix() + 30 { + if let Some(token) = &auth.access_token { + return Ok(token.clone()); + } + } + } + + refresh_access_token(state) +} + +fn refresh_access_token(state: &SharedState) -> Result { + let config = state.config.lock().clone(); + let refresh_token = state + .auth + .lock() + .refresh_token + .clone() + .ok_or_else(|| anyhow!("not signed in to FieldLens"))?; + + let endpoint = format!( + "{}/protocol/openid-connect/token", + config.issuer.trim_end_matches('/') + ); + let response = client()? + .post(endpoint) + .form(&[ + ("client_id", config.client_id.as_str()), + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token.as_str()), + ]) + .send() + .context("refresh FieldLens token")?; + + let response = ensure_success(response, "token refresh")?; + let token: TokenResponse = response.json().context("decode refresh response")?; + + let access_token = token.access_token.clone(); + let mut auth = state.auth.lock(); + auth.access_token = Some(token.access_token); + if token.refresh_token.is_some() { + auth.refresh_token = token.refresh_token; + } + auth.expires_at_unix = Some(now_unix() + token.expires_in); + Ok(access_token) +} + +pub fn sync_pending(state: &SharedState) -> Result { + let pending = state.pending.lock().clone(); + if pending.is_empty() { + return Ok(SyncResult { + synced: 0, + pending: 0, + }); + } + + let access_token = ensure_access_token(state)?; + let client = client()?; + let device_id = ensure_device(&client, state, &access_token)?; + + let batch: Vec = pending.into_iter().take(100).collect(); + let events: Vec = batch + .iter() + .map(|scan| { + json!({ + "id": scan.id, + "aggregateId": scan.id, + "eventType": "scan.captured", + "schemaVersion": 1, + "createdAt": scan.captured_at, + "payload": { + "id": scan.id, + "rawValue": scan.raw_value, + "format": scan.format, + "duplicate": scan.duplicate, + "capturedAt": scan.captured_at, + "source": scan.source, + "sourceMetadata": scan.source_metadata, + "schemaVersion": 1 + } + }) + }) + .collect(); + + let config = state.config.lock().clone(); + let response = client + .post(format!( + "{}/api/v1/sync", + config.api_base_url.trim_end_matches('/') + )) + .bearer_auth(&access_token) + .json(&json!({ + "deviceId": device_id, + "schemaVersion": 1, + "events": events + })) + .send() + .context("sync print scans")?; + + let response = ensure_success(response, "FieldLens sync")?; + let body: Value = response.json().context("decode FieldLens sync response")?; + let accepted: std::collections::HashSet = body + .get("acknowledgements") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|ack| { + let status = ack.get("status")?.as_str()?; + if status != "accepted" && status != "duplicate" { + return None; + } + ack.get("eventId")?.as_str().map(str::to_owned) + }) + .collect(); + + let synced = accepted.len(); + { + let mut queue = state.pending.lock(); + queue.retain(|scan| !accepted.contains(&scan.id)); + } + state.persist_pending()?; + + Ok(SyncResult { + synced, + pending: state.pending_count(), + }) +} + +fn ensure_device(client: &Client, state: &SharedState, access_token: &str) -> Result { + let config = state.config.lock().clone(); + if let Some(device_id) = config.device_id.filter(|id| !id.trim().is_empty()) { + return Ok(device_id); + } + + let mut body = json!({ + "installationId": config.installation_id, + "label": "FieldLens Print", + "appVersion": env!("CARGO_PKG_VERSION"), + "schemaVersion": 1 + }); + if !config.station_id.trim().is_empty() { + body["stationId"] = Value::String(config.station_id.clone()); + } + + let response = client + .post(format!( + "{}/api/v1/devices/register", + config.api_base_url.trim_end_matches('/') + )) + .bearer_auth(access_token) + .json(&body) + .send() + .context("register FieldLens Print device")?; + + let response = ensure_success(response, "FieldLens device registration")?; + let body: Value = response.json().context("decode device registration")?; + let device_id = body + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("FieldLens device registration returned no id"))? + .to_string(); + + state.config.lock().device_id = Some(device_id.clone()); + state.persist_config()?; + Ok(device_id) +} + +fn ensure_success(response: Response, operation: &str) -> Result { + if response.status().is_success() { + return Ok(response); + } + let status = response.status(); + let detail = response.text().unwrap_or_default(); + Err(anyhow!( + "{operation} failed with {}{}", + status.as_u16(), + if detail.is_empty() { + String::new() + } else { + format!(": {detail}") + } + )) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..f2e9f94 --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,108 @@ +mod capture; +mod fieldlens; +mod model; +mod state; +mod zpl; + +use parking_lot::Mutex; +use tauri::{AppHandle, Manager, State}; + +use capture::CaptureRuntime; +use model::{AppConfig, CaptureStatus, JobSummary, LoginPoll, LoginStart, SyncResult}; +use state::SharedState; + +struct AppState { + shared: SharedState, + runtime: Mutex>, +} + +#[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 { + 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 { + 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 { + fieldlens::begin_login(&state.shared).map_err(|error| error.to_string()) +} + +#[tauri::command] +fn poll_login(state: State<'_, AppState>) -> Result { + fieldlens::poll_login(&state.shared).map_err(|error| error.to_string()) +} + +#[tauri::command] +fn sync_pending(state: State<'_, AppState>) -> Result { + 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"); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..5e4dd91 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + fieldlens_print_lib::run(); +} diff --git a/src-tauri/src/model.rs b/src-tauri/src/model.rs new file mode 100644 index 0000000..71913a5 --- /dev/null +++ b/src-tauri/src/model.rs @@ -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, +} + +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, + pub captured_at: String, + pub forwarded: bool, + pub error: Option, +} + +#[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, + pub access_token: Option, + pub refresh_token: Option, + pub expires_at_unix: Option, + 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, + 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, +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs new file mode 100644 index 0000000..d534216 --- /dev/null +++ b/src-tauri/src/state.rs @@ -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>, + pub jobs: Arc>>, + pub pending: Arc>>, + pub auth: Arc>, + pub data_dir: PathBuf, +} + +impl SharedState { + pub fn load(data_dir: PathBuf) -> Result { + fs::create_dir_all(&data_dir).context("create FieldLens Print data directory")?; + + let mut config = read_json::(&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::>(&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 { + 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(path: &Path) -> Option { + let bytes = fs::read(path).ok()?; + serde_json::from_slice(&bytes).ok() +} + +fn write_json_atomic(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(()) +} diff --git a/src-tauri/src/zpl.rs b/src-tauri/src/zpl.rs new file mode 100644 index 0000000..d1ac0b6 --- /dev/null +++ b/src-tauri/src/zpl.rs @@ -0,0 +1,127 @@ +use crate::model::DecodedBarcode; + +pub fn extract_barcodes(bytes: &[u8]) -> Vec { + let text = String::from_utf8_lossy(bytes); + extract_from_zpl(&text) +} + +pub fn extract_from_zpl(input: &str) -> Vec { + let bytes = input.as_bytes(); + let mut index = 0usize; + let mut pending_format: Option = 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()); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..00188b4 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -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" + } + } + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..7f2ad5a --- /dev/null +++ b/src/main.ts @@ -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('#app')! +let config: AppConfig +let jobs: Job[] = [] +let status: Status +let loginTimer: number | undefined + +app.innerHTML = ` +
+
+
+
FL
+

FieldLens Print

Digital barcode capture before the label leaves the printer.

+
+
Loading
+
+ +
+
+

Capture

RAW + spool routes
+
+
+ + + + +
+
+ + + +
+
+
+ +
+

FieldLens

Device login
+
+
+
0Jobs
+
0Codes
+
0Pending
+
+ +
+ Connection settings +
+ + + + +
+
+
+
+ +
+

Recent print jobs

Waiting for capture
+
+
+
+
+` + +function el(id: string) { + return document.getElementById(id) as T +} + +function readForm(): AppConfig { + return { + ...config, + mode: el('mode').value as CaptureMode, + listenAddr: el('listenAddr').value.trim(), + forwardAddr: el('forwardAddr').value.trim(), + spoolDir: el('spoolDir').value.trim(), + apiBaseUrl: el('apiBaseUrl').value.trim(), + issuer: el('issuer').value.trim(), + clientId: el('clientId').value.trim(), + stationId: el('stationId').value.trim(), + } +} + +function writeForm(next: AppConfig) { + el('mode').value = next.mode + el('listenAddr').value = next.listenAddr + el('forwardAddr').value = next.forwardAddr + el('spoolDir').value = next.spoolDir + el('apiBaseUrl').value = next.apiBaseUrl + el('issuer').value = next.issuer + el('clientId').value = next.clientId + el('stationId').value = next.stationId +} + +function render() { + const pill = el('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) => ` +
+
${escapeHtml(job.source)} · ${escapeHtml(job.label)}${new Date(job.capturedAt).toLocaleTimeString()}
+ ${job.barcodes.length ? `${job.barcodes.length} barcode${job.barcodes.length === 1 ? '' : 's'} detected` : 'No supported barcode command detected'} +
${job.barcodes.map((code) => `${escapeHtml(code.format)} · ${escapeHtml(code.value)}`).join('')}
+
${job.bytes.toLocaleString()} bytes · ${job.forwarded ? 'forwarded' : 'not forwarded'}${job.error ? ` · ${escapeHtml(job.error)}` : ''}
+
+ `).join('') : '
No jobs captured yet.
' +} + +function escapeHtml(value: string) { + return value.replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]!)) +} + +async function refreshStatus() { + status = await invoke('get_status') + jobs = await invoke('recent_jobs') + render() +} + +el('save').addEventListener('click', async () => { + config = await invoke('save_config', { config: readForm() }) + writeForm(config) + await refreshStatus() +}) + +el('start').addEventListener('click', async () => { + config = await invoke('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('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('capture-job', (event) => { + jobs = [event.payload, ...jobs.filter((job) => job.id !== event.payload.id)].slice(0, 200) + void refreshStatus() +}) + +config = await invoke('get_config') +writeForm(config) +await refreshStatus() +window.setInterval(() => { void refreshStatus() }, 5000) diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..981c5fe --- /dev/null +++ b/src/styles.css @@ -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; } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ed0c191 --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..cf3cd6f --- /dev/null +++ b/vite.config.ts @@ -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/**'] }, + }, +})