mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-21 20:16:50 +00:00
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:
1 parent
43e0d825a7
commit
d5fe3d8f27
13 files changed
+1054
-491
No files matched your search
Generated
+198
-6
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -9,4 +9,5 @@ pub mod proxy;
|
||||
pub mod settings;
|
||||
pub mod state;
|
||||
pub mod usage_color;
|
||||
pub mod version;
|
||||
pub mod visibility;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(¤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 <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(¤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<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
@@ -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(), ¤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<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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user