//! 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 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"; #[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, } } 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 } 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::*; #[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); } } }