mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
UI changes to update and support auto updating (#6075)
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
919f0ade99
commit
256d1a86d2
@@ -15,8 +15,48 @@ const FIRST_LAUNCH_KEY: &str = "setup_completed";
|
||||
const CONNECTION_MODE_KEY: &str = "connection_mode";
|
||||
const SERVER_CONFIG_KEY: &str = "server_config";
|
||||
const LOCK_CONNECTION_KEY: &str = "lock_connection_mode";
|
||||
pub(crate) const UPDATE_MODE_KEY: &str = "update_mode";
|
||||
/// When `true` the update mode was written by a provisioning file and cannot
|
||||
/// be changed from the UI. Only another provisioning file (from MDM) can
|
||||
/// override it. We track this separately from `lock_connection_mode` because
|
||||
/// an admin may want to lock updates without locking the connection URL,
|
||||
/// or vice versa.
|
||||
pub(crate) const UPDATE_MODE_LOCKED_KEY: &str = "update_mode_locked";
|
||||
const PROVISIONING_FILE_NAME: &str = "stirling-provisioning.json";
|
||||
|
||||
/// How the desktop auto-updater should behave on startup.
|
||||
///
|
||||
/// * `Prompt` – default. Show the update popup when a new version is available
|
||||
/// and let the user decide whether to install.
|
||||
/// * `Auto` – silently download and install updates on startup, then restart.
|
||||
/// Intended for managed deployments (Intune/MDM) where the user
|
||||
/// cannot (or should not) be prompted.
|
||||
/// * `Disabled` – never check for updates, never show the update UI. Administrators
|
||||
/// are expected to push updates through their normal packaging flow.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum UpdateMode {
|
||||
Prompt,
|
||||
Auto,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
impl Default for UpdateMode {
|
||||
fn default() -> Self {
|
||||
UpdateMode::Prompt
|
||||
}
|
||||
}
|
||||
|
||||
/// Current update mode plus whether the UI is allowed to change it. Returned
|
||||
/// by [`get_update_mode`] so the settings page can show a "managed by
|
||||
/// administrator" hint instead of silently ignoring clicks.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateModeInfo {
|
||||
pub mode: UpdateMode,
|
||||
pub locked: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ConnectionConfig {
|
||||
pub mode: ConnectionMode,
|
||||
@@ -142,6 +182,9 @@ pub async fn set_connection_mode(
|
||||
struct ProvisioningConfig {
|
||||
server_url: Option<String>,
|
||||
lock_connection_mode: Option<bool>,
|
||||
/// Optional headless-install update policy (`"prompt"`, `"auto"`, `"disabled"`).
|
||||
/// When omitted the existing stored mode is left unchanged.
|
||||
update_mode: Option<UpdateMode>,
|
||||
}
|
||||
|
||||
fn provisioning_file_paths() -> Vec<PathBuf> {
|
||||
@@ -155,6 +198,23 @@ fn provisioning_file_paths() -> Vec<PathBuf> {
|
||||
paths
|
||||
}
|
||||
|
||||
/// A provisioning file should only lock the update-mode UI when it was placed
|
||||
/// somewhere that requires administrator rights to write to — i.e. the
|
||||
/// system-wide provisioning dir written by MSI/Intune. A file in the per-user
|
||||
/// `app_data_dir()` is just a user dropping JSON in their own profile; locking
|
||||
/// the UI based on that would let any local user permanently disable the
|
||||
/// Settings selector for themselves with no way back, because the file is
|
||||
/// deleted after apply but the lock flag persists in the store.
|
||||
pub(crate) fn provisioning_path_is_admin_owned(
|
||||
provisioning_path: &std::path::Path,
|
||||
system_dir: Option<&std::path::Path>,
|
||||
) -> bool {
|
||||
match system_dir {
|
||||
Some(dir) => provisioning_path.starts_with(dir),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_provisioning_if_present(app_handle: &AppHandle) -> Result<(), String> {
|
||||
let provisioning_paths = provisioning_file_paths();
|
||||
let provisioning_path = provisioning_paths
|
||||
@@ -181,8 +241,10 @@ pub fn apply_provisioning_if_present(app_handle: &AppHandle) -> Result<(), Strin
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
if server_url.is_none() {
|
||||
add_log("⚠️ Provisioning file missing serverUrl; skipping apply".to_string());
|
||||
if server_url.is_none() && parsed.update_mode.is_none() {
|
||||
add_log(
|
||||
"⚠️ Provisioning file has neither serverUrl nor updateMode; skipping apply".to_string(),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -192,36 +254,68 @@ pub fn apply_provisioning_if_present(app_handle: &AppHandle) -> Result<(), Strin
|
||||
.store(STORE_FILE)
|
||||
.map_err(|e| format!("Failed to access store: {}", e))?;
|
||||
|
||||
store.set(
|
||||
CONNECTION_MODE_KEY,
|
||||
serde_json::to_value(&ConnectionMode::SelfHosted)
|
||||
.map_err(|e| format!("Failed to serialize mode: {}", e))?,
|
||||
);
|
||||
// Apply server URL / connection settings only when a URL was supplied — a
|
||||
// provisioning file containing just `updateMode` should be allowed to configure
|
||||
// the headless update policy without forcing self-hosted mode.
|
||||
let server_config = if let Some(url) = server_url {
|
||||
store.set(
|
||||
CONNECTION_MODE_KEY,
|
||||
serde_json::to_value(&ConnectionMode::SelfHosted)
|
||||
.map_err(|e| format!("Failed to serialize mode: {}", e))?,
|
||||
);
|
||||
|
||||
let server_config = ServerConfig {
|
||||
url: server_url.clone().unwrap(),
|
||||
let cfg = ServerConfig { url };
|
||||
store.set(
|
||||
SERVER_CONFIG_KEY,
|
||||
serde_json::to_value(&cfg)
|
||||
.map_err(|e| format!("Failed to serialize config: {}", e))?,
|
||||
);
|
||||
|
||||
store.set(
|
||||
LOCK_CONNECTION_KEY,
|
||||
serde_json::to_value(lock_flag)
|
||||
.map_err(|e| format!("Failed to serialize lock flag: {}", e))?,
|
||||
);
|
||||
|
||||
store.set(FIRST_LAUNCH_KEY, serde_json::json!(true));
|
||||
Some(cfg)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
store.set(
|
||||
SERVER_CONFIG_KEY,
|
||||
serde_json::to_value(&server_config)
|
||||
.map_err(|e| format!("Failed to serialize config: {}", e))?,
|
||||
);
|
||||
|
||||
store.set(
|
||||
LOCK_CONNECTION_KEY,
|
||||
serde_json::to_value(lock_flag)
|
||||
.map_err(|e| format!("Failed to serialize lock flag: {}", e))?,
|
||||
);
|
||||
|
||||
store.set(FIRST_LAUNCH_KEY, serde_json::json!(true));
|
||||
if let Some(mode) = parsed.update_mode {
|
||||
store.set(
|
||||
UPDATE_MODE_KEY,
|
||||
serde_json::to_value(&mode)
|
||||
.map_err(|e| format!("Failed to serialize update mode: {}", e))?,
|
||||
);
|
||||
// Only lock the UI when the provisioning file came from a path that
|
||||
// requires admin rights to write — i.e. the system provisioning dir
|
||||
// populated by MSI/Intune. A user dropping a file in their own
|
||||
// `app_data_dir` must NOT lock themselves out of the Settings
|
||||
// selector permanently (the file is deleted after apply, but the
|
||||
// lock flag persists in the store).
|
||||
let system_dir = system_provisioning_dir();
|
||||
let locked = provisioning_path_is_admin_owned(
|
||||
&provisioning_path,
|
||||
system_dir.as_deref(),
|
||||
);
|
||||
store.set(UPDATE_MODE_LOCKED_KEY, serde_json::json!(locked));
|
||||
add_log(format!(
|
||||
"🧩 Provisioning set update mode to {:?} (locked={})",
|
||||
mode, locked
|
||||
));
|
||||
}
|
||||
|
||||
store
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save store: {}", e))?;
|
||||
|
||||
if let Ok(mut conn_state) = app_handle.state::<AppConnectionState>().0.lock() {
|
||||
if let (Some(cfg), Ok(mut conn_state)) =
|
||||
(server_config.as_ref(), app_handle.state::<AppConnectionState>().0.lock())
|
||||
{
|
||||
conn_state.mode = ConnectionMode::SelfHosted;
|
||||
conn_state.server_config = Some(server_config);
|
||||
conn_state.server_config = Some(cfg.clone());
|
||||
conn_state.lock_connection_mode = lock_flag;
|
||||
}
|
||||
|
||||
@@ -255,6 +349,78 @@ pub async fn is_first_launch(app_handle: AppHandle) -> Result<bool, String> {
|
||||
Ok(!setup_completed)
|
||||
}
|
||||
|
||||
/// Read the configured update mode from the tauri store.
|
||||
///
|
||||
/// Returns [`UpdateMode::Prompt`] when the store is unavailable or no mode
|
||||
/// has been set — the prompt-the-user flow is the safe default for normal,
|
||||
/// non-managed installs.
|
||||
pub(crate) fn read_update_mode(app_handle: &AppHandle) -> UpdateMode {
|
||||
read_update_mode_info(app_handle).mode
|
||||
}
|
||||
|
||||
/// Read the configured update mode AND whether it's locked by provisioning.
|
||||
pub(crate) fn read_update_mode_info(app_handle: &AppHandle) -> UpdateModeInfo {
|
||||
match app_handle.store(STORE_FILE) {
|
||||
Ok(store) => {
|
||||
let mode = store
|
||||
.get(UPDATE_MODE_KEY)
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
let locked = store
|
||||
.get(UPDATE_MODE_LOCKED_KEY)
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
UpdateModeInfo { mode, locked }
|
||||
}
|
||||
Err(_) => UpdateModeInfo {
|
||||
mode: UpdateMode::default(),
|
||||
locked: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_update_mode(app_handle: AppHandle) -> Result<UpdateModeInfo, String> {
|
||||
Ok(read_update_mode_info(&app_handle))
|
||||
}
|
||||
|
||||
/// Update the stored update mode from the UI.
|
||||
///
|
||||
/// Refuses to overwrite a provisioned (locked) value so an MDM-managed
|
||||
/// deployment can't be subverted by a user clicking in Settings.
|
||||
#[tauri::command]
|
||||
pub async fn set_update_mode(
|
||||
app_handle: AppHandle,
|
||||
mode: UpdateMode,
|
||||
) -> Result<(), String> {
|
||||
let store = app_handle
|
||||
.store(STORE_FILE)
|
||||
.map_err(|e| format!("Failed to access store: {}", e))?;
|
||||
|
||||
let locked = store
|
||||
.get(UPDATE_MODE_LOCKED_KEY)
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if locked {
|
||||
add_log(format!(
|
||||
"⚠️ set_update_mode({:?}) rejected — mode is locked by provisioning",
|
||||
mode
|
||||
));
|
||||
return Err("Update mode is locked by your administrator".to_string());
|
||||
}
|
||||
|
||||
store.set(
|
||||
UPDATE_MODE_KEY,
|
||||
serde_json::to_value(&mode)
|
||||
.map_err(|e| format!("Failed to serialize update mode: {}", e))?,
|
||||
);
|
||||
store
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save store: {}", e))?;
|
||||
add_log(format!("⚙️ User set update mode to {:?}", mode));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reset_setup_completion(app_handle: AppHandle) -> Result<(), String> {
|
||||
log::info!("Resetting setup completion flag");
|
||||
@@ -273,3 +439,105 @@ pub async fn reset_setup_completion(app_handle: AppHandle) -> Result<(), String>
|
||||
log::info!("Setup completion flag reset successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// Windows-path tests are cfg-gated because `Path::starts_with` is
|
||||
// component-wise: on Linux, `"C:\\foo\\bar"` is a SINGLE path component
|
||||
// (since backslash is a literal character there, not a separator) so
|
||||
// the prefix never matches the way it would on Windows.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn user_app_data_provisioning_does_not_lock_ui() {
|
||||
// A user dropping a provisioning file in their own roaming AppData
|
||||
// (per-user, user-writable) must NOT permanently lock the update-mode
|
||||
// selector. The file is deleted after apply but the lock flag would
|
||||
// persist in the store, locking the user out with no way back.
|
||||
let user_path = PathBuf::from(
|
||||
"C:\\Users\\alice\\AppData\\Roaming\\Stirling-PDF\\stirling-provisioning.json",
|
||||
);
|
||||
let system_dir = PathBuf::from("C:\\ProgramData\\Stirling-PDF");
|
||||
|
||||
assert!(!provisioning_path_is_admin_owned(
|
||||
&user_path,
|
||||
Some(&system_dir),
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn system_provisioning_dir_does_lock_ui() {
|
||||
// A provisioning file in ProgramData\Stirling-PDF (or /Library, /etc)
|
||||
// requires admin/root rights to write — those locations are how MSI
|
||||
// and Intune deliver policy — so locking the UI here is correct.
|
||||
let system_path = PathBuf::from(
|
||||
"C:\\ProgramData\\Stirling-PDF\\stirling-provisioning.json",
|
||||
);
|
||||
let system_dir = PathBuf::from("C:\\ProgramData\\Stirling-PDF");
|
||||
|
||||
assert!(provisioning_path_is_admin_owned(
|
||||
&system_path,
|
||||
Some(&system_dir),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_etc_provisioning_does_lock_ui() {
|
||||
let system_path = PathBuf::from("/etc/stirling-pdf/stirling-provisioning.json");
|
||||
let system_dir = PathBuf::from("/etc/stirling-pdf");
|
||||
assert!(provisioning_path_is_admin_owned(
|
||||
&system_path,
|
||||
Some(&system_dir),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_home_config_does_not_lock_ui() {
|
||||
let user_path =
|
||||
PathBuf::from("/home/alice/.config/Stirling-PDF/stirling-provisioning.json");
|
||||
let system_dir = PathBuf::from("/etc/stirling-pdf");
|
||||
assert!(!provisioning_path_is_admin_owned(
|
||||
&user_path,
|
||||
Some(&system_dir),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_library_provisioning_does_lock_ui() {
|
||||
let system_path = PathBuf::from(
|
||||
"/Library/Application Support/Stirling-PDF/stirling-provisioning.json",
|
||||
);
|
||||
let system_dir =
|
||||
PathBuf::from("/Library/Application Support/Stirling-PDF");
|
||||
assert!(provisioning_path_is_admin_owned(
|
||||
&system_path,
|
||||
Some(&system_dir),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_user_library_does_not_lock_ui() {
|
||||
let user_path = PathBuf::from(
|
||||
"/Users/alice/Library/Application Support/Stirling-PDF/stirling-provisioning.json",
|
||||
);
|
||||
let system_dir =
|
||||
PathBuf::from("/Library/Application Support/Stirling-PDF");
|
||||
assert!(!provisioning_path_is_admin_owned(
|
||||
&user_path,
|
||||
Some(&system_dir),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_system_dir_means_no_lock() {
|
||||
// Defensive: when the platform has no defined system_provisioning_dir,
|
||||
// refuse to lock — the user-AppData file is the only thing we'd be
|
||||
// matching against, and that's the case we explicitly want to leave
|
||||
// unlocked.
|
||||
let user_path = PathBuf::from("/home/alice/.config/Stirling-PDF/stirling-provisioning.json");
|
||||
assert!(!provisioning_path_is_admin_owned(&user_path, None));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod auth;
|
||||
pub mod default_app;
|
||||
pub mod platform;
|
||||
pub mod print;
|
||||
pub mod updater;
|
||||
pub mod window;
|
||||
|
||||
pub use backend::{cleanup_backend, get_backend_port, start_backend};
|
||||
@@ -19,9 +20,11 @@ pub use window::{
|
||||
};
|
||||
pub use connection::{
|
||||
get_connection_config,
|
||||
get_update_mode,
|
||||
is_first_launch,
|
||||
reset_setup_completion,
|
||||
set_connection_mode,
|
||||
set_update_mode,
|
||||
};
|
||||
pub use auth::{
|
||||
clear_auth_token,
|
||||
@@ -39,3 +42,7 @@ pub use auth::{
|
||||
pub use default_app::{is_default_pdf_handler, set_as_default_pdf_handler};
|
||||
pub use platform::get_desktop_os;
|
||||
pub use print::print_pdf_file_native;
|
||||
pub use updater::{
|
||||
can_install_updates, check_for_update, download_and_install_update, get_app_version,
|
||||
restart_app,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
use crate::commands::connection::{read_update_mode, UpdateMode};
|
||||
use crate::utils::add_log;
|
||||
|
||||
/// Information about an available update.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateInfo {
|
||||
/// The new version string (e.g. "2.8.0").
|
||||
pub version: String,
|
||||
/// The currently installed version string.
|
||||
pub current_version: String,
|
||||
/// Release notes / changelog, if provided by the update endpoint.
|
||||
pub release_notes: Option<String>,
|
||||
}
|
||||
|
||||
/// Progress payload emitted as `update-download-progress` events during download.
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateProgress {
|
||||
/// Total bytes downloaded so far.
|
||||
pub downloaded: u64,
|
||||
/// Total bytes to download, if known.
|
||||
pub total: Option<u64>,
|
||||
/// Download percentage (0–100). Zero when total is unknown.
|
||||
pub percent: f64,
|
||||
}
|
||||
|
||||
/// Check whether a newer version is available.
|
||||
///
|
||||
/// Returns `None` when the app is up-to-date or when the check cannot be
|
||||
/// performed (e.g. no network, endpoint misconfigured). Errors are logged
|
||||
/// internally so callers do not need to surface them to the user.
|
||||
#[tauri::command]
|
||||
pub async fn check_for_update(app: AppHandle) -> Result<Option<UpdateInfo>, String> {
|
||||
// Managed deployments may have updates disabled — never hit the endpoint in
|
||||
// that case so there's no network traffic and no way for the UI to discover
|
||||
// an update that would go unused.
|
||||
if read_update_mode(&app) == UpdateMode::Disabled {
|
||||
add_log("🔕 Update check skipped — update mode is disabled".to_string());
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
add_log("🔍 Checking for updates...".to_string());
|
||||
|
||||
let current_version = app.package_info().version.to_string();
|
||||
|
||||
let updater = match app.updater() {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
add_log(format!("⚠️ Updater not available (plugin not configured?): {}", e));
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
match updater.check().await {
|
||||
Ok(Some(update)) => {
|
||||
add_log(format!("✅ Update available: {} → {}", current_version, update.version));
|
||||
Ok(Some(UpdateInfo {
|
||||
version: update.version.clone(),
|
||||
current_version,
|
||||
release_notes: update.body.clone(),
|
||||
}))
|
||||
}
|
||||
Ok(None) => {
|
||||
add_log("✅ App is up to date".to_string());
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => {
|
||||
// Surface as Ok(None) so the frontend never shows a noisy error banner
|
||||
// on a transient network failure during startup.
|
||||
add_log(format!("⚠️ Update check failed: {}", e));
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Download and install the latest available update.
|
||||
///
|
||||
/// Emits three events on the `AppHandle`:
|
||||
/// * `update-download-progress` – [`UpdateProgress`] payload, sent for each
|
||||
/// received chunk.
|
||||
/// * `update-download-finished` – empty payload once the download is complete
|
||||
/// and the installer has been written to disk.
|
||||
/// * `update-ready-to-restart` – empty payload once the in-process install
|
||||
/// step has completed and the app can be safely restarted.
|
||||
///
|
||||
/// Returns `Err` if the update cannot be found, downloaded, or installed so
|
||||
/// the frontend can fall back to opening the download page.
|
||||
#[tauri::command]
|
||||
pub async fn download_and_install_update(app: AppHandle) -> Result<(), String> {
|
||||
add_log("📥 Starting update download and install...".to_string());
|
||||
|
||||
let updater = app.updater().map_err(|e| {
|
||||
let msg = format!("Updater plugin unavailable: {}", e);
|
||||
add_log(format!("⚠️ {}", msg));
|
||||
msg
|
||||
})?;
|
||||
|
||||
let update = updater
|
||||
.check()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "No update is currently available".to_string())?;
|
||||
|
||||
add_log(format!("📥 Downloading version {}...", update.version));
|
||||
|
||||
let downloaded = Arc::new(Mutex::new(0u64));
|
||||
let app_progress = app.clone();
|
||||
let app_finish = app.clone();
|
||||
|
||||
update
|
||||
.download_and_install(
|
||||
move |chunk_length, content_length| {
|
||||
let mut dl = downloaded.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*dl += chunk_length as u64;
|
||||
let current = *dl;
|
||||
let percent = content_length
|
||||
.map(|total| (current as f64 / total as f64) * 100.0)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let _ = app_progress.emit(
|
||||
"update-download-progress",
|
||||
UpdateProgress {
|
||||
downloaded: current,
|
||||
total: content_length,
|
||||
percent,
|
||||
},
|
||||
);
|
||||
},
|
||||
move || {
|
||||
add_log("✅ Download finished, applying update...".to_string());
|
||||
let _ = app_finish.emit("update-download-finished", ());
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let msg = format!("Update installation failed: {}", e);
|
||||
add_log(format!("❌ {}", msg));
|
||||
msg
|
||||
})?;
|
||||
|
||||
add_log("✅ Update installed — waiting for restart".to_string());
|
||||
let _ = app.emit("update-ready-to-restart", ());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restart the application to apply an already-installed update.
|
||||
///
|
||||
/// This function never returns — the process is replaced by the new version.
|
||||
#[tauri::command]
|
||||
pub fn restart_app(app: AppHandle) {
|
||||
add_log("🔄 Restarting app to apply update...".to_string());
|
||||
app.restart();
|
||||
}
|
||||
|
||||
/// Return the currently running application version string.
|
||||
#[tauri::command]
|
||||
pub fn get_app_version(app: AppHandle) -> String {
|
||||
app.package_info().version.to_string()
|
||||
}
|
||||
|
||||
/// Result of [`can_install_updates`] — does this process have the permissions
|
||||
/// needed to run the Tauri updater's MSI/NSIS installer without triggering
|
||||
/// UAC elevation (or another blocker)?
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CanInstallResult {
|
||||
/// `true` when the install directory is writable from the current
|
||||
/// process — the strongest signal that msiexec will succeed without
|
||||
/// needing elevation. `false` means auto-install is very likely to
|
||||
/// trigger a UAC prompt (or be blocked entirely) and should be
|
||||
/// skipped in silent/auto mode.
|
||||
pub can_install: bool,
|
||||
/// Machine-readable reason when `can_install` is `false`. One of
|
||||
/// `"install_dir_not_writable"`, `"install_dir_unknown"`. `None` when
|
||||
/// `can_install` is `true`.
|
||||
pub reason: Option<String>,
|
||||
/// Path we probed so the frontend can include it in the "contact your
|
||||
/// administrator" alert. Best-effort — may be empty on exotic layouts.
|
||||
pub install_dir: Option<String>,
|
||||
}
|
||||
|
||||
/// Check whether the Tauri updater is likely to be able to install a new
|
||||
/// version silently.
|
||||
///
|
||||
/// Tauri's Windows updater runs msiexec against the downloaded MSI, which on
|
||||
/// a per-machine install writes to `C:\Program Files\Stirling-PDF`. If the
|
||||
/// current user can write to that directory without elevation, msiexec will
|
||||
/// succeed silently (passive install mode). If they can't, the install
|
||||
/// triggers a UAC prompt that can't be auto-approved — and for a managed
|
||||
/// (Intune/MDM) deployment the user typically can't satisfy it at all.
|
||||
///
|
||||
/// We test this by writing (and immediately deleting) a zero-byte probe file
|
||||
/// next to the running executable. This avoids trying to reverse-engineer
|
||||
/// Windows token elevation state, which is surprisingly hard to do correctly
|
||||
/// on Windows when UAC filtered tokens are in play.
|
||||
///
|
||||
/// On macOS and Linux the probe-next-to-exe heuristic is misleading:
|
||||
/// `current_exe().parent()` on macOS is `Stirling-PDF.app/Contents/MacOS/`
|
||||
/// which is almost always user-writable even when `/Applications/` is not,
|
||||
/// so the probe returns a false positive. On Linux the AppImage/deb install
|
||||
/// story is different and not gated by this kind of check. We platform-gate
|
||||
/// to Windows and report `can_install: true` elsewhere so the existing
|
||||
/// interactive update flow runs unchanged.
|
||||
#[tauri::command]
|
||||
pub fn can_install_updates() -> CanInstallResult {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
return CanInstallResult {
|
||||
can_install: true,
|
||||
reason: None,
|
||||
install_dir: None,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let exe = match std::env::current_exe() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
add_log(format!("⚠️ can_install_updates: current_exe failed: {}", e));
|
||||
return CanInstallResult {
|
||||
can_install: false,
|
||||
reason: Some("install_dir_unknown".to_string()),
|
||||
install_dir: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
let parent = match exe.parent() {
|
||||
Some(p) => p.to_path_buf(),
|
||||
None => {
|
||||
return CanInstallResult {
|
||||
can_install: false,
|
||||
reason: Some("install_dir_unknown".to_string()),
|
||||
install_dir: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let dir_display = parent.display().to_string();
|
||||
let probe = parent.join(".stirling-auto-update-probe.tmp");
|
||||
match std::fs::File::create(&probe) {
|
||||
Ok(_) => {
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
add_log(format!("✅ can_install_updates: writable: {}", dir_display));
|
||||
CanInstallResult {
|
||||
can_install: true,
|
||||
reason: None,
|
||||
install_dir: Some(dir_display),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
add_log(format!(
|
||||
"⚠️ can_install_updates: install dir not writable ({}): {}",
|
||||
dir_display, e
|
||||
));
|
||||
CanInstallResult {
|
||||
can_install: false,
|
||||
reason: Some("install_dir_not_writable".to_string()),
|
||||
install_dir: Some(dir_display),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,9 +32,16 @@ use commands::{
|
||||
set_connection_mode,
|
||||
set_as_default_pdf_handler,
|
||||
get_desktop_os,
|
||||
get_update_mode,
|
||||
print_pdf_file_native,
|
||||
set_update_mode,
|
||||
start_backend,
|
||||
start_oauth_login,
|
||||
can_install_updates,
|
||||
check_for_update,
|
||||
download_and_install_update,
|
||||
get_app_version,
|
||||
restart_app,
|
||||
target_window_label,
|
||||
MAIN_WINDOW_LABEL,
|
||||
};
|
||||
@@ -79,6 +86,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.manage(AppConnectionState::default())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
@@ -181,6 +189,13 @@ pub fn run() {
|
||||
start_oauth_login,
|
||||
get_desktop_os,
|
||||
print_pdf_file_native,
|
||||
can_install_updates,
|
||||
check_for_update,
|
||||
download_and_install_update,
|
||||
get_app_version,
|
||||
get_update_mode,
|
||||
set_update_mode,
|
||||
restart_app,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
|
||||
Reference in New Issue
Block a user