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",
|
"name": "@fieldlens/print",
|
||||||
"version": "0.1.16",
|
"version": "0.1.17",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "fieldlens-print"
|
name = "fieldlens-print"
|
||||||
version = "0.1.16"
|
version = "0.1.17"
|
||||||
description = "FieldLens print-stream barcode capture companion"
|
description = "FieldLens print-stream barcode capture companion"
|
||||||
authors = ["Arrowstack"]
|
authors = ["Arrowstack"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@ -20,6 +20,7 @@ regex = "1"
|
|||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
sha2 = "0.10"
|
||||||
tauri = { version = "2", features = ["tray-icon"] }
|
tauri = { version = "2", features = ["tray-icon"] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
|||||||
@ -6,7 +6,7 @@ use serde::Deserialize;
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
model::{LoginPoll, LoginStart, ScanEvent, SyncResult},
|
model::{AuthStatus, LoginPoll, LoginStart, ScanEvent, SyncResult},
|
||||||
state::SharedState,
|
state::SharedState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -34,6 +34,12 @@ struct OAuthError {
|
|||||||
error: String,
|
error: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AccessStateResponse {
|
||||||
|
status: String,
|
||||||
|
role: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
fn default_interval() -> u64 {
|
fn default_interval() -> u64 {
|
||||||
5
|
5
|
||||||
}
|
}
|
||||||
@ -76,6 +82,7 @@ pub fn begin_login(state: &SharedState) -> Result<LoginStart> {
|
|||||||
auth.refresh_token = None;
|
auth.refresh_token = None;
|
||||||
auth.expires_at_unix = None;
|
auth.expires_at_unix = None;
|
||||||
}
|
}
|
||||||
|
state.persist_auth()?;
|
||||||
|
|
||||||
Ok(LoginStart {
|
Ok(LoginStart {
|
||||||
user_code: body.user_code,
|
user_code: body.user_code,
|
||||||
@ -115,13 +122,26 @@ pub fn poll_login(state: &SharedState) -> Result<LoginPoll> {
|
|||||||
|
|
||||||
if response.status().is_success() {
|
if response.status().is_success() {
|
||||||
let token: TokenResponse = response.json().context("decode token response")?;
|
let token: TokenResponse = response.json().context("decode token response")?;
|
||||||
let mut auth = state.auth.lock();
|
let access_token = token.access_token.clone();
|
||||||
auth.access_token = Some(token.access_token);
|
{
|
||||||
auth.refresh_token = token.refresh_token;
|
let mut auth = state.auth.lock();
|
||||||
auth.expires_at_unix = Some(now_unix() + token.expires_in);
|
auth.access_token = Some(token.access_token);
|
||||||
auth.device_code = None;
|
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 {
|
return Ok(LoginPoll {
|
||||||
status: "authorized".into(),
|
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 {
|
Ok(LoginPoll {
|
||||||
status: mapped.into(),
|
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> {
|
fn ensure_access_token(state: &SharedState) -> Result<String> {
|
||||||
{
|
{
|
||||||
let auth = state.auth.lock();
|
let auth = state.auth.lock();
|
||||||
@ -179,16 +256,28 @@ fn refresh_access_token(state: &SharedState) -> Result<String> {
|
|||||||
.send()
|
.send()
|
||||||
.context("refresh FieldLens token")?;
|
.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 token: TokenResponse = response.json().context("decode refresh response")?;
|
||||||
|
|
||||||
let access_token = token.access_token.clone();
|
let access_token = token.access_token.clone();
|
||||||
let mut auth = state.auth.lock();
|
{
|
||||||
auth.access_token = Some(token.access_token);
|
let mut auth = state.auth.lock();
|
||||||
if token.refresh_token.is_some() {
|
auth.access_token = Some(token.access_token);
|
||||||
auth.refresh_token = token.refresh_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)
|
Ok(access_token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,13 +3,14 @@ mod fieldlens;
|
|||||||
mod model;
|
mod model;
|
||||||
mod printers;
|
mod printers;
|
||||||
mod state;
|
mod state;
|
||||||
|
mod updates;
|
||||||
mod zpl;
|
mod zpl;
|
||||||
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use tauri::{AppHandle, Manager, State};
|
use tauri::{AppHandle, Manager, State};
|
||||||
|
|
||||||
use capture::CaptureRuntime;
|
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 printers::{SystemPrinter, VirtualPrinterStatus};
|
||||||
use state::SharedState;
|
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())
|
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")]
|
#[cfg(target_os = "linux")]
|
||||||
fn build_self_test_pdf() -> Vec<u8> {
|
fn build_self_test_pdf() -> Vec<u8> {
|
||||||
let objects = [
|
let objects = [
|
||||||
@ -206,6 +225,9 @@ pub fn run() {
|
|||||||
run_capture_self_test,
|
run_capture_self_test,
|
||||||
begin_login,
|
begin_login,
|
||||||
poll_login,
|
poll_login,
|
||||||
|
get_auth_status,
|
||||||
|
logout,
|
||||||
|
install_linux_update,
|
||||||
sync_pending
|
sync_pending
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
|
|||||||
@ -43,7 +43,7 @@ impl Default for AppConfig {
|
|||||||
spool_dir: String::new(),
|
spool_dir: String::new(),
|
||||||
api_base_url: "https://fieldlens.arrowstack.dev".into(),
|
api_base_url: "https://fieldlens.arrowstack.dev".into(),
|
||||||
issuer: "https://auth.arrowstack.dev/realms/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(),
|
station_id: String::new(),
|
||||||
installation_id: Uuid::new_v4().to_string(),
|
installation_id: Uuid::new_v4().to_string(),
|
||||||
device_id: None,
|
device_id: None,
|
||||||
@ -113,8 +113,19 @@ pub struct LoginStart {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct LoginPoll {
|
pub struct LoginPoll {
|
||||||
pub status: String,
|
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)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
|||||||
@ -7,7 +7,7 @@ use std::{
|
|||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
@ -32,15 +32,24 @@ impl SharedState {
|
|||||||
if config.spool_dir.trim().is_empty() {
|
if config.spool_dir.trim().is_empty() {
|
||||||
config.spool_dir = data_dir.join("spool").to_string_lossy().into_owned();
|
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();
|
fs::create_dir_all(&config.spool_dir).ok();
|
||||||
|
|
||||||
let pending = read_json::<Vec<ScanEvent>>(&data_dir.join("pending.json")).unwrap_or_default();
|
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 {
|
let state = Self {
|
||||||
config: Arc::new(Mutex::new(config)),
|
config: Arc::new(Mutex::new(config)),
|
||||||
jobs: Arc::new(Mutex::new(VecDeque::new())),
|
jobs: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
pending: Arc::new(Mutex::new(pending)),
|
pending: Arc::new(Mutex::new(pending)),
|
||||||
auth: Arc::new(Mutex::new(AuthState::default())),
|
auth: Arc::new(Mutex::new(auth)),
|
||||||
data_dir,
|
data_dir,
|
||||||
};
|
};
|
||||||
state.persist_config()?;
|
state.persist_config()?;
|
||||||
@ -55,6 +64,28 @@ impl SharedState {
|
|||||||
write_json_atomic(&self.data_dir.join("pending.json"), &*self.pending.lock())
|
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> {
|
pub fn replace_config(&self, next: AppConfig) -> Result<AppConfig> {
|
||||||
fs::create_dir_all(&next.spool_dir).ok();
|
fs::create_dir_all(&next.spool_dir).ok();
|
||||||
*self.config.lock() = next.clone();
|
*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> {
|
fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Option<T> {
|
||||||
let bytes = fs::read(path).ok()?;
|
let bytes = fs::read(path).ok()?;
|
||||||
serde_json::from_slice(&bytes).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",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "FieldLens Print",
|
"productName": "FieldLens Print",
|
||||||
"version": "0.1.16",
|
"version": "0.1.17",
|
||||||
"identifier": "dev.arrowstack.fieldlens.print",
|
"identifier": "dev.arrowstack.fieldlens.print",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
203
src/main.ts
203
src/main.ts
@ -64,6 +64,18 @@ type LoginStart = {
|
|||||||
interval: number
|
interval: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LoginPoll = {
|
||||||
|
status: string
|
||||||
|
accessStatus?: string | null
|
||||||
|
role?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthStatus = {
|
||||||
|
signedIn: boolean
|
||||||
|
accessStatus: string
|
||||||
|
role?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
type ManualReleaseDownload = {
|
type ManualReleaseDownload = {
|
||||||
url: string
|
url: string
|
||||||
sha256?: string
|
sha256?: string
|
||||||
@ -85,7 +97,7 @@ let config: AppConfig
|
|||||||
let jobs: Job[] = []
|
let jobs: Job[] = []
|
||||||
let status: Status
|
let status: Status
|
||||||
let loginTimer: number | undefined
|
let loginTimer: number | undefined
|
||||||
let currentVersion = '0.1.16'
|
let currentVersion = '0.1.17'
|
||||||
let bundleType: BundleType | null = null
|
let bundleType: BundleType | null = null
|
||||||
let availableUpdate: Update | null = null
|
let availableUpdate: Update | null = null
|
||||||
let availableManualUpdate: ManualUpdate | null = null
|
let availableManualUpdate: ManualUpdate | null = null
|
||||||
@ -93,6 +105,7 @@ let updateBusy = false
|
|||||||
let captureError: string | null = null
|
let captureError: string | null = null
|
||||||
let systemPrinters: SystemPrinter[] = []
|
let systemPrinters: SystemPrinter[] = []
|
||||||
let virtualPrinterStatus: VirtualPrinterStatus | null = null
|
let virtualPrinterStatus: VirtualPrinterStatus | null = null
|
||||||
|
let authStatus: AuthStatus = { signedIn: false, accessStatus: 'signed-out', role: null }
|
||||||
let activeView: 'setup' | 'running' = 'setup'
|
let activeView: 'setup' | 'running' = 'setup'
|
||||||
|
|
||||||
app.innerHTML = `
|
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) {
|
function formatBytes(bytes: number) {
|
||||||
if (bytes < 1024) return `${bytes} B`
|
if (bytes < 1024) return `${bytes} B`
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
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 () => {
|
el('sync').addEventListener('click', async () => {
|
||||||
const result = await invoke<{ synced: number; pending: number }>('sync_pending')
|
const state = el('login-state')
|
||||||
el('login-state').textContent = `Synced ${result.synced}. ${result.pending} pending.`
|
try {
|
||||||
await refreshStatus()
|
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 () => {
|
el('login').addEventListener('click', async () => {
|
||||||
const session = await invoke<LoginStart>('begin_login')
|
const login = el<HTMLButtonElement>('login')
|
||||||
el('login-code').textContent = session.userCode
|
const state = el('login-state')
|
||||||
el('login-state').textContent = 'Authorize this device in your browser…'
|
|
||||||
const target = session.verificationUriComplete || session.verificationUri
|
|
||||||
await openUrl(target)
|
|
||||||
|
|
||||||
if (loginTimer) window.clearInterval(loginTimer)
|
if (authStatus.signedIn) {
|
||||||
loginTimer = window.setInterval(async () => {
|
|
||||||
try {
|
try {
|
||||||
const result = await invoke<{ status: string }>('poll_login')
|
await invoke('logout')
|
||||||
if (result.status === 'authorized') {
|
authStatus = { signedIn: false, accessStatus: 'signed-out', role: null }
|
||||||
window.clearInterval(loginTimer)
|
el('login-code').textContent = ''
|
||||||
loginTimer = undefined
|
renderAuthStatus()
|
||||||
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) {
|
} 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) {
|
async function checkForUpdates(manual = false) {
|
||||||
@ -681,7 +769,7 @@ async function checkForUpdates(manual = false) {
|
|||||||
if (manualUpdate) {
|
if (manualUpdate) {
|
||||||
state.textContent = `Version ${manualUpdate.version} is available.`
|
state.textContent = `Version ${manualUpdate.version} is available.`
|
||||||
installButton.hidden = false
|
installButton.hidden = false
|
||||||
installButton.textContent = 'Open installer'
|
installButton.textContent = bundleType === BundleType.Deb ? 'Install & restart' : 'Open installer'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -702,29 +790,56 @@ async function installAvailableUpdate() {
|
|||||||
const button = el<HTMLButtonElement>('install-update')
|
const button = el<HTMLButtonElement>('install-update')
|
||||||
const progress = el<HTMLDivElement>('update-progress')
|
const progress = el<HTMLDivElement>('update-progress')
|
||||||
const bar = progress.querySelector('span') as HTMLSpanElement
|
const bar = progress.querySelector('span') as HTMLSpanElement
|
||||||
|
const wasRunning = Boolean(status?.running)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
updateBusy = true
|
updateBusy = true
|
||||||
button.disabled = true
|
button.disabled = true
|
||||||
progress.hidden = false
|
|
||||||
bar.style.width = '8%'
|
|
||||||
let downloaded = 0
|
|
||||||
let total = 0
|
|
||||||
|
|
||||||
const signedUpdate = availableUpdate
|
|
||||||
if (!signedUpdate) return
|
|
||||||
|
|
||||||
try {
|
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}…`
|
state.textContent = `Downloading FieldLens Print ${signedUpdate.version}…`
|
||||||
await signedUpdate.downloadAndInstall((event) => {
|
await signedUpdate.downloadAndInstall((event) => {
|
||||||
if (event.event === 'Started') {
|
if (event.event === 'Started') {
|
||||||
@ -747,6 +862,7 @@ async function installAvailableUpdate() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
state.textContent = `Update failed: ${String(error)}`
|
state.textContent = `Update failed: ${String(error)}`
|
||||||
progress.hidden = true
|
progress.hidden = true
|
||||||
|
if (wasRunning) await ensureCaptureStarted().catch(() => undefined)
|
||||||
} finally {
|
} finally {
|
||||||
updateBusy = false
|
updateBusy = false
|
||||||
button.disabled = false
|
button.disabled = false
|
||||||
@ -801,6 +917,7 @@ config = await invoke<AppConfig>('get_config')
|
|||||||
writeForm(config)
|
writeForm(config)
|
||||||
await refreshPrinters()
|
await refreshPrinters()
|
||||||
await ensureCaptureStarted()
|
await ensureCaptureStarted()
|
||||||
|
void refreshAuthStatus()
|
||||||
|
|
||||||
const readyForRunning = Boolean(
|
const readyForRunning = Boolean(
|
||||||
virtualPrinterStatus?.installed
|
virtualPrinterStatus?.installed
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user