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.
This commit is contained in:
tiennm99 committed 2026-10-21 16:24:32 +07:00
1 parent 43e0d825a7
commit d5fe3d8f27
13 files changed
+1054 -491

No files matched your search

Generated
+198 -6
View File
@@ -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"
+1 -1
View File
@@ -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"
+38 -1
View File
@@ -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>) -> 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<MenuEntry> {
let MenuInput {
@@ -166,6 +176,7 @@ pub fn build(input: MenuInput<'_>) -> Vec<MenuEntry> {
strings: s,
autostart_enabled,
update_status,
update_can_apply,
} = input;
let freq_labels = [
@@ -230,7 +241,7 @@ pub fn build(input: MenuInput<'_>) -> Vec<MenuEntry> {
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);
+1
View File
@@ -9,4 +9,5 @@ pub mod proxy;
pub mod settings;
pub mod state;
pub mod usage_color;
pub mod version;
pub mod visibility;
+102
View File
@@ -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::<u32>().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");
}
}
+1
View File
@@ -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.
+4
View File
@@ -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,
}
}
+175 -6
View File
@@ -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<Alert>,
@@ -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<Option<tauri_plugin_updater::Update>>,
scheduler: Sender<Message>,
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<Self>) {
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<Self>) {
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<Self>) {
// 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<dyn Error>> {
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<dyn Error>> {
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);
+64 -8
View File
@@ -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<OsString>) -> 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<OsString> {
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());
}
}
+177 -332
View File
@@ -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<PathBuf, Error> {
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<PathBuf, Error> {
updates_file(ERROR_LOG)
}
fn updates_file(name: &str) -> Result<PathBuf, Error> {
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(&current)?;
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(&current)?;
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(&current, &release.version)?;
let _ = std::fs::remove_file(&staging);
Ok(())
}
/// CLI entry point for `--apply-update <target> <source> <parent-pid> <version>`.
/// 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<i32> {
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::<u32>().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(&current, &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<PathBuf, super::Error> {
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<PathBuf, super::Error> {
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<PathBuf, super::Error> {
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"
);
}
}
+282 -17
View File
@@ -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<tauri_plugin_updater::Error> 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<T = Update> {
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<R: Runtime>(app: &AppHandle<R>, proxy: Option<Url>) -> Result<CheckOutcome, Error> {
let mut builder = app
.updater_builder()
.version_comparator(|current, release| {
version::newer(&release.version.to_string(), &current.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<T>(
result: Result<Option<T>, tauri_plugin_updater::Error>,
) -> Result<CheckOutcome<T>, 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<Version, Error> {
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<Url> {
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))
);
}
}
-119
View File
@@ -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<Self> {
let core = s.trim().trim_start_matches('v').split('-').next()?;
let mut parts = core.split('.').map(|p| p.parse::<u32>().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<super::CheckOutcome, super::Error> {
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<GhAsset>,
}
#[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<String>,
}
+11 -1
View File
@@ -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"
]
}
}
}