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
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:
parent
481b1c181c
commit
08f42b7a32
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fieldlens/print",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -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"] }
|
||||
|
||||
@ -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 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;
|
||||
}
|
||||
auth.expires_at_unix = Some(now_unix() + token.expires_in);
|
||||
}
|
||||
state.persist_auth()?;
|
||||
Ok(access_token)
|
||||
}
|
||||
|
||||
|
||||
@ -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!())
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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
147
src-tauri/src/updates.rs
Normal 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"))
|
||||
}
|
||||
@ -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",
|
||||
|
||||
151
src/main.ts
151
src/main.ts
@ -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 state = el('login-state')
|
||||
try {
|
||||
const result = await invoke<{ synced: number; pending: number }>('sync_pending')
|
||||
el('login-state').textContent = `Synced ${result.synced}. ${result.pending} 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 login = el<HTMLButtonElement>('login')
|
||||
const state = el('login-state')
|
||||
|
||||
if (authStatus.signedIn) {
|
||||
try {
|
||||
await invoke('logout')
|
||||
authStatus = { signedIn: false, accessStatus: 'signed-out', role: null }
|
||||
el('login-code').textContent = ''
|
||||
renderAuthStatus()
|
||||
} catch (error) {
|
||||
state.textContent = `Sign out failed: ${String(error)}`
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
login.disabled = true
|
||||
try {
|
||||
const session = await invoke<LoginStart>('begin_login')
|
||||
el('login-code').textContent = session.userCode
|
||||
el('login-state').textContent = 'Authorize this device in your browser…'
|
||||
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')
|
||||
const result = await invoke<LoginPoll>('poll_login')
|
||||
if (result.status === 'authorized') {
|
||||
window.clearInterval(loginTimer)
|
||||
loginTimer = undefined
|
||||
el('login-state').textContent = 'Signed in to FieldLens.'
|
||||
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
|
||||
el('login-state').textContent = `Login ${result.status}.`
|
||||
state.textContent = `Login ${result.status}.`
|
||||
}
|
||||
} catch (error) {
|
||||
el('login-state').textContent = String(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,6 +790,36 @@ async function installAvailableUpdate() {
|
||||
const button = el<HTMLButtonElement>('install-update')
|
||||
const progress = el<HTMLDivElement>('update-progress')
|
||||
const bar = progress.querySelector('span') as HTMLSpanElement
|
||||
const wasRunning = Boolean(status?.running)
|
||||
|
||||
updateBusy = true
|
||||
button.disabled = true
|
||||
|
||||
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…'
|
||||
@ -709,22 +827,19 @@ async function installAvailableUpdate() {
|
||||
return
|
||||
}
|
||||
|
||||
if (status?.running) {
|
||||
state.textContent = 'Stop capture before installing an update.'
|
||||
return
|
||||
}
|
||||
|
||||
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 (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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user