diff --git a/crates/update/src/lib.rs b/crates/update/src/lib.rs index 974af700..fab644dd 100644 --- a/crates/update/src/lib.rs +++ b/crates/update/src/lib.rs @@ -15,6 +15,7 @@ mod msi_copy; mod msi_migrate; pub mod packages; pub mod paths; +pub mod pins; pub mod platform; #[cfg(windows)] pub mod portable; diff --git a/crates/update/src/pins.rs b/crates/update/src/pins.rs new file mode 100644 index 00000000..08d5ce80 --- /dev/null +++ b/crates/update/src/pins.rs @@ -0,0 +1,221 @@ +//! The version each app should follow, shared by the launcher and the apps. +//! +//! The launcher writes one small file when someone chooses a version in "Choose version"; an app's +//! own update check reads it, so an app kept on an older release, or on nightlies, is not offered +//! the latest stable release every time it starts. Only the launcher writes the file; apps read. +//! +//! Invariants: +//! - A missing, unreadable or damaged file means every app follows stable releases, as before. +//! - An entry this version cannot read (written by a newer launcher) counts as managed by the +//! launcher, never as stable, and [`Pins::save`] writes it back unchanged. +//! - Choosing stable removes the app's entry, so the file only lists apps kept on something else. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// Names a file to use in place of the default (absolute paths only). The launcher sets it for an +/// app whose home it redirects (tarball installs on Linux), so the app still finds the real file. +pub const PATH_ENV: &str = "CRAFT_VERSIONS_FILE"; + +/// Which published build an app follows. A pinned version never changes on its own. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum VersionTrack { + #[default] + Stable, + Nightly, + Pinned(String), +} + +#[derive(Default, Serialize, Deserialize)] +struct File { + /// App id to its track, kept as raw values so entries from a newer launcher survive a save. + #[serde(default)] + apps: BTreeMap, +} + +/// The saved version choices. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Pins { + apps: BTreeMap, +} + +/// What an app's own update check should do. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Choice { + /// Follow stable releases: check and offer as usual. + Stable, + /// The launcher keeps this app on another track; the app offers nothing itself. + Managed(VersionTrack), + /// An entry this version cannot read; also left to the launcher. + Unknown, +} + +impl Pins { + /// The file in the launcher's per-user data folder, or the one [`PATH_ENV`] names. + pub fn default_path() -> Option { + if let Some(path) = std::env::var_os(PATH_ENV).map(PathBuf::from).filter(|p| p.is_absolute()) { + return Some(path); + } + let launcher = &crate::suite::LAUNCHER; + let dir = if cfg!(any(target_os = "macos", target_os = "windows")) { launcher.name } else { launcher.id }; + crate::folders::data_local_dir().map(|d| d.join(dir).join("versions.json")) + } + + /// Reads `path`. A missing or damaged file reads as empty: every app follows stable releases. + pub fn load(path: &Path) -> Self { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(), + Err(e) => { + log::warn!("couldn't read {} ({e}); every app follows stable releases", path.display()); + return Self::default(); + } + }; + match serde_json::from_str::(&text) { + Ok(file) => Self { apps: file.apps }, + Err(e) => { + log::warn!("{} is damaged ({e}); every app follows stable releases", path.display()); + Self::default() + } + } + } + + /// Reads the default file (see [`Self::default_path`]). + pub fn load_default() -> Self { + Self::default_path().map(|p| Self::load(&p)).unwrap_or_default() + } + + /// Writes `path` in one step, so a reader never sees half a file. + pub fn save(&self, path: &Path) -> Result<(), String> { + let file = File { apps: self.apps.clone() }; + let text = serde_json::to_string_pretty(&file).map_err(|e| format!("couldn't encode the version choices ({e})"))?; + crate::paths::write_atomic(path, text.as_bytes()) + } + + /// What `app`'s own update check should do. + pub fn choice(&self, app: &str) -> Choice { + match self.apps.get(app) { + None => Choice::Stable, + Some(raw) => match serde_json::from_value::(raw.clone()) { + Ok(VersionTrack::Stable) => Choice::Stable, + Ok(track) => Choice::Managed(track), + Err(_) => Choice::Unknown, + }, + } + } + + /// The track `app` follows, when this version can read it. + pub fn track(&self, app: &str) -> Option { + match self.choice(app) { + Choice::Stable => Some(VersionTrack::Stable), + Choice::Managed(track) => Some(track), + Choice::Unknown => None, + } + } + + /// Sets `app`'s track. Stable removes the entry. + pub fn set(&mut self, app: &str, track: &VersionTrack) { + match track { + VersionTrack::Stable => { + self.apps.remove(app); + } + other => { + if let Ok(raw) = serde_json::to_value(other) { + self.apps.insert(app.to_string(), raw); + } + } + } + } + + /// Every app with a track this version can read, stable ones excluded. + pub fn tracks(&self) -> impl Iterator { + self.apps.iter().filter_map(|(id, raw)| serde_json::from_value(raw.clone()).ok().map(|t| (id.as_str(), t))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pinned(tag: &str) -> VersionTrack { + VersionTrack::Pinned(tag.into()) + } + + #[test] + fn a_missing_or_damaged_file_means_every_app_follows_stable() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("versions.json"); + assert_eq!(Pins::load(&path).choice("pdfcraft"), Choice::Stable); + std::fs::write(&path, "{ not json").unwrap(); + assert_eq!(Pins::load(&path).choice("pdfcraft"), Choice::Stable); + std::fs::write(&path, r#"{"apps":"wrong shape"}"#).unwrap(); + assert_eq!(Pins::load(&path).choice("pdfcraft"), Choice::Stable); + } + + #[test] + fn choices_survive_a_save_and_only_the_named_app_is_held() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested").join("versions.json"); + let mut pins = Pins::default(); + pins.set("pdfcraft", &pinned("v0.13.0")); + pins.set("photocraft", &VersionTrack::Nightly); + pins.save(&path).unwrap(); + let read = Pins::load(&path); + assert_eq!(read, pins); + assert_eq!(read.choice("pdfcraft"), Choice::Managed(pinned("v0.13.0"))); + assert_eq!(read.choice("photocraft"), Choice::Managed(VersionTrack::Nightly)); + assert_eq!(read.choice("wordcraft"), Choice::Stable); + assert_eq!(read.tracks().count(), 2); + } + + #[test] + fn choosing_stable_removes_the_entry() { + let mut pins = Pins::default(); + pins.set("pdfcraft", &pinned("v0.13.0")); + pins.set("pdfcraft", &VersionTrack::Stable); + assert_eq!(pins, Pins::default()); + assert_eq!(pins.track("pdfcraft"), Some(VersionTrack::Stable)); + } + + #[test] + fn the_file_uses_the_launchers_own_shape() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("versions.json"); + std::fs::write(&path, r#"{"apps":{"pdfcraft":{"Pinned":"v0.13.0"},"filmcraft":"Nightly","wordcraft":"Stable"}}"#).unwrap(); + let pins = Pins::load(&path); + assert_eq!(pins.track("pdfcraft"), Some(pinned("v0.13.0"))); + assert_eq!(pins.track("filmcraft"), Some(VersionTrack::Nightly)); + assert_eq!(pins.choice("wordcraft"), Choice::Stable); + } + + #[test] + fn an_entry_from_a_newer_launcher_is_left_to_the_launcher_and_kept_on_save() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("versions.json"); + std::fs::write(&path, r#"{"apps":{"pdfcraft":{"Beta":{"since":"v0.20.0"}}}}"#).unwrap(); + let mut pins = Pins::load(&path); + assert_eq!(pins.choice("pdfcraft"), Choice::Unknown); + assert_eq!(pins.track("pdfcraft"), None); + pins.set("wordcraft", &pinned("v1.0.0")); + pins.save(&path).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("Beta") && text.contains("v0.20.0"), "kept: {text}"); + assert_eq!(Pins::load(&path).choice("pdfcraft"), Choice::Unknown); + } + + #[test] + fn the_default_file_sits_in_the_launchers_data_folder() { + // The environment override is read by the same function; it is not set in tests. + if std::env::var_os(PATH_ENV).is_some() { + return; + } + if let Some(path) = Pins::default_path() { + assert!(path.is_absolute()); + assert_eq!(path.file_name().and_then(|n| n.to_str()), Some("versions.json")); + let folder = path.parent().and_then(|p| p.file_name()).and_then(|n| n.to_str()).unwrap_or_default().to_string(); + assert!(folder == "ArtCraft Launcher" || folder == "artcraft-launcher", "{folder}"); + } + } +} diff --git a/crates/update/src/updater.rs b/crates/update/src/updater.rs index 63686001..ab8522c5 100644 --- a/crates/update/src/updater.rs +++ b/crates/update/src/updater.rs @@ -2,12 +2,18 @@ //! and the self-update worker. An app creates one [`Updater`], calls [`Updater::tick`] and //! [`Updater::show`] each frame, and acts on the [`Action`] it returns, so every app gets the same //! handling of failures, dropped workers, repaints and quitting. +//! +//! An app the launcher keeps on another version (see [`crate::pins`]) offers nothing itself: the +//! check reads the shared choice first, and a requested check reports [`Status::UpToDate`] while +//! [`Updater::managed`] says why. +use std::path::PathBuf; use std::sync::Arc; use std::sync::mpsc::{Receiver, TryRecvError, channel}; use std::time::Duration; use crate::dialog::{StartupPrompt, StartupPromptResponse}; +use crate::pins::{Choice, Pins}; use crate::release::Release; use crate::{App, Http, Paths, Platform, Stop, check_for_update, self_update}; @@ -55,6 +61,10 @@ pub struct Updater { restart: bool, /// An update is installed and waits for the app to exit; nothing starts a second one. installed: bool, + /// The version-choice file to read in place of [`Pins::default_path`] (tests). + pins_path: Option, + /// The launcher's choice for this app when it is not stable, as of the last check or offer. + managed: Option, } impl Updater { @@ -73,6 +83,8 @@ impl Updater { prompt: None, restart: false, installed: false, + pins_path: None, + managed: None, } } @@ -82,6 +94,32 @@ impl Updater { self } + /// Reads the version choices from `path` instead of the launcher's file (tests). + pub fn with_pins_path(mut self, path: impl Into) -> Self { + self.pins_path = Some(path.into()); + self + } + + /// The launcher's choice for this app when it keeps it off stable releases (a pinned version, + /// nightlies, or a choice this version cannot read), as of the last check. A window answering + /// Help ▸ Check for Updates can say so instead of "newest version". + pub fn managed(&self) -> Option<&Choice> { + self.managed.as_ref() + } + + /// Reads the launcher's choice for this app; remembers it when it is not stable. + fn read_choice(&mut self) -> bool { + let pins = match &self.pins_path { + Some(path) => Pins::load(path), + None => Pins::load_default(), + }; + self.managed = Some(pins.choice(self.app.id)).filter(|c| *c != Choice::Stable); + if let Some(choice) = &self.managed { + log::info!("{} follows the launcher's choice ({choice:?}); no update offered here", self.app.name); + } + self.managed.is_some() + } + /// The check at start, once. Later calls do nothing. pub fn check_at_start(&mut self, ctx: &egui::Context) { if !std::mem::replace(&mut self.started, true) { @@ -98,7 +136,7 @@ impl Updater { /// Opens the startup prompt for a release the app found itself. pub fn offer(&mut self, release: Release) { - if self.install.is_none() && !self.installed { + if self.install.is_none() && !self.installed && !self.read_choice() { self.prompt = Some(StartupPrompt::new(self.app, release, self.version.clone())); } } @@ -130,6 +168,11 @@ impl Updater { if self.check.is_some() { return; } + // The file is a few bytes, read once per check; the launcher may have changed it since. + if self.read_choice() { + self.finish_check(Ok(None)); + return; + } let (tx, rx) = channel(); let (source, app, version, ctx) = (self.source.clone(), self.app, self.version.clone(), ctx.clone()); let spawned = std::thread::Builder::new().name("update-check".into()).spawn(move || { @@ -153,7 +196,7 @@ impl Updater { match answer { Ok(Some(release)) => { self.status = Status::Idle; - if self.prompt.is_none() && self.install.is_none() && !self.installed { + if self.prompt.is_none() && self.install.is_none() && !self.installed && self.managed.is_none() { self.prompt = Some(StartupPrompt::new(self.app, release, self.version.clone())); } } @@ -313,12 +356,17 @@ mod tests { use super::*; use crate::suite::PHOTOCRAFT; + /// An updater that never reads the real version-choice file. fn updater(answer: Checked) -> Updater { - Updater::new(&PHOTOCRAFT, "0.1.0").with_source(Arc::new(move || answer.clone())) + Updater::new(&PHOTOCRAFT, "0.1.0").with_source(Arc::new(move || answer.clone())).with_pins_path(no_pins()) + } + + pub(super) fn no_pins() -> PathBuf { + std::env::temp_dir().join("craft-update-tests-no-such-dir").join("versions.json") } /// Ticks until the running check has reported. - fn settle(updater: &mut Updater, ctx: &egui::Context) { + pub(super) fn settle(updater: &mut Updater, ctx: &egui::Context) { let deadline = std::time::Instant::now() + Duration::from_secs(10); while updater.check.is_some() && std::time::Instant::now() < deadline { updater.tick(ctx); @@ -326,7 +374,7 @@ mod tests { } } - fn newer() -> Release { + pub(super) fn newer() -> Release { Release { tag: "v9.0.0".into(), version: crate::version::parse("9.0.0"), ..Release::default() } } @@ -444,7 +492,7 @@ mod tests { #[test] fn a_panicking_check_is_an_error_not_a_hang() { let ctx = egui::Context::default(); - let mut u = Updater::new(&PHOTOCRAFT, "0.1.0").with_source(Arc::new(|| std::panic::panic_any("boom"))); + let mut u = Updater::new(&PHOTOCRAFT, "0.1.0").with_source(Arc::new(|| std::panic::panic_any("boom"))).with_pins_path(no_pins()); u.check_now(&ctx); settle(&mut u, &ctx); assert!(matches!(u.status(), Status::Failed(_))); @@ -534,3 +582,7 @@ mod tests { assert_eq!(u.show(&ctx), Action::None); } } + +#[cfg(test)] +#[path = "updater_pin_tests.rs"] +mod pin_tests; diff --git a/crates/update/src/updater_pin_tests.rs b/crates/update/src/updater_pin_tests.rs new file mode 100644 index 00000000..dc4634d6 --- /dev/null +++ b/crates/update/src/updater_pin_tests.rs @@ -0,0 +1,81 @@ +//! The startup prompt and requested checks follow the launcher's shared version choice. + +use std::sync::Arc; + +use super::tests::{newer, settle}; +use super::*; +use crate::suite::PHOTOCRAFT; + +/// A version-choice file holding `track` for PhotoCraft. +fn pins_with(track: crate::pins::VersionTrack) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let mut pins = Pins::default(); + pins.set(PHOTOCRAFT.id, &track); + pins.save(&dir.path().join("versions.json")).unwrap(); + dir +} + +#[test] +fn a_pinned_app_is_not_prompted_and_the_network_is_not_asked() { + use crate::pins::VersionTrack; + let ctx = egui::Context::default(); + for track in [VersionTrack::Pinned("v0.13.0".into()), VersionTrack::Nightly] { + let dir = pins_with(track.clone()); + let mut u = Updater::new(&PHOTOCRAFT, "0.13.0") + .with_source(Arc::new(|| std::panic::panic_any("the network must not be asked"))) + .with_pins_path(dir.path().join("versions.json")); + u.check_at_start(&ctx); + settle(&mut u, &ctx); + assert!(!u.prompt_open(), "{track:?}"); + assert_eq!(u.status(), &Status::Idle); + u.check_now(&ctx); + settle(&mut u, &ctx); + assert_eq!(u.status(), &Status::UpToDate, "{track:?}"); + assert_eq!(u.managed(), Some(&Choice::Managed(track.clone()))); + u.offer(newer()); + assert!(!u.prompt_open(), "an offer is held too: {track:?}"); + } +} + +#[test] +fn unpinning_in_the_launcher_brings_updates_back_on_the_next_check() { + let ctx = egui::Context::default(); + let dir = pins_with(crate::pins::VersionTrack::Pinned("v0.13.0".into())); + let path = dir.path().join("versions.json"); + let mut u = Updater::new(&PHOTOCRAFT, "0.13.0").with_source(Arc::new(|| Ok(Some(newer())))).with_pins_path(&path); + u.check_now(&ctx); + settle(&mut u, &ctx); + assert!(!u.prompt_open()); + Pins::default().save(&path).unwrap(); + u.check_now(&ctx); + settle(&mut u, &ctx); + assert!(u.prompt_open()); + assert_eq!(u.managed(), None); +} + +#[test] +fn an_unreadable_choice_from_a_newer_launcher_also_holds_updates() { + let ctx = egui::Context::default(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("versions.json"); + std::fs::write(&path, r#"{"apps":{"photocraft":{"Beta":1}}}"#).unwrap(); + let mut u = Updater::new(&PHOTOCRAFT, "0.13.0").with_source(Arc::new(|| Ok(Some(newer())))).with_pins_path(&path); + u.check_at_start(&ctx); + settle(&mut u, &ctx); + assert!(!u.prompt_open()); + assert_eq!(u.managed(), Some(&Choice::Unknown)); +} + +#[test] +fn another_apps_pin_does_not_hold_this_one() { + let ctx = egui::Context::default(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("versions.json"); + let mut pins = Pins::default(); + pins.set("pdfcraft", &crate::pins::VersionTrack::Pinned("v0.2.0".into())); + pins.save(&path).unwrap(); + let mut u = Updater::new(&PHOTOCRAFT, "0.13.0").with_source(Arc::new(|| Ok(Some(newer())))).with_pins_path(&path); + u.check_at_start(&ctx); + settle(&mut u, &ctx); + assert!(u.prompt_open()); +}