craft-update: apps respect the launcher's per-app version choice

The launcher lets someone keep an app on an older release or on nightlies,
but the app's own update check never knew, so a pinned app prompted to
update on every start and "Update and restart" undid the downgrade.

`pins` is one small file the launcher writes (versions.json in its per-user
data folder, or the absolute path in CRAFT_VERSIONS_FILE) and apps read:
app id to Stable, Nightly or Pinned(tag), the launcher's own serde shape.
A missing or damaged file means everyone follows stable; an entry a newer
launcher wrote is left to the launcher and kept on save.

`Updater` reads it before each check and before an offer. An app kept off
stable gets no prompt and no network call; a requested check reports
UpToDate, and `Updater::managed()` says why, so the public Status enum is
unchanged and no app breaks on a rev bump.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
JeremyandClaude Opus 5.5 committed 2026-10-30 12:22:38 +00:00
1 parent 04aa1703b3
commit 7d303b4b50
4 files changed
+361 -6

No files matched your search

+1
View File
@@ -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;
+221
View File
@@ -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<String, serde_json::Value>,
}
/// The saved version choices.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Pins {
apps: BTreeMap<String, serde_json::Value>,
}
/// 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<PathBuf> {
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::<File>(&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::<VersionTrack>(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<VersionTrack> {
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<Item = (&str, VersionTrack)> {
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}");
}
}
}
+58 -6
View File
@@ -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<PathBuf>,
/// The launcher's choice for this app when it is not stable, as of the last check or offer.
managed: Option<Choice>,
}
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<PathBuf>) -> 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;
+81
View File
@@ -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());
}