From d5fe3d8f272c338a2abf9864d643e6ca11c9a984 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 21 Oct 2026 16:24:32 +0700 Subject: [PATCH] feat: check and apply updates through the Tauri updater plugin The plugin fetches latest.json and downloads the platform entry, verifying its minisign signature. The suffix-ignoring version comparison moves to core::version and drives the plugin comparator; a manifest without an entry for this platform counts as up to date. The Windows portable exe swaps itself in with self_replace, an AppImage goes through the plugin install, and both relaunch with --updated-to. A package-manager install keeps checking but greys the apply item. The GitHub API lookup, the WinHTTP download and the --apply-update helper are gone, along with the sha2 dependency they needed. --- Cargo.lock | 204 ++++++++++++++++- Cargo.toml | 2 +- src/core/menu_model.rs | 39 +++- src/core/mod.rs | 1 + src/core/version.rs | 102 +++++++++ src/lib.rs | 1 + src/menu.rs | 4 + src/runtime.rs | 181 ++++++++++++++- src/update/channel.rs | 72 +++++- src/update/install.rs | 509 ++++++++++++++--------------------------- src/update/mod.rs | 299 ++++++++++++++++++++++-- src/update/release.rs | 119 ---------- tauri.conf.json | 12 +- 13 files changed, 1054 insertions(+), 491 deletions(-) create mode 100644 src/core/version.rs delete mode 100644 src/update/release.rs diff --git a/Cargo.lock b/Cargo.lock index 8ada3a2..198b948 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,6 +47,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -518,13 +527,13 @@ dependencies = [ "self-replace", "serde", "serde_json", - "sha2", "simplelog", "tauri", "tauri-build", "tauri-plugin-autostart", "tauri-plugin-notification", "tauri-plugin-single-instance", + "tauri-plugin-updater", "thiserror 2.0.21", "tiny-skia", "ureq", @@ -563,6 +572,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -586,7 +605,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -599,7 +618,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -760,6 +779,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1037,6 +1067,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.15" @@ -1591,6 +1631,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.21" @@ -1610,9 +1665,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2138,6 +2195,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2438,6 +2501,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2562,6 +2637,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "pango" version = "0.18.3" @@ -3005,15 +3094,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3109,7 +3203,7 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni 0.22.4", "log", @@ -3229,7 +3323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3709,6 +3803,27 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3730,7 +3845,7 @@ checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400" dependencies = [ "bitflags 2.13.2", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -3773,6 +3888,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -3960,6 +4086,40 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7259989d4fe11b2bf51f7d671642249b1837c4ae2da1430dc713ae38f98ceeec" +dependencies = [ + "base64 0.22.1", + "dirs 7.0.0", + "flate2", + "futures-util", + "http", + "infer", + "libc", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.21", + "time", + "tokio", + "url", + "windows-sys 0.61.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.12.2" @@ -4230,6 +4390,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.20" @@ -5273,6 +5443,16 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acf4d1bc32aa46eec18caa634ec3cf4c05bfa151f12b93b510b15190f69a1ca8" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -5446,6 +5626,18 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.2", + "memchr", +] + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/Cargo.toml b/Cargo.toml index c265884..d42d093 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,9 +27,9 @@ log = "0.4" simplelog = "0.12" thiserror = "2" tiny-skia = "0.11" -sha2 = "0.10" self-replace = "1.5" tauri-plugin-notification = "2" +tauri-plugin-updater = "2" [target.'cfg(not(windows))'.dependencies] tauri-plugin-autostart = "2" diff --git a/src/core/menu_model.rs b/src/core/menu_model.rs index b9f0ece..452bee8 100644 --- a/src/core/menu_model.rs +++ b/src/core/menu_model.rs @@ -127,6 +127,9 @@ pub struct MenuInput<'a> { pub strings: &'a LocaleStrings, pub autostart_enabled: bool, pub update_status: UpdateStatus, + /// Whether this install can apply an update in place. When it cannot + /// (a package-manager install), "Update available" stays greyed. + pub update_can_apply: bool, } fn action(command: MenuCommand, label: &str, enabled: bool) -> MenuEntry { @@ -159,6 +162,13 @@ fn submenu(label: &str, entries: Vec) -> MenuEntry { } } +/// The version item checks for updates, or applies one once it is +/// available. It is greyed while either runs, and an available update stays +/// greyed when this install cannot apply it. +fn version_action_enabled(status: UpdateStatus, can_apply: bool) -> bool { + !status.is_busy() && (can_apply || status != UpdateStatus::Available) +} + /// Build the context menu in display order. pub fn build(input: MenuInput<'_>) -> Vec { let MenuInput { @@ -166,6 +176,7 @@ pub fn build(input: MenuInput<'_>) -> Vec { strings: s, autostart_enabled, update_status, + update_can_apply, } = input; let freq_labels = [ @@ -230,7 +241,7 @@ pub fn build(input: MenuInput<'_>) -> Vec { action( MenuCommand::VersionAction, &version_action_label(update_status, s), - !update_status.is_busy(), + version_action_enabled(update_status, update_can_apply), ), submenu(&s.auto_update_check, auto_update), ]; @@ -290,6 +301,7 @@ mod tests { strings: &strings, autostart_enabled: true, update_status: status, + update_can_apply: true, }) } @@ -382,6 +394,30 @@ mod tests { )); } + #[test] + fn available_update_is_greyed_when_it_cannot_be_applied() { + let strings = I18n::load().strings().clone(); + let settings = Settings::default(); + let menu_for = |status| { + build(MenuInput { + settings: &settings, + strings: &strings, + autostart_enabled: true, + update_status: status, + update_can_apply: false, + }) + }; + assert!(!enabled(&menu_for(UpdateStatus::Available), "version")); + // Checking still works on such an install. + for status in [ + UpdateStatus::Idle, + UpdateStatus::UpToDate, + UpdateStatus::Failed, + ] { + assert!(enabled(&menu_for(status), "version")); + } + } + #[test] fn top_level_order_and_hint_rows() { let m = menu(&Settings::default(), UpdateStatus::Idle); @@ -462,6 +498,7 @@ mod tests { strings: &strings, autostart_enabled: false, update_status: UpdateStatus::Idle, + update_can_apply: true, }); let mut lines = Vec::new(); outline(&m, 0, &mut lines); diff --git a/src/core/mod.rs b/src/core/mod.rs index 1adb50e..10518b7 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -9,4 +9,5 @@ pub mod proxy; pub mod settings; pub mod state; pub mod usage_color; +pub mod version; pub mod visibility; diff --git a/src/core/version.rs b/src/core/version.rs new file mode 100644 index 0000000..736d21d --- /dev/null +++ b/src/core/version.rs @@ -0,0 +1,102 @@ +//! Release version parsing and comparison for the update check. +//! +//! Only `major.minor.patch` counts: a leading `v` and anything after the +//! first `-` are ignored, and a missing or non-numeric part reads as 0. So +//! `v1.2.3-rc1` equals `1.2.3`, and a pre-release of the running version is +//! never offered as an update. + +use std::fmt; + +/// A `major.minor.patch` triple, ordered numerically part by part. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub struct Version { + pub major: u32, + pub minor: u32, + pub patch: u32, +} + +impl Version { + /// The running build's version, from `Cargo.toml`. + pub fn current() -> Self { + Self::parse(env!("CARGO_PKG_VERSION")) + } + + /// Parse a release tag or version string. Never fails: anything that + /// is not a number becomes 0, so `abc` is `0.0.0`. + pub fn parse(s: &str) -> Self { + let core = s + .trim() + .trim_start_matches(['v', 'V']) + .split('-') + .next() + .unwrap_or_default(); + let mut parts = core.split('.').map(|p| p.trim().parse::().ok()); + let mut next = || parts.next().flatten().unwrap_or(0); + Version { + major: next(), + minor: next(), + patch: next(), + } + } +} + +impl fmt::Display for Version { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}.{}.{}", self.major, self.minor, self.patch) + } +} + +/// Whether `candidate` is strictly newer than `current`. Equal versions and +/// downgrades are not updates. +pub fn newer(candidate: &str, current: &str) -> bool { + Version::parse(candidate) > Version::parse(current) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(major: u32, minor: u32, patch: u32) -> Version { + Version { + major, + minor, + patch, + } + } + + #[test] + fn suffixes_and_prefix_are_ignored() { + assert_eq!(Version::parse("v1.2.3-rc1"), v(1, 2, 3)); + assert_eq!(Version::parse("1.2.3"), v(1, 2, 3)); + assert_eq!(Version::parse(" V0.5.0 "), v(0, 5, 0)); + } + + #[test] + fn missing_and_malformed_parts_read_as_zero() { + assert_eq!(Version::parse("v1"), v(1, 0, 0)); + assert_eq!(Version::parse("1.2"), v(1, 2, 0)); + assert_eq!(Version::parse("abc"), v(0, 0, 0)); + assert_eq!(Version::parse(""), v(0, 0, 0)); + assert_eq!(Version::parse("1.x.3"), v(1, 0, 3)); + } + + #[test] + fn only_a_strictly_newer_version_is_an_update() { + assert!(newer("0.6.0", "0.5.0")); + assert!(newer("v0.5.1", "0.5.0")); + assert!(newer("1.0.0", "0.99.99")); + assert!(newer("0.10.0", "0.9.0")); + // Same version, with or without a suffix: no update. + assert!(!newer("0.5.0", "0.5.0")); + assert!(!newer("v0.5.0-rc1", "0.5.0")); + assert!(!newer("0.5.0", "0.5.0-beta")); + // No downgrade. + assert!(!newer("0.4.9", "0.5.0")); + assert!(!newer("abc", "0.5.0")); + } + + #[test] + fn displays_the_bare_triple() { + assert_eq!(Version::parse("v1.2.3-rc1").to_string(), "1.2.3"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 7d72818..143580a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,7 @@ pub mod platform; pub mod runtime; pub mod scheduler; pub mod tray; +pub mod update; pub mod usage; /// Starts the application and blocks until it exits. diff --git a/src/menu.rs b/src/menu.rs index ea66566..da4e072 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -28,6 +28,7 @@ pub struct MenuSnapshot { settings: Settings, strings: LocaleStrings, update_status: UpdateStatus, + update_can_apply: bool, } impl MenuSnapshot { @@ -36,6 +37,7 @@ impl MenuSnapshot { settings: state.settings.clone(), strings: state.strings.clone(), update_status: state.update_status, + update_can_apply: state.update_channel.can_apply(), } } @@ -46,6 +48,7 @@ impl MenuSnapshot { strings: &self.strings, autostart_enabled, update_status: self.update_status, + update_can_apply: self.update_can_apply, }) } } @@ -248,6 +251,7 @@ mod tests { settings, strings: I18n::load().strings().clone(), update_status: UpdateStatus::Idle, + update_can_apply: true, } } diff --git a/src/runtime.rs b/src/runtime.rs index c95283d..352cd50 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -30,6 +30,7 @@ use crate::core::visibility::{self, Visibility}; use crate::menu::{self, MenuCache, MenuSnapshot}; use crate::scheduler::{self, Action, Message, SchedulerHost}; use crate::tray::{self, Trays}; +use crate::update::{self, CheckOutcome}; use crate::usage::{self, refresh, ProviderId, Registry}; use crate::{bubble, diag, fullscreen, handoff, net, panel, platform}; @@ -71,6 +72,9 @@ pub struct AppState { pub visibility: Visibility, pub usage: UsageState, pub update_status: UpdateStatus, + /// How this copy was installed, which decides whether an available + /// update can be applied. + pub update_channel: update::Channel, /// The "update applied" alert from `--updated-to`, shown once the /// notification backend is up. pub pending_update_alert: Option, @@ -164,6 +168,9 @@ pub struct Runtime { /// At most one poll runs at a time; a Refresh spam or a timer firing /// during a slow poll is dropped instead of stacking requests. poll_in_flight: AtomicBool, + /// The release the last check found, applied by the version menu item. + /// Never locked together with `state`. + pending_update: Mutex>, scheduler: Sender, trays: Trays, menu_cache: MenuCache, @@ -388,7 +395,7 @@ impl Runtime { MenuCommand::ToggleProvider(provider) => self.toggle_provider(provider), MenuCommand::Autostart => menu::toggle_autostart(&self.ui.app), MenuCommand::ResetPosition => self.reset_positions(), - MenuCommand::VersionAction => self.check_for_updates(), + MenuCommand::VersionAction => self.version_action(), MenuCommand::UpdateInterval(secs) => self.set_update_interval(secs), MenuCommand::SizeSmaller => self.resize_bubbles(-geom::RESIZE_STEP_LOGICAL), MenuCommand::SizeLarger => self.resize_bubbles(geom::RESIZE_STEP_LOGICAL), @@ -532,11 +539,168 @@ impl Runtime { self.propagate(); } - /// The update check, automatic or from the menu. No updater is - /// registered in this build, so the deadline stays disarmed until a - /// check reports back. - fn check_for_updates(&self) { - log::debug!("update check due; no updater is registered"); + /// The version menu item: apply the update the last check found, or + /// check again. + fn version_action(self: &Arc) { + let (status, can_apply) = + self.with_state(|s| (s.update_status, s.update_channel.can_apply())); + if status == UpdateStatus::Available && can_apply { + self.apply_update(); + } else { + self.check_for_updates(); + } + } + + /// Run an update check on a worker thread, automatic or from the menu. + /// The scheduler re-arms the automatic check once it reports back. + fn check_for_updates(self: &Arc) { + let (previous, interval_secs) = self.with_state(|s| { + let previous = s.update_status; + if !previous.is_busy() { + s.update_status = UpdateStatus::Checking; + } + (previous, s.settings.update_check_interval_secs) + }); + match previous { + UpdateStatus::Checking => { + // The running check re-arms the schedule when it finishes. + log::debug!("update check already running"); + return; + } + UpdateStatus::Applying => { + // The apply may fail and keep this process running, so the + // automatic check must stay armed. + log::debug!("update check skipped while an update is applied"); + self.send(Message::UpdateCheckDone { interval_secs }); + return; + } + _ => {} + } + self.refresh_shell(false); + let runtime = Arc::clone(self); + let spawned = std::thread::Builder::new() + .name("update-check".into()) + .spawn(move || runtime.run_update_check()); + if let Err(err) = spawned { + log::error!("update check thread could not start: {err}"); + self.with_state(|s| s.update_status = UpdateStatus::Failed); + self.send(Message::UpdateCheckDone { interval_secs }); + self.refresh_shell(false); + } + } + + /// Blocking body of [`Self::check_for_updates`]. + fn run_update_check(&self) { + let proxy = if net::env_proxy().is_some() { + None + } else { + update::proxy_url(self.http.system_proxy()) + }; + let outcome = update::check(&self.ui.app, proxy); + let now_unix = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or_default(); + let (status, found) = match outcome { + Ok(CheckOutcome::UpToDate) => { + log::info!("update check: up to date"); + (UpdateStatus::UpToDate, None) + } + Ok(CheckOutcome::Available(found)) => { + log::info!("update check: {} available", found.version); + (UpdateStatus::Available, Some(found)) + } + Err(err) => { + log::warn!("update check failed: {err}"); + (UpdateStatus::Failed, None) + } + }; + // Stored before the status, so "Update available" always has a + // release behind it. + *self + .pending_update + .lock() + .unwrap_or_else(PoisonError::into_inner) = found; + let (snapshot, interval_secs) = self.with_state(|s| { + s.update_status = status; + s.settings.last_update_check_unix = Some(now_unix); + (s.settings.clone(), s.settings.update_check_interval_secs) + }); + if let Err(err) = settings::save(&snapshot) { + log::warn!("settings save failed: {err}"); + } + self.send(Message::UpdateCheckDone { interval_secs }); + self.refresh_shell(false); + } + + /// Download and install the release the last check found on a worker + /// thread, then relaunch into it. + fn apply_update(self: &Arc) { + // Claim the apply in one step, so a check that started since the + // click cannot run alongside it. + let channel = self.with_state(|s| { + (s.update_status == UpdateStatus::Available).then(|| { + s.update_status = UpdateStatus::Applying; + s.update_channel + }) + }); + let Some(channel) = channel else { + log::debug!("update apply skipped, no update is available any more"); + return; + }; + let found = self + .pending_update + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take(); + let Some(found) = found else { + // Nothing to apply after all; a fresh check sorts the state out. + self.with_state(|s| s.update_status = UpdateStatus::Idle); + self.check_for_updates(); + return; + }; + self.refresh_shell(false); + let runtime = Arc::clone(self); + let spawned = std::thread::Builder::new() + .name("update-apply".into()) + .spawn(move || runtime.run_update_apply(found, channel)); + if let Err(err) = spawned { + log::error!("update apply thread could not start: {err}"); + self.update_failed(&update::Error::Io(err)); + } + } + + /// Blocking body of [`Self::apply_update`]. + fn run_update_apply(&self, found: tauri_plugin_updater::Update, channel: update::Channel) { + let installed = match update::apply(found, channel) { + Ok(version) => version, + Err(err) => { + self.update_failed(&err); + return; + } + }; + log::info!("update {installed} installed; relaunching"); + let snapshot = self.with_state(|s| s.settings.clone()); + if let Err(err) = settings::save(&snapshot) { + log::warn!("settings save before the update relaunch failed: {err}"); + } + let args = [ + OsString::from("--updated-to"), + OsString::from(installed.to_string()), + ]; + if let Err(err) = self.relaunch_and_exit(&args) { + // The new binary is in place but not running; the next restart + // or login starts it. + self.update_failed(&update::Error::Relaunch(err)); + } + } + + /// Record a failed apply: Failed status plus `update-error.log`. + fn update_failed(&self, err: &update::Error) { + log::error!("update apply failed: {err}"); + update::install::write_update_error(err); + self.with_state(|s| s.update_status = UpdateStatus::Failed); + self.refresh_shell(false); } /// Spawn the next instance with `--wait-pid` plus `extra_args`, then @@ -719,6 +883,8 @@ pub fn run() { let app = builder .plugin(tauri_plugin_notification::init()) + // Driven from Rust only; no capability exposes it to the webviews. + .plugin(tauri_plugin_updater::Builder::new().build()) .invoke_handler(tauri::generate_handler![ crate::commands::get_bubble_state, crate::commands::get_panel_state, @@ -780,6 +946,7 @@ fn setup(app: &mut tauri::App, cli: &CliArgs) -> Result<(), Box> { visibility: Visibility::from_widget_visible(settings.widget_visible), usage: UsageState::default(), update_status: UpdateStatus::Idle, + update_channel: update::current_channel(), pending_update_alert: cli .updated_to .clone() @@ -802,6 +969,7 @@ fn setup(app: &mut tauri::App, cli: &CliArgs) -> Result<(), Box> { http, registry: Mutex::new(Registry::with_defaults()), poll_in_flight: AtomicBool::new(false), + pending_update: Mutex::new(None), scheduler: tx, trays: Trays::default(), menu_cache: MenuCache::default(), @@ -974,6 +1142,7 @@ mod tests { visibility: Visibility::default(), usage: UsageState::default(), update_status: UpdateStatus::Idle, + update_channel: update::Channel::Portable, pending_update_alert: None, }; let snapshot = BubbleSnapshot::of(&state); diff --git a/src/update/channel.rs b/src/update/channel.rs index ee45f61..920947f 100644 --- a/src/update/channel.rs +++ b/src/update/channel.rs @@ -1,16 +1,72 @@ -// Install-channel detection. -// -// Until this app has a winget package, every install is treated as -// "portable" — meaning self-update applies via the inline-cmd handoff. -// When a winget package exists, restore the path-probe code from git -// history (see commit before Phase 6). +//! How this copy of the app was installed, which decides whether an update +//! can be applied in place. + +use std::ffi::OsString; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Channel { + /// The Windows portable exe: the download replaces it via `self_replace`. Portable, - Winget, + /// A Linux AppImage (`$APPIMAGE` set): the updater plugin swaps the file. + AppImage, + /// A Linux install owned by a package manager (`.deb`): the check runs, + /// applying is left to the package manager. + PackageManager, + /// No in-place update path yet (macOS). + Unsupported, } +impl Channel { + /// Whether "Update available" may download and swap the binary. + pub fn can_apply(self) -> bool { + matches!(self, Channel::Portable | Channel::AppImage) + } +} + +/// The channel of the running process. pub fn current() -> Channel { - Channel::Portable + detect(std::env::consts::OS, std::env::var_os("APPIMAGE")) +} + +/// The channel for an OS name (`std::env::consts::OS`) and the `APPIMAGE` +/// variable the AppImage runtime sets. An empty `APPIMAGE` counts as unset. +pub fn detect(os: &str, appimage: Option) -> Channel { + match os { + "windows" => Channel::Portable, + "linux" if appimage.is_some_and(|v| !v.is_empty()) => Channel::AppImage, + "linux" => Channel::PackageManager, + _ => Channel::Unsupported, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn os(v: &str) -> Option { + Some(OsString::from(v)) + } + + #[test] + fn channel_follows_the_os_and_appimage_variable() { + assert_eq!(detect("windows", None), Channel::Portable); + // A stray APPIMAGE on Windows does not change the portable path. + assert_eq!(detect("windows", os("/x.AppImage")), Channel::Portable); + assert_eq!( + detect("linux", os("/home/u/App.AppImage")), + Channel::AppImage + ); + assert_eq!(detect("linux", None), Channel::PackageManager); + assert_eq!(detect("linux", os("")), Channel::PackageManager); + assert_eq!(detect("macos", None), Channel::Unsupported); + assert_eq!(detect("macos", os("/x.AppImage")), Channel::Unsupported); + } + + #[test] + fn only_portable_and_appimage_apply_in_place() { + assert!(Channel::Portable.can_apply()); + assert!(Channel::AppImage.can_apply()); + assert!(!Channel::PackageManager.can_apply()); + assert!(!Channel::Unsupported.can_apply()); + } } diff --git a/src/update/install.rs b/src/update/install.rs index 5b1889e..44fa0f0 100644 --- a/src/update/install.rs +++ b/src/update/install.rs @@ -1,372 +1,217 @@ -// Download a release asset and swap it in through the proven `self_replace` -// handoff path. -// -// The running process cannot reliably replace its own mapped image on -// Windows. `self_replace` handles the platform-specific rename/copy sequence -// for that case. After the replacement succeeds, we start the installed path -// with `--wait-pid` and let the current process exit. +//! Applying a downloaded, signature-checked update to the portable exe, and +//! the paths under `updates_dir` that updates use. +//! +//! The running process cannot reliably replace its own mapped image on +//! Windows; `self_replace` handles the rename and copy sequence for that. +//! The caller then relaunches the installed path with `--wait-pid` and exits. -use std::ffi::OsString; +use std::fmt::Display; +use std::io; use std::path::{Path, PathBuf}; -use sha2::{Digest, Sha256}; +use super::Error; +use crate::handoff; -use windows::core::PCWSTR; -use windows::Win32::Storage::FileSystem::{ - MoveFileExW, MOVEFILE_COPY_ALLOWED, MOVEFILE_REPLACE_EXISTING, MOVE_FILE_FLAGS, -}; -use windows::Win32::System::Threading::GetCurrentProcessId; -use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK}; +/// File name the download is staged under before the swap. Startup cleanup +/// deletes a leftover one. +const STAGED_EXE: &str = "update.exe"; -use crate::net::Client; -use crate::os::to_utf16_nul; +/// File name of the last apply failure's log. +const ERROR_LOG: &str = "update-error.log"; -pub fn begin(http: &Client, release: &super::Release) -> Result<(), super::Error> { - match begin_inner(http, release) { - Ok(()) => Ok(()), - Err(e) => { - write_update_error(&e); - Err(e) - } - } +/// `updates_dir()/update.exe`. +pub fn stage_path() -> Result { + updates_file(STAGED_EXE) } -fn begin_inner(http: &Client, release: &super::Release) -> Result<(), super::Error> { +/// `updates_dir()/update-error.log`. +pub fn error_log_path() -> Result { + updates_file(ERROR_LOG) +} + +fn updates_file(name: &str) -> Result { + handoff::updates_dir() + .map(|dir| dir.join(name)) + .ok_or_else(|| Error::NotWritable("no local data directory available".to_string())) +} + +/// Replace the running executable with `bytes`. +pub fn replace_portable(bytes: &[u8]) -> Result<(), Error> { let current = std::env::current_exe()?; - ensure_writable(¤t)?; let staging = stage_path()?; - // Defense in depth: `MoveFileExW` itself is immune to `%`-expansion - // (no shell parses our paths), but the existing rejection guards - // future code paths that might invoke external tools, so keep it. - reject_unsafe_path(¤t)?; - reject_unsafe_path(&staging)?; - if let Some(parent) = staging.parent() { - std::fs::create_dir_all(parent)?; - } - download( - http, - &release.asset_url, - &staging, - release.asset_sha256.as_ref(), - )?; - replace_current_exe(&staging)?; - spawn_replaced_exe(¤t, &release.version)?; - let _ = std::fs::remove_file(&staging); - Ok(()) -} - -/// CLI entry point for `--apply-update `. -/// Runs from the staged new binary, waits for the old UI process to exit, -/// replaces the installed exe, and starts the installed copy. -pub fn run_cli(args: &[String]) -> Option { - if args.get(1).map(String::as_str) != Some("--apply-update") { - return None; - } - let Some(target) = args.get(2).map(PathBuf::from) else { - return Some(2); - }; - let Some(source) = args.get(3).map(PathBuf::from) else { - return Some(2); - }; - let Some(parent_pid) = args.get(4).and_then(|s| s.parse::().ok()) else { - return Some(2); - }; - let Some(version) = args.get(5).cloned() else { - return Some(2); - }; - - super::handoff::wait_for_parent_exit(parent_pid, 15_000); - match replace_from_helper(&source, &target, &version) { - Ok(()) => Some(0), - Err(e) => { - log::error!("apply-update failed: {e}"); - Some(1) - } - } -} - -fn download( - http: &Client, - url: &str, - to: &Path, - expected_sha256: Option<&[u8; 32]>, -) -> Result<(), super::Error> { - let resp = http - .get(url) - .header("User-Agent", super::release::user_agent()) - .send()?; - if !(200..300).contains(&resp.status()) { - return Err(super::Error::Network(crate::net::Error::Status( - resp.status(), - ))); - } - let body = resp.body(); - if let Some(expected) = expected_sha256 { - let mut hasher = Sha256::new(); - hasher.update(body); - let actual = hasher.finalize(); - if actual.as_slice() != expected { - return Err(super::Error::ChecksumMismatch { - expected: hex_encode(expected), - actual: hex_encode(&actual), - }); - } - } - std::fs::write(to, body)?; - Ok(()) -} - -fn hex_encode(bytes: &[u8]) -> String { - const HEX: &[u8] = b"0123456789abcdef"; - let mut out = String::with_capacity(bytes.len() * 2); - for b in bytes { - out.push(HEX[(b >> 4) as usize] as char); - out.push(HEX[(b & 0x0f) as usize] as char); - } - out -} - -fn reject_unsafe_path(p: &Path) -> Result<(), super::Error> { - let s = p.to_string_lossy(); - if s.contains('%') { - return Err(super::Error::UnsafePath(format!("path contains '%': {s}"))); - } - Ok(()) -} - -fn spawn_update_helper( - helper: &Path, - staging: &Path, - target: &Path, - version: &super::release::Version, -) -> Result<(), super::Error> { - let pid = unsafe { GetCurrentProcessId() }; - let version_str = format!("{}.{}.{}", version.major, version.minor, version.patch); - let args = vec![ - OsString::from("--apply-update"), - target.as_os_str().to_os_string(), - staging.as_os_str().to_os_string(), - OsString::from(pid.to_string()), - OsString::from(version_str), - ]; - super::handoff::spawn_detached(helper, &args).map_err(super::Error::Io) -} - -fn replace_current_exe(staging: &Path) -> Result<(), super::Error> { - self_replace::self_replace(staging) - .map_err(|e| super::Error::SwapFailed(format!("self_replace({}): {e}", staging.display()))) -} - -fn spawn_replaced_exe( - target: &Path, - version: &super::release::Version, -) -> Result<(), super::Error> { - let pid = unsafe { GetCurrentProcessId() }; - let version_str = format!("{}.{}.{}", version.major, version.minor, version.patch); - let args = vec![ - OsString::from("--wait-pid"), - OsString::from(pid.to_string()), - OsString::from("--updated-to"), - OsString::from(version_str), - ]; - super::handoff::spawn_detached(target, &args).map_err(super::Error::Io) -} - -fn replace_from_helper(source: &Path, target: &Path, version: &str) -> Result<(), super::Error> { - let backup = backup_path(target); - // Parent has exited, so the install target is no longer mapped. - move_file(target, &backup, MOVE_FILE_FLAGS(0))?; - - let swap_flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED; - if let Err(swap_err) = move_file(source, target, swap_flags) { - // Compatibility for users updating from a release that invoked - // the downloaded binary itself as the helper. A mapped source exe - // may not be movable, but it can usually still be copied. - let copy_result = std::fs::copy(source, target); - if copy_result.is_err() { - log::error!("source move failed before copy fallback: {swap_err}"); - } - if let Err(copy_err) = copy_result { - if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) { - log::error!("rollback also failed: {revert_err}; surfacing modal"); - let target_name = target - .file_name() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_else(|| "claude-code-usage-bubble.exe".to_string()); - surface_rollback_failure(&backup, &target_name); - } - return Err(super::Error::Io(copy_err)); - } - } - - let args = vec![OsString::from("--updated-to"), OsString::from(version)]; - if let Err(spawn_err) = super::handoff::spawn_detached(target, &args) { - log::error!("spawn_detached failed after swap: {spawn_err}; attempting revert"); - let _ = std::fs::remove_file(target); - if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) { - log::error!("post-spawn revert failed: {revert_err}"); - } - return Err(super::Error::Io(spawn_err)); - } - - let _ = std::fs::remove_file(source); - - Ok(()) -} - -fn move_file(src: &Path, dst: &Path, flags: MOVE_FILE_FLAGS) -> Result<(), super::Error> { - let src_w = to_utf16_nul(&src.to_string_lossy()); - let dst_w = to_utf16_nul(&dst.to_string_lossy()); - let result = unsafe { - MoveFileExW( - PCWSTR::from_raw(src_w.as_ptr()), - PCWSTR::from_raw(dst_w.as_ptr()), - flags, - ) - }; - result.map_err(|e| { - super::Error::SwapFailed(format!( - "MoveFileExW({} -> {}): {e}", - src.display(), - dst.display() - )) + stage_and_swap(¤t, &staging, bytes, |staged| { + self_replace::self_replace(staged) }) } -fn backup_path(target: &Path) -> PathBuf { - let pid = unsafe { GetCurrentProcessId() }; - let fname = target - .file_name() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_else(|| "exe".to_string()); - let mut p = target.to_owned(); - p.set_file_name(format!("{fname}.old.{pid}")); - p -} - -fn surface_rollback_failure(backup: &Path, target_name: &str) { - // Pull the UI body text from i18n; the caller passes the - // user-meaningful filename so we can format it in-place. - let strings = crate::i18n::I18n::load().strings().clone(); - let body = format!( - "{}{}\n\n{}", - strings.update_rollback_failed_body, - backup.display(), - target_name - ); - let title_w = to_utf16_nul(&strings.update_failed); - let body_w = to_utf16_nul(&body); - unsafe { - MessageBoxW( - None, - PCWSTR::from_raw(body_w.as_ptr()), - PCWSTR::from_raw(title_w.as_ptr()), - MB_OK | MB_ICONERROR, - ); +/// Write `bytes` to `staging`, then `swap` it in for `target`. The staged +/// file is removed afterwards, whether the swap worked or not. +fn stage_and_swap( + target: &Path, + staging: &Path, + bytes: &[u8], + swap: impl FnOnce(&Path) -> io::Result<()>, +) -> Result<(), Error> { + ensure_writable(target)?; + // No shell parses these paths today; the rejection keeps any future + // hand-off through an external tool safe from `%`-expansion. + reject_unsafe_path(target)?; + reject_unsafe_path(staging)?; + if let Some(parent) = staging.parent() { + std::fs::create_dir_all(parent)?; } + std::fs::write(staging, bytes)?; + let swapped = swap(staging).map_err(|e| { + Error::SwapFailed(format!( + "self_replace({} -> {}): {e}", + staging.display(), + target.display() + )) + }); + if let Err(err) = std::fs::remove_file(staging) { + log::debug!("staged update {} not removed: {err}", staging.display()); + } + swapped } -fn stage_path() -> Result { - let base = dirs::data_local_dir().ok_or_else(|| { - super::Error::NotWritable("no local data directory available".to_string()) - })?; - Ok(base - .join("ClaudeCodeUsageBubble") - .join("updates") - .join("update.exe")) +/// Fail early when the install directory cannot take a new file, which +/// `self_replace` needs. +fn ensure_writable(target: &Path) -> Result<(), Error> { + let parent = target + .parent() + .ok_or_else(|| Error::NotWritable("could not resolve install directory".to_string()))?; + let probe = parent.join(".__bubble_update_probe"); + std::fs::write(&probe, b"").map_err(|e| Error::NotWritable(e.to_string()))?; + let _ = std::fs::remove_file(&probe); + Ok(()) } -fn update_error_log_path() -> Result { - let base = dirs::data_local_dir().ok_or_else(|| { - super::Error::NotWritable("no local data directory available".to_string()) - })?; - Ok(base - .join("ClaudeCodeUsageBubble") - .join("updates") - .join("update-error.log")) +fn reject_unsafe_path(p: &Path) -> Result<(), Error> { + let s = p.to_string_lossy(); + if s.contains('%') { + return Err(Error::UnsafePath(format!("path contains '%': {s}"))); + } + Ok(()) } -fn write_update_error(error: &super::Error) { - let Ok(path) = update_error_log_path() else { - return; +/// Record an apply failure in `update-error.log`, replacing the previous +/// entry. Failures to write it are only logged. +pub fn write_update_error(error: &impl Display) { + let path = match error_log_path() { + Ok(path) => path, + Err(err) => { + log::warn!("update error log skipped: {err}"); + return; + } }; if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } - let ts = std::time::SystemTime::now() + let unix_time = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or_default(); - let body = format!("unix_time={ts}\nerror={error}\n"); - let _ = std::fs::write(path, body); -} - -fn helper_path() -> Result { - let base = dirs::data_local_dir().ok_or_else(|| { - super::Error::NotWritable("no local data directory available".to_string()) - })?; - let pid = unsafe { GetCurrentProcessId() }; - Ok(base - .join("ClaudeCodeUsageBubble") - .join("updates") - .join(format!("updater-helper-{pid}.exe"))) -} - -fn prepare_update_helper(current: &Path, helper: &Path) -> Result<(), super::Error> { - if let Some(parent) = helper.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::copy(current, helper)?; - Ok(()) -} - -pub fn cleanup_staged_update_files() { - let Ok(stage) = stage_path() else { - return; - }; - let Some(dir) = stage.parent() else { - return; - }; - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - let name = entry.file_name(); - let name = name.to_string_lossy(); - if name == "update.exe" || (name.starts_with("updater-helper-") && name.ends_with(".exe")) { - if let Err(e) = std::fs::remove_file(&path) { - log::debug!("cleanup_staged_update_files: remove {:?} failed: {e}", path); - } - } + if let Err(err) = std::fs::write(&path, error_log_body(unix_time, error)) { + log::warn!("update error log {} not written: {err}", path.display()); } } -fn ensure_writable(target: &Path) -> Result<(), super::Error> { - let parent = target.parent().ok_or_else(|| { - super::Error::NotWritable("could not resolve install directory".to_string()) - })?; - let probe = parent.join(".__bubble_update_probe"); - std::fs::write(&probe, b"").map_err(|e| super::Error::NotWritable(e.to_string()))?; - let _ = std::fs::remove_file(&probe); - Ok(()) +/// The log format every release has written. +fn error_log_body(unix_time: u64, error: &impl Display) -> String { + format!("unix_time={unix_time}\nerror={error}\n") } #[cfg(test)] mod tests { use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn scratch_dir(tag: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let dir = + std::env::temp_dir().join(format!("ccub-install-{tag}-{}-{nanos}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create scratch dir"); + dir + } #[test] - fn stage_and_helper_paths_are_distinct_exes() { - let stage = stage_path().expect("stage path"); - let helper = helper_path().expect("helper path"); + fn staging_and_log_paths_sit_in_the_updates_dir() { + let (Ok(stage), Ok(log)) = (stage_path(), error_log_path()) else { + // No local data dir on this host; the error path is covered by + // the `NotWritable` mapping. + return; + }; + let dir = handoff::updates_dir().expect("updates dir"); + assert_eq!(stage, dir.join("update.exe")); + assert_eq!(log, dir.join("update-error.log")); + } - assert_eq!(stage.file_name().unwrap(), "update.exe"); - assert!(helper - .file_name() - .unwrap() - .to_string_lossy() - .starts_with("updater-helper-")); - assert_ne!(stage, helper); + #[test] + fn swap_gets_the_staged_bytes_and_the_stage_is_removed() { + let root = scratch_dir("swap"); + let target = root.join("app.exe"); + std::fs::write(&target, b"old").expect("target"); + let staging = root.join("updates").join("update.exe"); + + stage_and_swap(&target, &staging, b"new", |staged| { + std::fs::copy(staged, &target).map(|_| ()) + }) + .expect("swap"); + + assert_eq!(std::fs::read(&target).expect("read"), b"new"); + assert!(!staging.exists()); + std::fs::remove_dir_all(&root).expect("cleanup"); + } + + #[test] + fn failed_swap_keeps_the_target_and_removes_the_stage() { + let root = scratch_dir("fail"); + let target = root.join("app.exe"); + std::fs::write(&target, b"old").expect("target"); + let staging = root.join("update.exe"); + + let err = stage_and_swap(&target, &staging, b"new", |_| { + Err(io::Error::new(io::ErrorKind::PermissionDenied, "locked")) + }) + .expect_err("swap fails"); + + assert!(matches!(err, Error::SwapFailed(ref m) if m.contains("locked"))); + assert_eq!(std::fs::read(&target).expect("read"), b"old"); + assert!(!staging.exists()); + std::fs::remove_dir_all(&root).expect("cleanup"); + } + + #[test] + fn percent_paths_are_rejected_before_anything_is_written() { + let root = scratch_dir("unsafe"); + let target = root.join("app.exe"); + let staging = root.join("%TEMP%").join("update.exe"); + + let err = stage_and_swap(&target, &staging, b"new", |_| Ok(())).expect_err("rejected"); + + assert!(matches!(err, Error::UnsafePath(_))); + assert!(!staging.exists()); + std::fs::remove_dir_all(&root).expect("cleanup"); + } + + #[test] + fn missing_install_dir_is_not_writable() { + let root = scratch_dir("missing"); + let target = root.join("absent").join("app.exe"); + let err = stage_and_swap(&target, &root.join("update.exe"), b"new", |_| Ok(())) + .expect_err("not writable"); + assert!(matches!(err, Error::NotWritable(_))); + std::fs::remove_dir_all(&root).expect("cleanup"); + } + + #[test] + fn error_log_keeps_its_format() { + assert_eq!( + error_log_body(1_700_000_000, &"network: timed out"), + "unix_time=1700000000\nerror=network: timed out\n" + ); } } diff --git a/src/update/mod.rs b/src/update/mod.rs index 9b40623..c8cc0b2 100644 --- a/src/update/mod.rs +++ b/src/update/mod.rs @@ -1,19 +1,48 @@ -// Self-update subsystem. -// -// Two stages: `release::fetch_latest` checks GitHub releases for a newer -// build; `install::begin` downloads the .exe, swaps it in via `self_replace`, -// then spawns the new binary detached via `CreateProcessW`. No shell handoff — -// nothing can flash a console. +//! Self-update through `tauri-plugin-updater`. +//! +//! The plugin fetches `latest.json`, picks this platform's entry and +//! downloads it, checking the minisign signature against the configured +//! public key before handing the bytes back. Applying depends on the +//! install [`Channel`]: the Windows portable exe swaps itself through +//! `self_replace`, an AppImage goes through the plugin's install, and other +//! channels only report that an update exists. The caller relaunches with +//! `--updated-to` and exits. +//! +//! Every call here blocks; run it on a worker thread. pub mod channel; -pub mod handoff; pub mod install; -pub mod release; + +use std::time::Duration; + +use tauri::{AppHandle, Runtime, Url}; +use tauri_plugin_updater::{Update, UpdaterExt}; + +use crate::core::version::{self, Version}; + +pub use channel::{current as current_channel, Channel}; + +/// Budget for the whole check request. +pub const CHECK_TIMEOUT: Duration = Duration::from_secs(30); + +/// Budget for the whole download request. The plugin does not carry the +/// check builder's timeout over to the download (an `Update` starts with +/// none), so apply sets this one explicitly. +pub const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(600); + +/// Hosts the update requests go to: the manifest endpoint and the release +/// asset downloads it redirects to. A system proxy bypassing either is not +/// applied, so the system's own routing stays in charge. +const UPDATE_HOSTS: [&str; 3] = [ + "github.com", + "release-assets.githubusercontent.com", + "objects.githubusercontent.com", +]; #[derive(Debug, thiserror::Error)] pub enum Error { #[error("network: {0}")] - Network(#[from] crate::net::Error), + Network(String), #[error("io: {0}")] Io(#[from] std::io::Error), #[error("no matching release asset")] @@ -22,21 +51,257 @@ pub enum Error { NotWritable(String), #[error("malformed version: {0}")] BadVersion(String), - #[error("asset checksum mismatch: expected {expected}, got {actual}")] - ChecksumMismatch { expected: String, actual: String }, + #[error("signature check failed: {0}")] + Signature(String), #[error("path rejected for safety: {0}")] UnsafePath(String), #[error("file swap failed: {0}")] SwapFailed(String), + #[error("updates cannot be applied in place for this install")] + Unsupported, + #[error("relaunch after the update failed: {0}")] + Relaunch(std::io::Error), + #[error("updater: {0}")] + Updater(String), } -pub use channel::{current as current_channel, Channel}; -pub use install::run_cli; -pub use release::Release; +impl From for Error { + fn from(err: tauri_plugin_updater::Error) -> Self { + use tauri_plugin_updater::Error as E; + match err { + E::Io(e) => Error::Io(e), + E::Reqwest(_) + | E::Network(_) + | E::Http(_) + | E::ReleaseNotFound + | E::Serialization(_) + | E::UrlParse(_) + | E::EmptyEndpoints + | E::InsecureTransportProtocol => Error::Network(err.to_string()), + E::Semver(_) => Error::BadVersion(err.to_string()), + E::TargetNotFound(_) | E::TargetsNotFound(_) => Error::NoAsset, + E::Minisign(_) + | E::Base64(_) + | E::SignatureUtf8(_) + | E::SignedVersionMismatch { .. } + | E::MissingSignedVersion => Error::Signature(err.to_string()), + E::InvalidUpdaterFormat + | E::BinaryNotFoundInArchive + | E::TempDirNotFound + | E::TempDirNotOnSameMountPoint + | E::PreviousAppNotRestored(_) + | E::FailedToDetermineExtractPath => Error::SwapFailed(err.to_string()), + E::UnsupportedOs | E::UnsupportedArch => Error::Unsupported, + other => Error::Updater(other.to_string()), + } + } +} -/// Result of a release-check call. +/// Result of an update check. #[derive(Debug)] -pub enum CheckOutcome { +pub enum CheckOutcome { UpToDate, - Available(Release), + Available(T), +} + +/// Ask the update endpoint whether a newer release exists for this +/// platform. `proxy` overrides the plugin's own proxy discovery. +pub fn check(app: &AppHandle, proxy: Option) -> Result { + let mut builder = app + .updater_builder() + .version_comparator(|current, release| { + version::newer(&release.version.to_string(), ¤t.to_string()) + }) + .timeout(CHECK_TIMEOUT); + if let Some(proxy) = proxy { + builder = builder.proxy(proxy); + } + // The file to replace is the AppImage itself, not the executable + // inside its read-only mount. + #[cfg(target_os = "linux")] + if let Some(appimage) = std::env::var_os("APPIMAGE").filter(|v| !v.is_empty()) { + builder = builder.executable_path(appimage); + } + let updater = builder.build()?; + outcome_of(tauri::async_runtime::block_on(updater.check())) +} + +/// Map the plugin's check result. A manifest without an entry for this +/// platform (a deliberately Windows-only release, say) is not a failure: +/// there is simply nothing to install here. +pub fn outcome_of( + result: Result, tauri_plugin_updater::Error>, +) -> Result, Error> { + use tauri_plugin_updater::Error as E; + match result { + Ok(Some(update)) => Ok(CheckOutcome::Available(update)), + Ok(None) | Err(E::TargetNotFound(_) | E::TargetsNotFound(_)) => Ok(CheckOutcome::UpToDate), + Err(err) => Err(err.into()), + } +} + +/// Download `update`, which the plugin checks against the signing key, and +/// put it in place of the running binary. Returns the installed version for +/// `--updated-to`. The caller relaunches; nothing here exits. +pub fn apply(mut update: Update, channel: Channel) -> Result { + if !channel.can_apply() { + return Err(Error::Unsupported); + } + update.timeout = Some(DOWNLOAD_TIMEOUT); + log::info!( + "downloading update {} from {}", + update.version, + update.download_url + ); + let bytes = tauri::async_runtime::block_on(update.download(|_, _| {}, || {}))?; + log::info!("update {} downloaded and verified", update.version); + match channel { + Channel::Portable => install::replace_portable(&bytes)?, + // `install` checks the signature again before swapping the file. + Channel::AppImage => update.install(&bytes)?, + Channel::PackageManager | Channel::Unsupported => return Err(Error::Unsupported), + } + Ok(Version::parse(&update.version)) +} + +/// The proxy for the update requests: the system proxy, unless an update +/// host bypasses it. Pass `None` when an environment proxy is set, which the +/// plugin reads itself. +pub fn proxy_url(system: Option<&crate::core::proxy::SystemProxy>) -> Option { + let system = system?; + if UPDATE_HOSTS.iter().any(|host| system.bypasses(host)) { + return None; + } + match Url::parse(&system.url) { + Ok(url) => Some(url), + Err(err) => { + // The proxy URL is not logged: it may carry credentials. + log::warn!("system proxy ignored for updates, not a valid URL: {err}"); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::proxy::SystemProxy; + use tauri_plugin_updater::Error as E; + + #[test] + fn missing_platform_entry_is_up_to_date() { + assert!(matches!( + outcome_of::<()>(Err(E::TargetNotFound("windows-x86_64".into()))), + Ok(CheckOutcome::UpToDate) + )); + assert!(matches!( + outcome_of::<()>(Err(E::TargetsNotFound(vec!["linux-x86_64".into()]))), + Ok(CheckOutcome::UpToDate) + )); + assert!(matches!( + outcome_of::<()>(Ok(None)), + Ok(CheckOutcome::UpToDate) + )); + assert!(matches!( + outcome_of(Ok(Some(7))), + Ok(CheckOutcome::Available(7)) + )); + } + + #[test] + fn other_check_errors_fail() { + assert!(matches!( + outcome_of::<()>(Err(E::ReleaseNotFound)), + Err(Error::Network(_)) + )); + assert!(matches!( + outcome_of::<()>(Err(E::Network("503".into()))), + Err(Error::Network(_)) + )); + } + + #[test] + fn plugin_errors_map_onto_the_update_errors() { + let io = std::io::Error::other("disk"); + assert!(matches!(Error::from(E::Io(io)), Error::Io(_))); + assert!(matches!( + Error::from(E::SignatureUtf8("x".into())), + Error::Signature(_) + )); + assert!(matches!( + Error::from(E::SignedVersionMismatch { + signed: "0.6.0".into(), + announced: "0.7.0".into() + }), + Error::Signature(_) + )); + assert!(matches!( + Error::from(E::InvalidUpdaterFormat), + Error::SwapFailed(_) + )); + assert!(matches!(Error::from(E::UnsupportedOs), Error::Unsupported)); + assert!(matches!( + Error::from(E::TargetNotFound("t".into())), + Error::NoAsset + )); + assert!(matches!( + Error::from(E::AuthenticationFailed), + Error::Updater(_) + )); + } + + #[test] + fn error_text_keeps_the_log_prefixes() { + assert_eq!( + Error::from(E::Network("timed out".into())).to_string(), + "network: `timed out`" + ); + assert_eq!( + Error::NotWritable("denied".into()).to_string(), + "install location not writable: denied" + ); + } + + fn proxy(bypass: &[&str]) -> SystemProxy { + SystemProxy { + url: "http://proxy.corp:8080".into(), + bypass: bypass.iter().map(|s| s.to_string()).collect(), + } + } + + #[test] + fn update_proxy_honours_the_bypass_list() { + assert_eq!(proxy_url(None), None); + assert_eq!( + proxy_url(Some(&proxy(&[]))).map(|u| u.to_string()), + Some("http://proxy.corp:8080/".into()) + ); + assert!(proxy_url(Some(&proxy(&["*.example.com"]))).is_some()); + assert_eq!(proxy_url(Some(&proxy(&["github.com"]))), None); + assert_eq!(proxy_url(Some(&proxy(&["*.githubusercontent.com"]))), None); + } + + #[test] + fn config_points_the_plugin_at_the_release_manifest() { + let config: serde_json::Value = + serde_json::from_str(include_str!("../../tauri.conf.json")).expect("tauri.conf.json"); + assert_eq!( + config.pointer("/plugins/updater/endpoints"), + Some(&serde_json::json!([ + "https://github.com/tiennm99/claude-code-usage-bubble/releases/latest/download/latest.json" + ])) + ); + // The plugin parses this when it is registered; a parse error + // would stop the app from starting. + let plugin: tauri_plugin_updater::Config = + serde_json::from_value(config["plugins"]["updater"].clone()) + .expect("updater plugin config"); + assert_eq!(plugin.endpoints.len(), 1); + // Only the release workflow turns updater artifacts on, so local + // bundle builds need no signing key. + assert_eq!( + config.pointer("/bundle/createUpdaterArtifacts"), + Some(&serde_json::Value::Bool(false)) + ); + } } diff --git a/src/update/release.rs b/src/update/release.rs deleted file mode 100644 index d6ac48b..0000000 --- a/src/update/release.rs +++ /dev/null @@ -1,119 +0,0 @@ -// Query the GitHub Releases API and pick the relevant asset. - -use serde::Deserialize; - -use crate::net::Client; - -const ASSET_NAME: &str = "claude-code-usage-bubble.exe"; -const REPO_OWNER: &str = "tiennm99"; -const REPO_NAME: &str = "claude-code-usage-bubble"; - -#[derive(Clone, Debug)] -pub struct Release { - pub version: Version, - pub asset_url: String, - /// SHA-256 of the asset bytes, parsed from the GitHub Releases - /// API `digest` field. `None` if GitHub omitted it (older - /// releases predate the digest field). - pub asset_sha256: Option<[u8; 32]>, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub struct Version { - pub major: u32, - pub minor: u32, - pub patch: u32, -} - -impl Version { - pub fn current() -> Self { - Self::parse(env!("CARGO_PKG_VERSION")).unwrap_or(Version { - major: 0, - minor: 0, - patch: 0, - }) - } - - pub fn parse(s: &str) -> Option { - let core = s.trim().trim_start_matches('v').split('-').next()?; - let mut parts = core.split('.').map(|p| p.parse::().ok()); - Some(Version { - major: parts.next().flatten().unwrap_or(0), - minor: parts.next().flatten().unwrap_or(0), - patch: parts.next().flatten().unwrap_or(0), - }) - } -} - -pub fn fetch_latest(http: &Client) -> Result { - let url = format!("https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/releases/latest"); - let resp = http - .get(&url) - .header("Accept", "application/vnd.github+json") - .header("X-GitHub-Api-Version", "2022-11-28") - .header("User-Agent", user_agent()) - .send()?; - if !(200..300).contains(&resp.status()) { - return Err(super::Error::Network(crate::net::Error::Status(resp.status()))); - } - let body: GhRelease = resp.json()?; - let candidate = Version::parse(&body.tag_name) - .ok_or_else(|| super::Error::BadVersion(body.tag_name.clone()))?; - if candidate <= Version::current() { - return Ok(super::CheckOutcome::UpToDate); - } - let asset = body - .assets - .iter() - .find(|a| a.name.eq_ignore_ascii_case(ASSET_NAME)) - .or_else(|| { - body.assets - .iter() - .find(|a| a.name.to_ascii_lowercase().ends_with(".exe")) - }) - .ok_or(super::Error::NoAsset)?; - Ok(super::CheckOutcome::Available(Release { - version: candidate, - asset_url: asset.browser_download_url.clone(), - asset_sha256: asset.digest.as_deref().and_then(parse_sha256_digest), - })) -} - -/// Parse a GitHub `digest` field of the form `"sha256:<64 hex chars>"` -/// into a 32-byte array. Returns `None` for any other algorithm or -/// malformed input — callers should treat a missing digest as "no -/// integrity check available" rather than as a parse failure. -fn parse_sha256_digest(raw: &str) -> Option<[u8; 32]> { - let hex = raw.strip_prefix("sha256:")?; - if hex.len() != 64 { - return None; - } - let mut out = [0u8; 32]; - for (i, byte_chars) in hex.as_bytes().chunks(2).enumerate() { - let high = (byte_chars[0] as char).to_digit(16)?; - let low = (byte_chars[1] as char).to_digit(16)?; - out[i] = ((high << 4) | low) as u8; - } - Some(out) -} - -pub fn user_agent() -> &'static str { - concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")) -} - -#[derive(Deserialize)] -struct GhRelease { - tag_name: String, - assets: Vec, -} - -#[derive(Deserialize)] -struct GhAsset { - name: String, - browser_download_url: String, - /// GitHub started returning `digest: "sha256:..."` on the asset - /// object in 2024. Older releases omit it; we treat that as - /// "verification unavailable" rather than a hard error. - #[serde(default)] - digest: Option, -} diff --git a/tauri.conf.json b/tauri.conf.json index b03c472..1ffb770 100644 --- a/tauri.conf.json +++ b/tauri.conf.json @@ -16,7 +16,9 @@ }, "bundle": { "active": true, - "targets": ["appimage", "deb"], + "targets": ["appimage", + "deb" + ], "createUpdaterArtifacts": false, "copyright": "Copyright (C) 2026 tiennm99", "publisher": "tiennm99", @@ -28,5 +30,13 @@ "icons/icon.icns", "icons/icon.ico" ] + }, + "plugins": { + "updater": { + "pubkey": "", + "endpoints": [ + "https://github.com/tiennm99/claude-code-usage-bubble/releases/latest/download/latest.json" + ] + } } }