Host: `updater::check_now` runs the same check the timer runs (the same updater endpoint, nothing else) for the app's shell-only `update_check`. It never queues or retries: while a check is in flight (the timer's or an earlier request's), or within 10 seconds of the last request, it only answers with the current status (`update::may_check_now`, tested). The timer's loop uses the same single-flight guard. Frame: the menu's Updates row is that check (`updateRow`, tested): "Checking…", "Up to date · <version>", "Downloading <version>…", "Restart to update" when an update is ready (the row then installs it), and "Could not check" with a plain sentence as its tooltip when the server does not answer. The updater's error text is never shown there, and a failure is only mentioned when the owner asked for the check in this menu; the timer's own failures stay quiet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
134 lines
5.9 KiB
Rust
134 lines
5.9 KiB
Rust
//! 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<Duration>) -> 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<PendingUpdate>,
|
|
/// 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<PendingUpdate>, 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/" }));
|
|
}
|
|
}
|