//! Pure update-scheduling and crash-handshake logic (no Tauri types, so it is unit-tested headless). //! The app owns the updater endpoint, its public key and the manual download URL. use serde::{Deserialize, Serialize}; use std::time::Duration; /// First check after launch. pub const FIRST_CHECK: Duration = Duration::from_secs(15); /// Base interval between checks; each wait is jittered by +/- `JITTER`. pub const CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60); pub const JITTER: Duration = Duration::from_secs(5 * 60); /// After an update, the shell must call `app_ready` within this long. pub const READY_DEADLINE: Duration = Duration::from_secs(30); /// Launches of an updated version without `app_ready` before automatic apply is disabled. pub const MAX_FAILED_BOOTS: u32 = 2; pub const PENDING_FILE: &str = "update-pending.json"; /// Least time between two checks the owner asks for ("Check for updates"): a row clicked again /// and again asks the server once. pub const MANUAL_CHECK_GAP: Duration = Duration::from_secs(10); /// Whether a check the owner asked for may start: never while one is in flight (the timer's or /// an earlier request's), and not within [`MANUAL_CHECK_GAP`] of the last request. A refused /// request answers with the current status; nothing is queued and nothing retries. pub fn may_check_now(in_flight: bool, since_last_request: Option) -> bool { !in_flight && since_last_request.is_none_or(|elapsed| elapsed >= MANUAL_CHECK_GAP) } /// Wait before the next check: 30 min +/- 5 min, from any 64-bit entropy value. pub fn next_check_delay(entropy: u64) -> Duration { let span = JITTER.as_secs() * 2 + 1; CHECK_INTERVAL - JITTER + Duration::from_secs(entropy % span) } /// What the shell's titlebar chip reflects. Only `Ready` is shown prominently. #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(tag = "state", rename_all = "camelCase")] pub enum UpdateStatus { Idle, Checking, UpToDate, Downloading { version: String }, #[serde(rename_all = "camelCase")] Ready { version: String, notes: String }, Installing { version: String }, Error { message: String }, /// The last update failed to start repeatedly; automatic apply is off. #[serde(rename_all = "camelCase")] Problem { download_url: String }, } /// Written just before installing, cleared by `app_ready` in the new version. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PendingUpdate { pub version: String, /// Launches of `version` that have not (yet) confirmed `app_ready`. pub boots: u32, } #[derive(Debug, PartialEq)] pub struct BootDecision { /// Marker to persist (`None` deletes it). pub marker: Option, /// Automatic apply is disabled and the shell is told to offer a manual download. pub problem: bool, } /// Called at every launch with the marker left on disk. pub fn on_boot(pending: Option, current_version: &str) -> BootDecision { match pending { // The installer never replaced the binary (or it was a different release): forget it. Some(p) if p.version != current_version => BootDecision { marker: None, problem: false }, Some(p) => { let boots = p.boots.saturating_add(1); BootDecision { // `boots - 1` earlier launches of this version never reached `app_ready`. problem: boots > MAX_FAILED_BOOTS, marker: Some(PendingUpdate { version: p.version, boots }), } } None => BootDecision { marker: None, problem: false }, } } #[cfg(test)] mod tests { use super::*; #[test] fn a_requested_check_runs_once_and_never_piles_up() { assert!(may_check_now(false, None), "the first request checks"); assert!(!may_check_now(true, None), "not while the timer's check is in flight"); assert!(!may_check_now(true, Some(Duration::from_secs(3600)))); assert!(!may_check_now(false, Some(Duration::from_secs(2))), "a row clicked again asks the server once"); assert!(may_check_now(false, Some(MANUAL_CHECK_GAP))); assert!(may_check_now(false, Some(Duration::from_secs(60)))); } #[test] fn delay_stays_within_jitter() { for entropy in [0, 1, 300, 600, 601, u64::MAX] { let delay = next_check_delay(entropy); assert!(delay >= Duration::from_secs(25 * 60) && delay <= Duration::from_secs(35 * 60)); } assert_eq!(next_check_delay(0), Duration::from_secs(25 * 60)); assert_eq!(next_check_delay(600), Duration::from_secs(35 * 60)); } #[test] fn handshake_disables_apply_after_two_failed_boots() { let pending = |boots| Some(PendingUpdate { version: "0.3.0".into(), boots }); // First launch of the new version: not a problem yet. assert_eq!(on_boot(pending(0), "0.3.0"), BootDecision { marker: pending(1), problem: false }); // Second launch without app_ready: one failure. assert!(!on_boot(pending(1), "0.3.0").problem); // Third launch: two failures in a row. assert!(on_boot(pending(2), "0.3.0").problem); } #[test] fn handshake_forgets_marker_for_other_versions() { let pending = Some(PendingUpdate { version: "0.3.0".into(), boots: 5 }); assert_eq!(on_boot(pending, "0.2.0"), BootDecision { marker: None, problem: false }); assert_eq!(on_boot(None, "0.2.0"), BootDecision { marker: None, problem: false }); } #[test] fn status_serializes_for_the_shell() { let json = serde_json::to_value(UpdateStatus::Ready { version: "0.3.0".into(), notes: String::new() }).unwrap(); assert_eq!(json, serde_json::json!({ "state": "ready", "version": "0.3.0", "notes": "" })); let json = serde_json::to_value(UpdateStatus::Problem { download_url: "https://a.test/".into() }).unwrap(); assert_eq!(json, serde_json::json!({ "state": "problem", "downloadUrl": "https://a.test/" })); } }