//! In-app updates (M10.9). //! //! Two channels, because two audiences: `stable` follows tagged `v*` releases, //! `dev` follows every green push. Each reads a `latest.json` published as an asset //! on a release whose TAG NEVER CHANGES — verified necessary, because Forgejo has no //! `/releases/latest/download/` route (it 404s), so "newest" cannot be named //! in a URL. A fixed tag can. //! //! The feed lives on Fabled-Git rather than on a ThoughtSync server, deliberately: //! this app is usable having never linked a server, and an install that can't reach //! its own updates because it isn't paired with anything would contradict the whole //! local-first premise. //! //! Updates are signed. The public half is baked into `tauri.conf.json`; the private //! half exists only as a CI secret, and is generated by the operator — a release //! signing key that has passed through anyone else's hands is not a signing key. use std::path::Path; use serde::{Deserialize, Serialize}; use tauri::State; use tauri_plugin_updater::UpdaterExt; use crate::local::{store, Db}; /// Where the manifests live. Fixed tags, so these URLs are permanent. const FEED_BASE: &str = "https://git.fabledsword.com/bvandeusen/thoughtsync/releases/download"; const CHANNEL_PREF: &str = "update_channel"; /// The channel the app follows, as recorded by whatever installed it. Written into /// the app-data directory by `desktop/packaging/install.sh`; both sides must agree on /// this name. const INSTALL_MARKER: &str = "install-channel"; /// The marker value we last acted on. Deliberately separate from `CHANNEL_PREF`: it /// records what the INSTALLER said, so the two can be compared and a change told apart /// from a repeat. See `adopt_installer_channel`. const CHANNEL_SEED_PREF: &str = "update_channel_seed"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Channel { Stable, Dev, } impl Channel { fn as_str(self) -> &'static str { match self { Channel::Stable => "stable", Channel::Dev => "dev", } } /// Anything unrecognized reads as `stable`. A corrupted or hand-edited value /// must not silently opt someone into pre-release builds. fn parse(raw: &str) -> Self { match raw.trim() { "dev" => Channel::Dev, _ => Channel::Stable, } } /// Strict parse, for the installer's marker file only. /// /// Unrecognized reads as "no opinion" here rather than as `stable` (which is what /// `parse` does), because the two are asked different questions. `parse` reads a /// pref that is definitely *this app's* setting, so it has to answer with a /// channel. The marker is a file written by something else, and a truncated or /// hand-edited one must not silently move someone OFF the channel they chose. /// Ignoring it is the only reading that can't do harm in either direction. fn from_marker(raw: &str) -> Option { match raw.trim() { "stable" => Some(Channel::Stable), "dev" => Some(Channel::Dev), _ => None, } } fn feed_url(self) -> String { format!("{FEED_BASE}/{}/latest.json", self.as_str()) } } /// What the UI needs to describe the update situation without a second call. #[derive(Debug, Serialize)] pub struct UpdateStatus { pub channel: Channel, pub current_version: String, /// The newer version on offer, or `None` when already up to date. pub available: Option, pub notes: Option, /// False when this install can't apply an update to itself (see /// `self_update_blocker`). The UI must not offer a button that cannot work. pub can_install: bool, /// Why not, in words meant for the person reading them. pub blocked_reason: Option, } /// Whether this install can replace itself, and if not, why. /// /// The updater rewrites the running bundle in place, which only works for formats /// that ARE a single self-contained file. On Linux that means the AppImage and /// nothing else: a `.deb` or pacman install is owned by the package manager, and /// silently overwriting files it tracks would corrupt its database. Tauri detects /// the AppImage case by the `APPIMAGE` env var the runtime sets. fn self_update_blocker() -> Option { if cfg!(target_os = "linux") && std::env::var_os("APPIMAGE").is_none() { return Some( "This copy was installed by your package manager, so it updates the same \ way — `apt upgrade`, `pacman -Syu`, or re-running the install script. \ In-app updates work on the AppImage build." .to_string(), ); } None } /// Adopt the channel the installer recorded, when it differs from the one we last /// adopted. Called once at startup, before the frontend can ask what the channel is. /// /// The installer knows which channel the user asked for; the app is what acts on it. /// Until this existed, `install.sh --channel dev` left the pref at its `stable` /// default, so a dev build checked the stable feed — which advertises an OLDER /// version — and reported "up to date" forever (issue 2183). Two places held the same /// decision and only one of them was set. /// /// Comparing against the last-adopted value, rather than only seeding when the pref is /// unset, is what makes both directions work: choosing a channel in the app sticks /// across launches (the marker hasn't changed since we adopted it), while re-running /// the installer on a DIFFERENT channel is honoured (it has). Seeding-when-unset would /// have fixed only the very first install and left the second one silently wrong. /// /// Every failure is logged and stepped over. No marker at all is the ordinary state of /// a build installed some other way — from source, from a downloaded AppImage, or by a /// Windows installer that has no channel concept — and none of that should keep the /// app from opening. pub fn adopt_installer_channel(db: &Db, data_dir: &Path) { let path = data_dir.join(INSTALL_MARKER); let Ok(raw) = std::fs::read_to_string(&path) else { return; }; let Some(channel) = Channel::from_marker(&raw) else { log::warn!( "ignoring an unreadable install channel marker at {}", path.display() ); return; }; match adopt(db, channel) { Ok(true) => log::info!( "following the {} update channel, as recorded by the installer", channel.as_str() ), Ok(false) => {} Err(e) => log::warn!("could not apply the installer's update channel: {e}"), } } /// Apply `channel` unless we already applied this same marker value. Returns whether /// anything changed. fn adopt(db: &Db, channel: Channel) -> Result { let conn = db.0.lock().map_err(|e| e.to_string())?; let adopted = store::pref(&conn, CHANNEL_SEED_PREF).map_err(|e| e.to_string())?; // Already acted on this marker — whatever the pref says now is the user's own // choice, and re-applying would quietly undo it. if adopted.as_deref() == Some(channel.as_str()) { return Ok(false); } store::set_pref(&conn, CHANNEL_PREF, channel.as_str()).map_err(|e| e.to_string())?; store::set_pref(&conn, CHANNEL_SEED_PREF, channel.as_str()).map_err(|e| e.to_string())?; Ok(true) } fn read_channel(db: &State<'_, Db>) -> Result { let conn = db.0.lock().map_err(|e| e.to_string())?; let raw = store::pref(&conn, CHANNEL_PREF).map_err(|e| e.to_string())?; Ok(raw .as_deref() .map(Channel::parse) .unwrap_or(Channel::Stable)) } #[tauri::command] pub fn update_channel_get(db: State<'_, Db>) -> Result { read_channel(&db) } #[tauri::command] pub fn update_channel_set(channel: Channel, db: State<'_, Db>) -> Result { let conn = db.0.lock().map_err(|e| e.to_string())?; store::set_pref(&conn, CHANNEL_PREF, channel.as_str()).map_err(|e| e.to_string())?; log::info!("update channel set to {}", channel.as_str()); Ok(channel) } /// Ask the feed whether there's something newer. Never installs anything. #[tauri::command] pub async fn update_check( app: tauri::AppHandle, db: State<'_, Db>, ) -> Result { let channel = read_channel(&db)?; let current_version = app.package_info().version.to_string(); let blocked_reason = self_update_blocker(); let url = channel .feed_url() .parse() .map_err(|e| format!("the update feed address is malformed: {e}"))?; let updater = app .updater_builder() .endpoints(vec![url]) .map_err(|e| e.to_string())? .build() .map_err(|e| format!("updates aren't configured for this build: {e}"))?; // A missing manifest is the ordinary state of a channel nobody has published to // yet — report it as "nothing available" rather than as a failure to act on. // Matched on the message rather than an error variant so this doesn't break on a // plugin minor that renames one. let found = match updater.check().await { Ok(found) => found, Err(e) => { let detail = e.to_string(); if is_missing_manifest(&detail) { None } else { return Err(describe_check_error(&detail)); } } }; Ok(UpdateStatus { channel, current_version, available: found.as_ref().map(|u| u.version.clone()), notes: found.as_ref().and_then(|u| u.body.clone()), can_install: found.is_some() && blocked_reason.is_none(), blocked_reason, }) } /// Download, verify and apply the update, then relaunch. /// /// Refuses up front on an install that can't replace itself, rather than failing /// halfway through with a permissions error nobody can interpret. #[tauri::command] pub async fn update_install(app: tauri::AppHandle, db: State<'_, Db>) -> Result<(), String> { if let Some(reason) = self_update_blocker() { return Err(reason); } let channel = read_channel(&db)?; let url = channel .feed_url() .parse() .map_err(|e| format!("the update feed address is malformed: {e}"))?; let updater = app .updater_builder() .endpoints(vec![url]) .map_err(|e| e.to_string())? .build() .map_err(|e| format!("updates aren't configured for this build: {e}"))?; let found = updater .check() .await .map_err(|e| describe_check_error(&e.to_string()))?; let Some(update) = found else { return Err("There's no update to install — this is already the newest build.".to_string()); }; log::info!( "installing update {} over {} ({} channel)", update.version, app.package_info().version, channel.as_str() ); update .download_and_install(|_chunk, _total| {}, || {}) .await .map_err(|e| format!("the update couldn't be installed: {e}"))?; // Only reached if the install succeeded. `restart` diverges, so it's the tail. log::info!("update installed; restarting"); app.restart() } /// Whether a check failure just means "this channel has nothing published yet". fn is_missing_manifest(detail: &str) -> bool { let lower = detail.to_lowercase(); lower.contains("404") || lower.contains("not found") } /// Turn a check failure into something worth reading. The default rendering of a /// transport error names the URL and nothing else, which tells a user nothing about /// what they could do next. fn describe_check_error(detail: &str) -> String { let lower = detail.to_lowercase(); if lower.contains("error sending request") || lower.contains("dns") || lower.contains("connect") { return "Couldn't reach the update server — check your connection.".to_string(); } format!("Couldn't check for updates: {detail}") } #[cfg(test)] mod tests { use super::*; use crate::local::schema; use rusqlite::Connection; use std::sync::Mutex; fn db() -> Db { let conn = Connection::open_in_memory().expect("in-memory db"); schema::migrate(&conn).expect("migrate"); Db(Mutex::new(conn)) } /// The channel `update_check` would actually use. fn effective(db: &Db) -> Channel { let conn = db.0.lock().expect("lock"); store::pref(&conn, CHANNEL_PREF) .expect("read pref") .as_deref() .map(Channel::parse) .unwrap_or(Channel::Stable) } fn set_channel(db: &Db, channel: Channel) { let conn = db.0.lock().expect("lock"); store::set_pref(&conn, CHANNEL_PREF, channel.as_str()).expect("set pref"); } /// A scratch directory holding a marker file, named uniquely so tests can run in /// parallel. Dropped by the OS' temp cleanup; nothing here is worth a new dependency. fn data_dir_with_marker(contents: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("ts-marker-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).expect("scratch dir"); std::fs::write(dir.join(INSTALL_MARKER), contents).expect("write marker"); dir } #[test] fn a_dev_install_follows_the_dev_channel() { // The bug this whole mechanism exists for (2183): the installer knew, the app // didn't, and a dev install checked the stable feed forever. let db = db(); assert_eq!( effective(&db), Channel::Stable, "default before any install" ); adopt_installer_channel(&db, &data_dir_with_marker("dev\n")); assert_eq!(effective(&db), Channel::Dev); } #[test] fn choosing_a_channel_in_the_app_survives_the_next_launch() { // Adoption runs on EVERY startup, so the marker must not keep re-asserting // itself over a choice the user made afterwards. let db = db(); let dir = data_dir_with_marker("dev"); adopt_installer_channel(&db, &dir); set_channel(&db, Channel::Stable); adopt_installer_channel(&db, &dir); assert_eq!(effective(&db), Channel::Stable); } #[test] fn reinstalling_on_a_different_channel_moves_the_app() { // The half a seed-only-when-unset fix would have missed: install stable, then // install dev. The pref is already set, but the user just asked for dev. let db = db(); adopt_installer_channel(&db, &data_dir_with_marker("stable")); assert_eq!(effective(&db), Channel::Stable); adopt_installer_channel(&db, &data_dir_with_marker("dev")); assert_eq!(effective(&db), Channel::Dev); } #[test] fn a_damaged_marker_changes_nothing() { // Ignored, NOT read as stable — a corrupt file must not move someone off the // channel they're on. let db = db(); set_channel(&db, Channel::Dev); adopt_installer_channel(&db, &data_dir_with_marker("de")); assert_eq!(effective(&db), Channel::Dev); assert_eq!(Channel::from_marker("nightly"), None); assert_eq!(Channel::from_marker(""), None); assert_eq!(Channel::from_marker(" dev\n"), Some(Channel::Dev)); } #[test] fn no_marker_at_all_is_not_an_error() { // Built from source, or installed by anything that isn't install.sh. let db = db(); set_channel(&db, Channel::Dev); let empty = std::env::temp_dir().join(format!("ts-nomarker-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&empty).expect("scratch dir"); adopt_installer_channel(&db, &empty); assert_eq!(effective(&db), Channel::Dev); } #[test] fn each_channel_has_its_own_fixed_feed() { assert!(Channel::Stable.feed_url().ends_with("/stable/latest.json")); assert!(Channel::Dev.feed_url().ends_with("/dev/latest.json")); assert_ne!(Channel::Stable.feed_url(), Channel::Dev.feed_url()); } #[test] fn the_feed_is_https() { // The manifest names the URL and signature of a binary that is about to // replace this one. The signature is what actually protects it, but there's // no reason to hand an attacker the manifest to tamper with in the first place. assert!(Channel::Stable.feed_url().starts_with("https://")); assert!(Channel::Dev.feed_url().starts_with("https://")); } #[test] fn an_unknown_channel_falls_back_to_stable() { // A corrupted or hand-edited pref must never silently opt someone into // pre-release builds — the safe default is the conservative one. assert_eq!(Channel::parse("dev"), Channel::Dev); assert_eq!(Channel::parse("stable"), Channel::Stable); assert_eq!(Channel::parse("nightly"), Channel::Stable); assert_eq!(Channel::parse(""), Channel::Stable); } #[test] fn an_unpublished_channel_is_not_reported_as_a_failure() { // Before the first publish, or on a channel nobody uses, the feed simply // isn't there. That's "you're up to date", not something to alarm anyone with. assert!(is_missing_manifest("http status: 404 Not Found")); assert!(is_missing_manifest("Release Not Found")); assert!(!is_missing_manifest("invalid signature")); } #[test] fn an_unreachable_server_reads_as_a_connection_problem() { let msg = describe_check_error("error sending request for url (https://…)"); assert!(msg.contains("connection"), "got {msg}"); // Anything unrecognized still surfaces its detail rather than being swallowed. assert!(describe_check_error("invalid signature").contains("invalid signature")); } #[test] fn the_channel_name_round_trips() { for channel in [Channel::Stable, Channel::Dev] { assert_eq!(Channel::parse(channel.as_str()), channel); } } }