feat(desktop): persist auth and install Linux updates in app
Some checks failed
Desktop Release / Bootstrap updater signing key (push) Has been cancelled
Desktop Release / Windows x64 NSIS (push) Has been cancelled
Desktop Release / Linux x64 DEB + AppImage (push) Has been cancelled
Desktop Release / Publish public desktop release (push) Has been cancelled

This commit is contained in:
Admin 2026-09-24 18:36:34 +00:00
parent 481b1c181c
commit 08f42b7a32
9 changed files with 485 additions and 62 deletions

View File

@ -1,6 +1,6 @@
{
"name": "@fieldlens/print",
"version": "0.1.16",
"version": "0.1.17",
"private": true,
"type": "module",
"scripts": {

View File

@ -1,6 +1,6 @@
[package]
name = "fieldlens-print"
version = "0.1.16"
version = "0.1.17"
description = "FieldLens print-stream barcode capture companion"
authors = ["Arrowstack"]
edition = "2021"
@ -20,6 +20,7 @@ regex = "1"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-opener = "2"
uuid = { version = "1", features = ["v4", "serde"] }

View File

@ -6,7 +6,7 @@ use serde::Deserialize;
use serde_json::{json, Value};
use crate::{
model::{LoginPoll, LoginStart, ScanEvent, SyncResult},
model::{AuthStatus, LoginPoll, LoginStart, ScanEvent, SyncResult},
state::SharedState,
};
@ -34,6 +34,12 @@ struct OAuthError {
error: String,
}
#[derive(Debug, Deserialize)]
struct AccessStateResponse {
status: String,
role: Option<String>,
}
fn default_interval() -> u64 {
5
}
@ -76,6 +82,7 @@ pub fn begin_login(state: &SharedState) -> Result<LoginStart> {
auth.refresh_token = None;
auth.expires_at_unix = None;
}
state.persist_auth()?;
Ok(LoginStart {
user_code: body.user_code,
@ -115,13 +122,26 @@ pub fn poll_login(state: &SharedState) -> Result<LoginPoll> {
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;
let access_token = token.access_token.clone();
{
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;
}
state.persist_auth()?;
let access = ensure_access_state(&client()?, state, &access_token, true)
.unwrap_or(AccessStateResponse {
status: "unknown".into(),
role: None,
});
return Ok(LoginPoll {
status: "authorized".into(),
access_status: Some(access.status),
role: access.role,
});
}
@ -140,9 +160,66 @@ pub fn poll_login(state: &SharedState) -> Result<LoginPoll> {
Ok(LoginPoll {
status: mapped.into(),
access_status: None,
role: None,
})
}
pub fn auth_status(state: &SharedState) -> Result<AuthStatus> {
let has_session = state.auth.lock().refresh_token.is_some();
if !has_session {
return Ok(AuthStatus {
signed_in: false,
access_status: "signed-out".into(),
role: None,
});
}
let access_token = ensure_access_token(state)?;
let access = ensure_access_state(&client()?, state, &access_token, true)?;
Ok(AuthStatus {
signed_in: true,
access_status: access.status,
role: access.role,
})
}
pub fn logout(state: &SharedState) -> Result<()> {
state.clear_auth()
}
fn ensure_access_state(
client: &Client,
state: &SharedState,
access_token: &str,
request_if_needed: bool,
) -> Result<AccessStateResponse> {
let config = state.config.lock().clone();
let base = config.api_base_url.trim_end_matches('/');
let response = client
.get(format!("{base}/api/v1/access/me"))
.bearer_auth(access_token)
.send()
.context("read FieldLens access status")?;
let response = ensure_success(response, "FieldLens access status")?;
let mut access: AccessStateResponse =
response.json().context("decode FieldLens access status")?;
if request_if_needed && (access.status == "none" || access.status == "rejected") {
let response = client
.post(format!("{base}/api/v1/access/request"))
.bearer_auth(access_token)
.send()
.context("request FieldLens access")?;
let response = ensure_success(response, "FieldLens access request")?;
access = response.json().context("decode FieldLens access request")?;
}
Ok(access)
}
fn ensure_access_token(state: &SharedState) -> Result<String> {
{
let auth = state.auth.lock();
@ -179,16 +256,28 @@ fn refresh_access_token(state: &SharedState) -> Result<String> {
.send()
.context("refresh FieldLens token")?;
let response = ensure_success(response, "token refresh")?;
let failed_status = response.status();
let response = match ensure_success(response, "token refresh") {
Ok(response) => response,
Err(error) => {
if failed_status.as_u16() == 400 || failed_status.as_u16() == 401 {
let _ = state.clear_auth();
}
return Err(error);
}
};
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;
{
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);
}
auth.expires_at_unix = Some(now_unix() + token.expires_in);
state.persist_auth()?;
Ok(access_token)
}

View File

@ -3,13 +3,14 @@ mod fieldlens;
mod model;
mod printers;
mod state;
mod updates;
mod zpl;
use parking_lot::Mutex;
use tauri::{AppHandle, Manager, State};
use capture::CaptureRuntime;
use model::{AppConfig, CaptureStatus, JobSummary, LoginPoll, LoginStart, SyncResult};
use model::{AppConfig, AuthStatus, CaptureStatus, JobSummary, LoginPoll, LoginStart, SyncResult};
use printers::{SystemPrinter, VirtualPrinterStatus};
use state::SharedState;
@ -96,6 +97,24 @@ fn poll_login(state: State<'_, AppState>) -> Result<LoginPoll, String> {
fieldlens::poll_login(&state.shared).map_err(|error| error.to_string())
}
#[tauri::command]
fn get_auth_status(state: State<'_, AppState>) -> Result<AuthStatus, String> {
fieldlens::auth_status(&state.shared).map_err(|error| error.to_string())
}
#[tauri::command]
fn logout(state: State<'_, AppState>) -> Result<(), String> {
fieldlens::logout(&state.shared).map_err(|error| error.to_string())
}
#[tauri::command]
async fn install_linux_update() -> Result<updates::UpdateInstallResult, String> {
tauri::async_runtime::spawn_blocking(updates::install_linux_update)
.await
.map_err(|error| error.to_string())?
.map_err(|error| error.to_string())
}
#[cfg(target_os = "linux")]
fn build_self_test_pdf() -> Vec<u8> {
let objects = [
@ -206,6 +225,9 @@ pub fn run() {
run_capture_self_test,
begin_login,
poll_login,
get_auth_status,
logout,
install_linux_update,
sync_pending
])
.run(tauri::generate_context!())

View File

@ -43,7 +43,7 @@ impl Default for AppConfig {
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(),
client_id: "fieldlens-print-desktop".into(),
station_id: String::new(),
installation_id: Uuid::new_v4().to_string(),
device_id: None,
@ -113,8 +113,19 @@ pub struct LoginStart {
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LoginPoll {
pub status: String,
pub access_status: Option<String>,
pub role: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthStatus {
pub signed_in: bool,
pub access_status: String,
pub role: Option<String>,
}
#[derive(Debug, Clone, Serialize)]

View File

@ -7,7 +7,7 @@ use std::{
use anyhow::{Context, Result};
use parking_lot::Mutex;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tauri::{AppHandle, Emitter};
@ -32,15 +32,24 @@ impl SharedState {
if config.spool_dir.trim().is_empty() {
config.spool_dir = data_dir.join("spool").to_string_lossy().into_owned();
}
if config.client_id == "fieldlens-print" {
config.client_id = "fieldlens-print-desktop".into();
}
fs::create_dir_all(&config.spool_dir).ok();
let pending = read_json::<Vec<ScanEvent>>(&data_dir.join("pending.json")).unwrap_or_default();
let persisted_auth =
read_json::<PersistedAuth>(&data_dir.join("auth.json")).unwrap_or_default();
let auth = AuthState {
refresh_token: persisted_auth.refresh_token,
..AuthState::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())),
auth: Arc::new(Mutex::new(auth)),
data_dir,
};
state.persist_config()?;
@ -55,6 +64,28 @@ impl SharedState {
write_json_atomic(&self.data_dir.join("pending.json"), &*self.pending.lock())
}
pub fn persist_auth(&self) -> Result<()> {
let path = self.data_dir.join("auth.json");
let persisted = PersistedAuth {
refresh_token: self.auth.lock().refresh_token.clone(),
};
write_json_atomic(&path, &persisted)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))
.context("secure FieldLens auth state")?;
}
Ok(())
}
pub fn clear_auth(&self) -> Result<()> {
*self.auth.lock() = AuthState::default();
self.persist_auth()
}
pub fn replace_config(&self, next: AppConfig) -> Result<AppConfig> {
fs::create_dir_all(&next.spool_dir).ok();
*self.config.lock() = next.clone();
@ -109,6 +140,11 @@ impl SharedState {
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct PersistedAuth {
refresh_token: Option<String>,
}
fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Option<T> {
let bytes = fs::read(path).ok()?;
serde_json::from_slice(&bytes).ok()

147
src-tauri/src/updates.rs Normal file
View File

@ -0,0 +1,147 @@
use std::{fs, path::PathBuf, process::Command, time::Duration};
use anyhow::{anyhow, Context, Result};
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
const RELEASE_FEED: &str =
"https://git.arrowstack.dev/Admin/fieldlens-desktop/raw/branch/main/downloads/release.json";
const LINUX_DEB_URL: &str =
"https://fieldlens.arrowstack.dev/downloads/fieldlens-print-linux-x64.deb";
#[derive(Debug, Deserialize)]
struct ReleaseFeed {
version: String,
downloads: std::collections::HashMap<String, ReleaseDownload>,
}
#[derive(Debug, Deserialize)]
struct ReleaseDownload {
url: String,
sha256: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateInstallResult {
pub status: String,
pub version: String,
}
fn newer(candidate: &str, current: &str) -> bool {
let parse = |value: &str| {
value
.split('.')
.map(|part| part.parse::<u64>().unwrap_or(0))
.collect::<Vec<_>>()
};
let left = parse(candidate);
let right = parse(current);
let width = left.len().max(right.len());
for index in 0..width {
let a = *left.get(index).unwrap_or(&0);
let b = *right.get(index).unwrap_or(&0);
if a != b {
return a > b;
}
}
false
}
#[cfg(target_os = "linux")]
pub fn install_linux_update() -> Result<UpdateInstallResult> {
let client = Client::builder()
.timeout(Duration::from_secs(120))
.build()
.context("create update HTTP client")?;
let release: ReleaseFeed = client
.get(RELEASE_FEED)
.header("cache-control", "no-cache")
.send()
.context("fetch FieldLens release feed")?
.error_for_status()
.context("FieldLens release feed returned an error")?
.json()
.context("decode FieldLens release feed")?;
if !newer(&release.version, env!("CARGO_PKG_VERSION")) {
return Ok(UpdateInstallResult {
status: "up-to-date".into(),
version: release.version,
});
}
let download = release
.downloads
.get("linux-x86_64-deb")
.ok_or_else(|| anyhow!("release feed has no Linux DEB"))?;
if download.url != LINUX_DEB_URL {
return Err(anyhow!("refusing unexpected Linux update URL"));
}
if download.sha256.len() != 64
|| !download.sha256.chars().all(|value| value.is_ascii_hexdigit())
{
return Err(anyhow!("release feed contains an invalid SHA-256"));
}
let package = client
.get(&download.url)
.send()
.context("download FieldLens update")?
.error_for_status()
.context("FieldLens update download returned an error")?
.bytes()
.context("read FieldLens update")?;
let actual = format!("{:x}", Sha256::digest(&package));
if !actual.eq_ignore_ascii_case(&download.sha256) {
return Err(anyhow!("FieldLens update checksum verification failed"));
}
let path: PathBuf = std::env::temp_dir().join(format!(
"fieldlens-print-update-{}-{}.deb",
release.version,
Uuid::new_v4()
));
fs::write(&path, &package).context("stage FieldLens update")?;
let installer = if std::path::Path::new("/usr/bin/apt-get").is_file() {
"/usr/bin/apt-get"
} else {
"/usr/bin/dpkg"
};
let mut command = Command::new("pkexec");
command.arg(installer);
if installer.ends_with("apt-get") {
command.args(["install", "-y"]).arg(&path);
} else {
command.arg("-i").arg(&path);
}
let status = command
.status()
.context("run elevated FieldLens update installer")?;
let _ = fs::remove_file(&path);
if !status.success() {
return Err(anyhow!(
"FieldLens update installation was cancelled or failed"
));
}
Ok(UpdateInstallResult {
status: "installed".into(),
version: release.version,
})
}
#[cfg(not(target_os = "linux"))]
pub fn install_linux_update() -> Result<UpdateInstallResult> {
Err(anyhow!("Linux DEB updates are only available on Linux"))
}

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "FieldLens Print",
"version": "0.1.16",
"version": "0.1.17",
"identifier": "dev.arrowstack.fieldlens.print",
"build": {
"beforeDevCommand": "npm run dev",

View File

@ -64,6 +64,18 @@ type LoginStart = {
interval: number
}
type LoginPoll = {
status: string
accessStatus?: string | null
role?: string | null
}
type AuthStatus = {
signedIn: boolean
accessStatus: string
role?: string | null
}
type ManualReleaseDownload = {
url: string
sha256?: string
@ -85,7 +97,7 @@ let config: AppConfig
let jobs: Job[] = []
let status: Status
let loginTimer: number | undefined
let currentVersion = '0.1.16'
let currentVersion = '0.1.17'
let bundleType: BundleType | null = null
let availableUpdate: Update | null = null
let availableManualUpdate: ManualUpdate | null = null
@ -93,6 +105,7 @@ let updateBusy = false
let captureError: string | null = null
let systemPrinters: SystemPrinter[] = []
let virtualPrinterStatus: VirtualPrinterStatus | null = null
let authStatus: AuthStatus = { signedIn: false, accessStatus: 'signed-out', role: null }
let activeView: 'setup' | 'running' = 'setup'
app.innerHTML = `
@ -390,6 +403,46 @@ async function refreshPrinters() {
}
}
function renderAuthStatus() {
const state = el('login-state')
const login = el<HTMLButtonElement>('login')
const sync = el<HTMLButtonElement>('sync')
if (!authStatus.signedIn) {
state.textContent = 'Not signed in.'
login.textContent = 'Sign in'
sync.disabled = true
return
}
login.textContent = 'Sign out'
if (authStatus.accessStatus === 'approved') {
state.textContent = authStatus.role
? `Signed in · ${authStatus.role}`
: 'Signed in · access approved'
sync.disabled = false
} else if (authStatus.accessStatus === 'pending') {
state.textContent = 'Signed in · access pending approval'
sync.disabled = true
} else {
state.textContent = `Signed in · access ${authStatus.accessStatus}`
sync.disabled = true
}
}
async function refreshAuthStatus() {
try {
authStatus = await invoke<AuthStatus>('get_auth_status')
} catch (error) {
authStatus = { signedIn: false, accessStatus: 'unavailable', role: null }
el('login-state').textContent = `Sign-in status unavailable: ${String(error)}`
el<HTMLButtonElement>('sync').disabled = true
el<HTMLButtonElement>('login').textContent = 'Sign in'
return
}
renderAuthStatus()
}
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
@ -614,36 +667,71 @@ el('running-stop').addEventListener('click', async () => {
})
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()
const state = el('login-state')
try {
const result = await invoke<{ synced: number; pending: number }>('sync_pending')
state.textContent = `Synced ${result.synced}. ${result.pending} pending.`
await refreshStatus()
} catch (error) {
state.textContent = `Sync failed: ${String(error)}`
await refreshAuthStatus()
}
})
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)
const login = el<HTMLButtonElement>('login')
const state = el('login-state')
if (loginTimer) window.clearInterval(loginTimer)
loginTimer = window.setInterval(async () => {
if (authStatus.signedIn) {
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}.`
}
await invoke('logout')
authStatus = { signedIn: false, accessStatus: 'signed-out', role: null }
el('login-code').textContent = ''
renderAuthStatus()
} catch (error) {
el('login-state').textContent = String(error)
state.textContent = `Sign out failed: ${String(error)}`
}
}, Math.max(3, session.interval) * 1000)
return
}
login.disabled = true
try {
const session = await invoke<LoginStart>('begin_login')
el('login-code').textContent = session.userCode
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<LoginPoll>('poll_login')
if (result.status === 'authorized') {
window.clearInterval(loginTimer)
loginTimer = undefined
el('login-code').textContent = ''
authStatus = {
signedIn: true,
accessStatus: result.accessStatus || 'unknown',
role: result.role ?? null,
}
renderAuthStatus()
} else if (result.status === 'expired' || result.status === 'denied') {
window.clearInterval(loginTimer)
loginTimer = undefined
state.textContent = `Login ${result.status}.`
}
} catch (error) {
if (loginTimer) window.clearInterval(loginTimer)
loginTimer = undefined
state.textContent = String(error)
}
}, Math.max(3, session.interval) * 1000)
} catch (error) {
state.textContent = `Sign in failed: ${String(error)}`
} finally {
login.disabled = false
}
})
async function checkForUpdates(manual = false) {
@ -681,7 +769,7 @@ async function checkForUpdates(manual = false) {
if (manualUpdate) {
state.textContent = `Version ${manualUpdate.version} is available.`
installButton.hidden = false
installButton.textContent = 'Open installer'
installButton.textContent = bundleType === BundleType.Deb ? 'Install & restart' : 'Open installer'
return
}
@ -702,29 +790,56 @@ async function installAvailableUpdate() {
const button = el<HTMLButtonElement>('install-update')
const progress = el<HTMLDivElement>('update-progress')
const bar = progress.querySelector('span') as HTMLSpanElement
if (availableManualUpdate) {
state.textContent = 'Opening the current FieldLens Print installer…'
await openUrl(availableManualUpdate.url)
return
}
if (status?.running) {
state.textContent = 'Stop capture before installing an update.'
return
}
const wasRunning = Boolean(status?.running)
updateBusy = true
button.disabled = true
progress.hidden = false
bar.style.width = '8%'
let downloaded = 0
let total = 0
const signedUpdate = availableUpdate
if (!signedUpdate) return
try {
if (availableManualUpdate && bundleType === BundleType.Deb) {
progress.hidden = false
bar.style.width = '20%'
state.textContent = 'Stopping capture before update…'
if (wasRunning) {
await invoke('stop_capture')
await refreshStatus()
}
state.textContent = `Downloading and verifying FieldLens Print ${availableManualUpdate.version}…`
bar.style.width = '45%'
const result = await invoke<{ status: string; version: string }>('install_linux_update')
if (result.status === 'up-to-date') {
state.textContent = `FieldLens Print ${result.version} is already installed.`
progress.hidden = true
if (wasRunning) await ensureCaptureStarted()
return
}
bar.style.width = '100%'
state.textContent = `FieldLens Print ${result.version} installed. Restarting…`
await relaunch()
return
}
if (availableManualUpdate) {
state.textContent = 'Opening the current FieldLens Print installer…'
await openUrl(availableManualUpdate.url)
return
}
progress.hidden = false
bar.style.width = '8%'
let downloaded = 0
let total = 0
const signedUpdate = availableUpdate
if (!signedUpdate) return
if (wasRunning) {
state.textContent = 'Stopping capture before update…'
await invoke('stop_capture')
await refreshStatus()
}
state.textContent = `Downloading FieldLens Print ${signedUpdate.version}…`
await signedUpdate.downloadAndInstall((event) => {
if (event.event === 'Started') {
@ -747,6 +862,7 @@ async function installAvailableUpdate() {
} catch (error) {
state.textContent = `Update failed: ${String(error)}`
progress.hidden = true
if (wasRunning) await ensureCaptureStarted().catch(() => undefined)
} finally {
updateBusy = false
button.disabled = false
@ -801,6 +917,7 @@ config = await invoke<AppConfig>('get_config')
writeForm(config)
await refreshPrinters()
await ensureCaptureStarted()
void refreshAuthStatus()
const readyForRunning = Boolean(
virtualPrinterStatus?.installed