From 08f42b7a323e222bfda97eed0a629aeaaee62642 Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 24 Sep 2026 18:36:34 +0000 Subject: [PATCH] feat(desktop): persist auth and install Linux updates in app --- package.json | 2 +- src-tauri/Cargo.toml | 3 +- src-tauri/src/fieldlens.rs | 113 ++++++++++++++++++--- src-tauri/src/lib.rs | 24 ++++- src-tauri/src/model.rs | 13 ++- src-tauri/src/state.rs | 40 +++++++- src-tauri/src/updates.rs | 147 +++++++++++++++++++++++++++ src-tauri/tauri.conf.json | 2 +- src/main.ts | 203 +++++++++++++++++++++++++++++-------- 9 files changed, 485 insertions(+), 62 deletions(-) create mode 100644 src-tauri/src/updates.rs diff --git a/package.json b/package.json index 8f8b59b..966e8f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fieldlens/print", - "version": "0.1.16", + "version": "0.1.17", "private": true, "type": "module", "scripts": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1c3a3ab..44b3409 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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"] } diff --git a/src-tauri/src/fieldlens.rs b/src-tauri/src/fieldlens.rs index 5f01fee..e52689a 100644 --- a/src-tauri/src/fieldlens.rs +++ b/src-tauri/src/fieldlens.rs @@ -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, +} + fn default_interval() -> u64 { 5 } @@ -76,6 +82,7 @@ pub fn begin_login(state: &SharedState) -> Result { 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 { 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 { Ok(LoginPoll { status: mapped.into(), + access_status: None, + role: None, }) } +pub fn auth_status(state: &SharedState) -> Result { + 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 { + 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 { { let auth = state.auth.lock(); @@ -179,16 +256,28 @@ fn refresh_access_token(state: &SharedState) -> Result { .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) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index be02b41..85c4eef 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { fieldlens::poll_login(&state.shared).map_err(|error| error.to_string()) } +#[tauri::command] +fn get_auth_status(state: State<'_, AppState>) -> Result { + 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 { + 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 { 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!()) diff --git a/src-tauri/src/model.rs b/src-tauri/src/model.rs index c0ac507..cfc4ba6 100644 --- a/src-tauri/src/model.rs +++ b/src-tauri/src/model.rs @@ -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, + pub role: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthStatus { + pub signed_in: bool, + pub access_status: String, + pub role: Option, } #[derive(Debug, Clone, Serialize)] diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 02cef22..98746bf 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -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::>(&data_dir.join("pending.json")).unwrap_or_default(); + let persisted_auth = + read_json::(&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 { 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, +} + fn read_json(path: &Path) -> Option { let bytes = fs::read(path).ok()?; serde_json::from_slice(&bytes).ok() diff --git a/src-tauri/src/updates.rs b/src-tauri/src/updates.rs new file mode 100644 index 0000000..34b7dc2 --- /dev/null +++ b/src-tauri/src/updates.rs @@ -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, +} + +#[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::().unwrap_or(0)) + .collect::>() + }; + 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 { + 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 { + Err(anyhow!("Linux DEB updates are only available on Linux")) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f8b7ab1..f816412 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/main.ts b/src/main.ts index 8f71331..5842fe2 100644 --- a/src/main.ts +++ b/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('login') + const sync = el('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('get_auth_status') + } catch (error) { + authStatus = { signedIn: false, accessStatus: 'unavailable', role: null } + el('login-state').textContent = `Sign-in status unavailable: ${String(error)}` + el('sync').disabled = true + el('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('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('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('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('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('install-update') const progress = el('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('get_config') writeForm(config) await refreshPrinters() await ensureCaptureStarted() +void refreshAuthStatus() const readyForRunning = Boolean( virtualPrinterStatus?.installed