//! Signed updates from storebound.dev. The updater plugin checks `latest.json` of the chosen channel, downloads the //! platform's bundle and refuses it unless its minisign signature matches the public key in tauri.conf.json (the //! private key never leaves the release machine, ~/.storebound-keys). Nothing installs without the owner's click in //! a native dialog (commands::update_install), or by the background updater (lib.rs auto_update). Pure parts here so tests can pin them. use std::sync::atomic::{AtomicBool, Ordering}; use url::Url; /// Set when Settings > Updates switched the channel: the background updater checks on its next tick. pub static KICK: AtomicBool = AtomicBool::new(false); pub fn kick() { KICK.store(true, Ordering::SeqCst); } pub fn take_kick() -> bool { KICK.swap(false, Ordering::SeqCst) } /// `latest.json` of each channel (static files on storebound.dev, published by tools/desktop-release.ts). pub const STABLE: &str = "https://storebound.dev/desktop/latest.json"; pub const BETA: &str = "https://storebound.dev/desktop/beta/latest.json"; /// Every push to main (Forgejo ci `dev` job, signed on the VDS): `-dev.`. pub const DEV: &str = "https://storebound.dev/desktop/dev/latest.json"; /// The channels Settings > Updates accepts (beta is kept for saved states; the window offers stable and dev). pub const CHANNELS: &[&str] = &["stable", "dev", "beta"]; /// The endpoint of a channel (Settings > Updates); anything unknown is the stable channel. pub fn endpoint(channel: &str) -> &'static str { match channel { "beta" => BETA, "dev" => DEV, _ => STABLE, } } /// How often the background updater asks the channel (plus once at start and right after a resume from sleep). pub const CHECK_EVERY_SECS: u64 = 15 * 60; /// The background loop's tick; a wall-clock jump of more than this plus a minute between two ticks is a resume. pub const TICK_SECS: u64 = 5; /// An install waits while the main window is visible and focused, at most this many checks in a row (so an app that /// stays open still updates). pub const MAX_DEFERS: u32 = 4; /// A semver version as (major, minor, patch) and its prerelease identifiers; None when it is not one. fn parse(v: &str) -> Option<([u64; 3], Vec)> { let v = v.trim().trim_start_matches('v'); let v = v.split('+').next()?; let (core, pre) = match v.split_once('-') { Some((c, p)) => (c, p.split('.').map(str::to_string).collect::>()), None => (v, Vec::new()), }; let mut n = core.split('.').map(|x| x.parse::().ok()); let r = [n.next()??, n.next()??, n.next()??]; if n.next().is_some() || pre.iter().any(String::is_empty) { return None; } Some((r, pre)) } /// semver 2.0 precedence, a > b: a release is above its own prereleases (0.8.48 > 0.8.48-dev.20261023120000), numeric /// identifiers compare as numbers and sort below alphanumeric ones, a longer prerelease is above its prefix. pub fn newer(a: &str, b: &str) -> bool { use std::cmp::Ordering::{Equal, Greater, Less}; let (Some((ac, ap)), Some((bc, bp))) = (parse(a), parse(b)) else { return false }; match ac.cmp(&bc) { Greater => return true, Less => return false, Equal => {} } match (ap.is_empty(), bp.is_empty()) { (true, true) | (false, true) => return false, (true, false) => return true, _ => {} } for (x, y) in ap.iter().zip(bp.iter()) { let o = match (x.parse::(), y.parse::()) { (Ok(i), Ok(j)) => i.cmp(&j), (Ok(_), Err(_)) => Less, (Err(_), Ok(_)) => Greater, _ => x.cmp(y), }; if o != Equal { return o == Greater; } } ap.len() > bp.len() } /// The no-downgrade rule: an update installs only when the channel's version is strictly newer than this build. A dev /// install switched back to stable stays on its dev build until a stable release passes it. pub fn should_install(current: &str, offered: &str) -> bool { newer(offered, current) } /// The background updater defers an install while the owner works in the main window (visible and focused), at most /// MAX_DEFERS checks in a row. A hidden window can report focus, so only a visible one counts. pub fn defer_install(visible: bool, focused: bool, defers_so_far: u32) -> bool { visible && focused && defers_so_far < MAX_DEFERS } /// Whether the background updater asks the channel now: the first tick, a kick (channel switched), a resume from sleep /// (the wall clock moved much further than one tick), or CHECK_EVERY_SECS since the last check. pub fn check_due(first: bool, kicked: bool, wall_gap_secs: u64, since_check_secs: u64) -> bool { first || kicked || wall_gap_secs > TICK_SECS + 60 || since_check_secs >= CHECK_EVERY_SECS } /// Only storebound.dev over HTTPS, without port or user info, may serve updates. pub fn endpoint_allowed(url: &Url) -> bool { url.scheme() == "https" && url.host_str() == Some("storebound.dev") && url.port().is_none() && url.username().is_empty() && url.password().is_none() && url.path().starts_with("/desktop/") } /// The updater's error in words the owner can act on. pub fn error_text(e: &str) -> String { let l = e.to_lowercase(); if l.contains("signature") { "the update's signature does not match this app's key: not installed".into() } else if l.contains("404") || l.contains("not found") { "no update information on storebound.dev yet".into() } else if l.contains("platform") { "no update for this system in the latest release yet".into() } else { format!("update check failed: {e}") } }