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:
Anthony Stirling
2026-06-02 23:11:37 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 919f0ade99
commit 256d1a86d2
31 changed files with 3602 additions and 488 deletions
@@ -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 (0100). 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),
}
}
}
}
}
+15
View File
@@ -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")