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
+170
View File
@@ -75,6 +75,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arrayvec"
version = "0.7.6"
@@ -899,6 +908,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -1253,6 +1273,16 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -2467,6 +2497,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2762,6 +2798,18 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-pdf-kit"
version = "0.3.2"
@@ -2874,6 +2922,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -3686,15 +3748,20 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -3863,6 +3930,33 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
@@ -4429,6 +4523,7 @@ dependencies = [
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tiny_http",
"tokio",
@@ -4640,6 +4735,17 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -4979,6 +5085,39 @@ dependencies = [
"tracing",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest 0.13.2",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]]
name = "tauri-plugin-window-state"
version = "2.4.1"
@@ -5928,6 +6067,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
@@ -6625,6 +6773,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.1"
@@ -6789,6 +6947,18 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.13.0",
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.21"
+1
View File
@@ -35,6 +35,7 @@ tauri-plugin-store = "2.4.2"
tauri-plugin-opener = "2.5.3"
tauri-plugin-deep-link = "2.4.6"
tauri-plugin-notification = "2.3.3"
tauri-plugin-updater = "2"
tauri-plugin-window-state = "2.2.1"
keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] }
tokio = { version = "1.50", features = ["time", "sync"] }
@@ -58,6 +58,11 @@
"notification:allow-is-permission-granted",
"window-state:allow-filename",
"window-state:allow-restore-state",
"window-state:allow-save-window-state"
"window-state:allow-save-window-state",
"updater:default",
"updater:allow-check",
"updater:allow-download",
"updater:allow-download-and-install",
"updater:allow-install"
]
}
@@ -6,14 +6,37 @@ use std::path::PathBuf;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProvisioningConfig<'a> {
server_url: &'a str,
lock_connection_mode: bool,
#[serde(skip_serializing_if = "Option::is_none")]
server_url: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
lock_connection_mode: Option<bool>,
/// Optional headless-install update policy.
/// One of `"prompt"` (default), `"auto"`, or `"disabled"`.
#[serde(skip_serializing_if = "Option::is_none")]
update_mode: Option<&'a str>,
}
fn parse_bool(value: &str) -> bool {
matches!(
value.trim().to_lowercase().as_str(),
"1" | "true" | "yes" | "y"
)
}
/// Normalise the `--update-mode` argument into the lowercase tokens the app
/// understands. Empty / whitespace values are treated as "not supplied" so
/// MSI installs that don't pass STIRLING_UPDATE_MODE behave identically to
/// earlier builds.
fn parse_update_mode(value: &str) -> Result<Option<&'static str>, String> {
match value.trim().to_lowercase().as_str() {
"1" | "true" | "yes" | "y" => true,
_ => false,
"" => Ok(None),
"prompt" => Ok(Some("prompt")),
"auto" => Ok(Some("auto")),
"disabled" | "off" | "none" => Ok(Some("disabled")),
other => Err(format!(
"Invalid --update-mode value '{}': expected prompt, auto, or disabled",
other
)),
}
}
@@ -21,6 +44,7 @@ fn main() -> Result<(), String> {
let mut output: Option<PathBuf> = None;
let mut url: Option<String> = None;
let mut lock_value: Option<String> = None;
let mut update_mode_arg: Option<String> = None;
let mut args = env::args().skip(1);
while let Some(arg) = args.next() {
@@ -43,6 +67,12 @@ fn main() -> Result<(), String> {
.ok_or_else(|| "--lock requires a value".to_string())?;
lock_value = Some(value);
}
"--update-mode" => {
let value = args
.next()
.ok_or_else(|| "--update-mode requires a value".to_string())?;
update_mode_arg = Some(value);
}
_ => {
return Err(format!("Unknown argument: {}", arg));
}
@@ -50,11 +80,28 @@ fn main() -> Result<(), String> {
}
let output = output.ok_or_else(|| "Missing --output".to_string())?;
let url = url
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| "Missing --url".to_string())?;
let lock = lock_value.as_deref().map(parse_bool).unwrap_or(false);
.filter(|value| !value.is_empty());
let update_mode = update_mode_arg
.as_deref()
.map(parse_update_mode)
.transpose()?
.flatten();
// Nothing to write — avoid clobbering an existing provisioning file when
// the MSI is invoked without any of STIRLING_SERVER_URL / STIRLING_UPDATE_MODE.
if url.is_none() && update_mode.is_none() {
return Ok(());
}
let lock = if url.is_some() {
Some(lock_value.as_deref().map(parse_bool).unwrap_or(false))
} else {
None
};
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)
@@ -62,8 +109,9 @@ fn main() -> Result<(), String> {
}
let config = ProvisioningConfig {
server_url: url.as_str(),
server_url: url.as_deref(),
lock_connection_mode: lock,
update_mode,
};
let json = serde_json::to_string_pretty(&config)
@@ -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")
+10
View File
@@ -83,6 +83,16 @@
"desktop": {
"schemes": ["stirlingpdf"]
}
},
"updater": {
"endpoints": [
"https://github.com/Stirling-Tools/Stirling-PDF/releases/latest/download/latest.json"
],
"dialog": false,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDZEREM2QTgwNjdGNzdCOUQKUldTZGUvZG5nR3JjYmZ4TlZ3YVpHTVIzd2hBT2c4c000RDNIN2tLZENEblE4ZXFhU3J5V0lQanEK",
"windows": {
"installMode": "passive"
}
}
}
}
@@ -3,6 +3,13 @@
<Fragment>
<Property Id="STIRLING_SERVER_URL" Secure="yes" />
<Property Id="STIRLING_LOCK_CONNECTION" Secure="yes" />
<!--
STIRLING_UPDATE_MODE — headless auto-update policy for MDM/Intune deploys.
Accepts "prompt" (default, user-interactive), "auto" (silent download+install on
startup), or "disabled" (never check). Leaving it unset preserves whatever was
configured on the previous install (or defaults to "prompt" on a fresh install).
-->
<Property Id="STIRLING_UPDATE_MODE" Secure="yes" />
<DirectoryRef Id="TARGETDIR">
<Directory Id="AppDataFolder" />
@@ -64,20 +71,24 @@
<CustomAction
Id="SetWriteProvisioningFilePerUser"
Property="WriteProvisioningFilePerUser"
Value="--output &quot;[AppDataFolder]Stirling-PDF\stirling-provisioning.json&quot; --url &quot;[STIRLING_SERVER_URL]&quot; --lock &quot;[STIRLING_LOCK_CONNECTION]&quot;"
Value="--output &quot;[AppDataFolder]Stirling-PDF\stirling-provisioning.json&quot; --url &quot;[STIRLING_SERVER_URL]&quot; --lock &quot;[STIRLING_LOCK_CONNECTION]&quot; --update-mode &quot;[STIRLING_UPDATE_MODE]&quot;"
/>
<CustomAction
Id="SetWriteProvisioningFileAllUsers"
Property="WriteProvisioningFileAllUsers"
Value="--output &quot;[CommonAppDataFolder]Stirling-PDF\stirling-provisioning.json&quot; --url &quot;[STIRLING_SERVER_URL]&quot; --lock &quot;[STIRLING_LOCK_CONNECTION]&quot;"
Value="--output &quot;[CommonAppDataFolder]Stirling-PDF\stirling-provisioning.json&quot; --url &quot;[STIRLING_SERVER_URL]&quot; --lock &quot;[STIRLING_LOCK_CONNECTION]&quot; --update-mode &quot;[STIRLING_UPDATE_MODE]&quot;"
/>
<!--
Run the provisioner when EITHER STIRLING_SERVER_URL or STIRLING_UPDATE_MODE is set.
The provisioner is a no-op if both values end up empty, so passing neither is safe.
-->
<InstallExecuteSequence>
<Custom Action="SetWriteProvisioningFilePerUser" After="InstallFiles">STIRLING_SERVER_URL &lt;&gt; &quot;&quot; AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
<Custom Action="WriteProvisioningFilePerUser" After="SetWriteProvisioningFilePerUser">STIRLING_SERVER_URL &lt;&gt; &quot;&quot; AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
<Custom Action="SetWriteProvisioningFileAllUsers" After="InstallFiles">STIRLING_SERVER_URL &lt;&gt; &quot;&quot; AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
<Custom Action="WriteProvisioningFileAllUsers" After="SetWriteProvisioningFileAllUsers">STIRLING_SERVER_URL &lt;&gt; &quot;&quot; AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
<Custom Action="SetWriteProvisioningFilePerUser" After="InstallFiles">(STIRLING_SERVER_URL &lt;&gt; &quot;&quot; OR STIRLING_UPDATE_MODE &lt;&gt; &quot;&quot;) AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
<Custom Action="WriteProvisioningFilePerUser" After="SetWriteProvisioningFilePerUser">(STIRLING_SERVER_URL &lt;&gt; &quot;&quot; OR STIRLING_UPDATE_MODE &lt;&gt; &quot;&quot;) AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
<Custom Action="SetWriteProvisioningFileAllUsers" After="InstallFiles">(STIRLING_SERVER_URL &lt;&gt; &quot;&quot; OR STIRLING_UPDATE_MODE &lt;&gt; &quot;&quot;) AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
<Custom Action="WriteProvisioningFileAllUsers" After="SetWriteProvisioningFileAllUsers">(STIRLING_SERVER_URL &lt;&gt; &quot;&quot; OR STIRLING_UPDATE_MODE &lt;&gt; &quot;&quot;) AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
</InstallExecuteSequence>
</Fragment>
</Wix>