126 lines
5.6 KiB
Rust
126 lines
5.6 KiB
Rust
//! 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): `<next patch>-dev.<UTC yyyymmddHHMMSS>`.
|
|
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<String>)> {
|
|
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::<Vec<_>>()),
|
|
None => (v, Vec::new()),
|
|
};
|
|
let mut n = core.split('.').map(|x| x.parse::<u64>().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::<u64>(), y.parse::<u64>()) {
|
|
(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}")
|
|
}
|
|
}
|