From eb9f76df23bdebb0ead51382b8d0e69f28f55761 Mon Sep 17 00:00:00 2001 From: Marcel Losso Date: Thu, 29 Oct 2026 01:51:34 -0300 Subject: [PATCH 1/7] Let a client retire the shared core, so it exits as soon as its last client leaves --- .../Sources/LocalhostCore/Targets.swift | 6 ++++++ core/localhost-core/src/ffi/objects.rs | 5 +++++ core/localhost-core/src/remote/client.rs | 6 ++++++ core/localhost-core/src/remote/mod.rs | 6 +++++- core/localhost-core/src/remote/server.rs | 12 ++++++++++++ core/localhost-core/src/remote/shared/mod.rs | 7 +++++-- core/localhost-core/src/targets.rs | 12 ++++++++++++ core/localhost-core/tests/shared.rs | 17 +++++++++++++++++ 8 files changed, 68 insertions(+), 3 deletions(-) diff --git a/apps/macos/LocalhostCoreFFI/Sources/LocalhostCore/Targets.swift b/apps/macos/LocalhostCoreFFI/Sources/LocalhostCore/Targets.swift index 76235e9..a09008d 100644 --- a/apps/macos/LocalhostCoreFFI/Sources/LocalhostCore/Targets.swift +++ b/apps/macos/LocalhostCoreFFI/Sources/LocalhostCore/Targets.swift @@ -75,6 +75,12 @@ extension Targets { await ffiStopEveryRun(origin: origin) } + /// Asks the shared core to exit as soon as no client is connected, so + /// the version an update relaunches starts a core of its own. + public func retireSharedCore() async { + await ffiRetireSharedCore() + } + public func setAutomaticPortMove(_ isEnabled: Bool) { ffiSetAutomaticPortMove(isEnabled: isEnabled) } diff --git a/core/localhost-core/src/ffi/objects.rs b/core/localhost-core/src/ffi/objects.rs index 06bf918..2784dcd 100644 --- a/core/localhost-core/src/ffi/objects.rs +++ b/core/localhost-core/src/ffi/objects.rs @@ -490,6 +490,11 @@ impl Targets { on_runtime(async move { self.stop_every_run(origin).await }).await } + #[uniffi::method] + pub async fn ffi_retire_shared_core(self: Arc) { + on_runtime(async move { self.retire_shared_core().await }).await + } + #[uniffi::method] pub fn ffi_set_automatic_port_move(&self, is_enabled: bool) { self.set_automatic_port_move(is_enabled) diff --git a/core/localhost-core/src/remote/client.rs b/core/localhost-core/src/remote/client.rs index 0eba9a4..5f86309 100644 --- a/core/localhost-core/src/remote/client.rs +++ b/core/localhost-core/src/remote/client.rs @@ -305,6 +305,12 @@ impl Remote { self.call(Request::StopEveryRun { origin }).await } + /// Asks the shared core to exit as soon as no client is connected, e.g. + /// before the app quits to install an update. + pub async fn retire(&self) -> Result<(), LocalhostError> { + self.call(Request::Retire).await + } + pub async fn set_automatic_port_move(&self, is_enabled: bool) -> Result<(), LocalhostError> { self.call(Request::SetAutomaticPortMove { is_enabled }).await } diff --git a/core/localhost-core/src/remote/mod.rs b/core/localhost-core/src/remote/mod.rs index eb4d16c..20f5dfc 100644 --- a/core/localhost-core/src/remote/mod.rs +++ b/core/localhost-core/src/remote/mod.rs @@ -37,7 +37,7 @@ use crate::{ }; /// Changes whenever a message changes shape; both ends must speak the same. -pub const PROTOCOL_VERSION: u32 = 4; +pub const PROTOCOL_VERSION: u32 = 5; /// What a connection is, which decides what its disconnecting stops. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -127,6 +127,10 @@ pub(crate) enum Request { StopEveryRun { origin: Origin, }, + /// Exit as soon as no client is connected rather than after the idle + /// time: the app is being replaced by an update, and the one it + /// relaunches should start a core of its own version. + Retire, SetAutomaticPortMove { is_enabled: bool, }, diff --git a/core/localhost-core/src/remote/server.rs b/core/localhost-core/src/remote/server.rs index e348903..ca10506 100644 --- a/core/localhost-core/src/remote/server.rs +++ b/core/localhost-core/src/remote/server.rs @@ -4,6 +4,7 @@ use std::collections::{HashMap, HashSet}; use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, MutexGuard}; use std::task::Poll; use std::time::Duration; @@ -37,6 +38,8 @@ pub(crate) struct Core { manager: ServiceManager, stop_policy: StopPolicy, connections: Mutex, + /// A client asked it to exit once none is connected (`Request::Retire`). + is_retiring: AtomicBool, } #[derive(Default)] @@ -86,6 +89,7 @@ impl Core { manager, stop_policy, connections: Mutex::new(Connections::default()), + is_retiring: AtomicBool::new(false), }) } @@ -97,6 +101,10 @@ impl Core { self.connections().clients.len() } + pub(crate) fn is_retiring(&self) -> bool { + self.is_retiring.load(Ordering::Relaxed) + } + pub(crate) async fn stop_every_run(&self) { self.connections().holders.clear(); self.manager.stop_every_run(Client::App.origin()).await; @@ -303,6 +311,10 @@ impl Connection { core.connections().holders.clear(); done() } + Request::Retire => { + core.is_retiring.store(true, Ordering::Relaxed); + done() + } Request::SetAutomaticPortMove { is_enabled } => { // An app setting: an agent's or the command's default doesn't override it. if self.client() == Client::App { diff --git a/core/localhost-core/src/remote/shared/mod.rs b/core/localhost-core/src/remote/shared/mod.rs index 234fa00..13403cd 100644 --- a/core/localhost-core/src/remote/shared/mod.rs +++ b/core/localhost-core/src/remote/shared/mod.rs @@ -14,8 +14,10 @@ //! //! Its lifetime follows its clients. A client disconnecting stops the Runs //! nobody needs any more (`Client`); once none has been connected for -//! `IDLE_TIMEOUT`, it exits. Being told to end (SIGTERM, SIGINT, SIGHUP; on -//! Windows its console closing, logoff or shutdown) stops every Run first. +//! `IDLE_TIMEOUT`, it exits, or at once if a client asked it to retire (an +//! app about to install an update). Being told to end (SIGTERM, SIGINT, +//! SIGHUP; on Windows its console closing, logoff or shutdown) stops every +//! Run first. #[cfg(unix)] mod unix; @@ -186,6 +188,7 @@ pub async fn serve( tokio::pin!(shutdown); loop { let is_idle = core.connection_count() == 0; + let idle_timeout = if core.is_retiring() { Duration::ZERO } else { idle_timeout }; tokio::select! { accepted = transport::accept(&mut listener) => match accepted { Ok(Some((reader, writer))) => { diff --git a/core/localhost-core/src/targets.rs b/core/localhost-core/src/targets.rs index 397e090..9e0a0a5 100644 --- a/core/localhost-core/src/targets.rs +++ b/core/localhost-core/src/targets.rs @@ -272,6 +272,18 @@ impl Targets { stops.join_all().await; } + /// Asks this machine's shared core to exit as soon as no client is + /// connected, instead of after its idle time: the app is about to quit + /// to install an update, and the version it relaunches should start a + /// core of its own rather than reach this one. Nothing to do when the + /// core isn't connected, or runs in this process. + pub async fn retire_shared_core(&self) { + let shared = self.inner.connected().into_iter().find(|(target, _)| *target == ExecutionTarget::ThisMachine); + if let Some((_, remote)) = shared { + let _ = remote.retire().await; + } + } + /// An app setting, on by default; a target connected later gets it too. /// May be called outside the runtime. pub fn set_automatic_port_move(&self, is_enabled: bool) { diff --git a/core/localhost-core/tests/shared.rs b/core/localhost-core/tests/shared.rs index 1eb301d..554f838 100644 --- a/core/localhost-core/tests/shared.rs +++ b/core/localhost-core/tests/shared.rs @@ -191,6 +191,23 @@ fn the_core_exits_once_no_client_has_been_connected_for_a_while() { }); } +#[test] +fn a_retired_core_exits_as_soon_as_its_last_client_leaves() { + within_patience(async { + // Far longer than the test may take: only retiring ends it in time. + let setup = setup(PATIENCE * 10).await; + let app = setup.connect(Client::App).await; + let agent = setup.connect(Client::Agent).await; + app.retire().await.unwrap(); + drop(app); + settle().await; + assert!(!setup.core.is_finished(), "the agent still needs it"); + + drop(agent); + tokio::time::timeout(PATIENCE, setup.core).await.expect("the core exits").unwrap(); + }); +} + #[test] fn one_core_per_storage_root() { let fixture = Fixture::new(); From dfc8eb69d3f525289854e6849183d699da66a48f Mon Sep 17 00:00:00 2001 From: Marcel Losso Date: Thu, 29 Oct 2026 01:53:14 -0300 Subject: [PATCH 2/7] Check for, download and install updates in the Linux and Windows app, stopping every Service first --- apps/desktop/src-tauri/Cargo.lock | 472 +++++++++++++++- apps/desktop/src-tauri/Cargo.toml | 1 + apps/desktop/src-tauri/src/lib.rs | 19 +- apps/desktop/src-tauri/src/lifecycle.rs | 6 + apps/desktop/src-tauri/src/settings.rs | 11 +- apps/desktop/src-tauri/src/updates.rs | 524 ++++++++++++++++++ apps/desktop/src-tauri/tauri.conf.json | 8 + apps/desktop/src/App.tsx | 3 +- apps/desktop/src/bindings.ts | 78 ++- apps/desktop/src/components/Alerts.tsx | 15 + .../desktop/src/components/SettingsDialog.tsx | 16 + apps/desktop/src/components/Sidebar.tsx | 9 +- .../src/components/UpdateNotice.module.css | 55 ++ .../src/components/UpdateNotice.test.tsx | 67 +++ apps/desktop/src/components/UpdateNotice.tsx | 74 +++ apps/desktop/src/palette.ts | 8 + apps/desktop/src/settings.ts | 8 +- apps/desktop/src/ui.ts | 4 +- apps/desktop/src/updates.test.ts | 29 + apps/desktop/src/updates.ts | 46 ++ 20 files changed, 1421 insertions(+), 32 deletions(-) create mode 100644 apps/desktop/src-tauri/src/updates.rs create mode 100644 apps/desktop/src/components/UpdateNotice.module.css create mode 100644 apps/desktop/src/components/UpdateNotice.test.tsx create mode 100644 apps/desktop/src/components/UpdateNotice.tsx create mode 100644 apps/desktop/src/updates.test.ts create mode 100644 apps/desktop/src/updates.ts diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 218e975..66e6f7c 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -567,6 +567,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" @@ -590,7 +600,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", @@ -603,7 +613,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", ] @@ -858,7 +868,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1029,7 +1039,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1077,6 +1087,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.14" @@ -1335,6 +1355,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1637,6 +1668,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" @@ -1656,9 +1702,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1952,6 +2000,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2125,6 +2203,7 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-single-instance", + "tauri-plugin-updater", "tauri-specta", "tokio", "webview2-com", @@ -2178,6 +2257,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" @@ -2227,7 +2312,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.21", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2506,6 +2591,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" @@ -2569,6 +2666,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + [[package]] name = "option-ext" version = "0.2.0" @@ -2585,6 +2688,20 @@ dependencies = [ "pin-project-lite", ] +[[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" @@ -2996,15 +3113,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", @@ -3040,6 +3162,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3065,7 +3201,80 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -3083,6 +3292,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3166,7 +3384,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", @@ -3425,6 +3643,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.4" @@ -3450,7 +3684,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3582,6 +3816,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.9" @@ -3645,6 +3885,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" @@ -3666,7 +3927,7 @@ checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400" dependencies = [ "bitflags 2.13.2", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -3676,7 +3937,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3709,6 +3970,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" @@ -3732,7 +4004,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3903,6 +4175,39 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d957abbcb3f9f6c76d22a3a0f012f30880640d0bccb5634d2b12a9937e4c872b" +dependencies = [ + "base64 0.23.1", + "dirs", + "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", +] + [[package]] name = "tauri-runtime" version = "2.12.1" @@ -3913,7 +4218,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3936,7 +4241,7 @@ checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -4041,7 +4346,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4166,6 +4471,16 @@ dependencies = [ "syn 3.0.9", ] +[[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" @@ -4370,7 +4685,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.21", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4399,7 +4714,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4423,6 +4738,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4677,6 +4998,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.39.1" @@ -4733,7 +5063,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4754,7 +5084,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "windows-sys 0.61.2", + "windows-sys 0.60.2", "windows-version", ] @@ -4841,6 +5171,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -4868,6 +5209,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -4901,6 +5251,22 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -4911,7 +5277,7 @@ dependencies = [ "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.53.1", "windows_i686_msvc 0.53.1", "windows_x86_64_gnu 0.53.1", "windows_x86_64_gnullvm 0.53.1", @@ -4942,6 +5308,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" @@ -4954,6 +5326,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" @@ -4966,12 +5344,24 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" @@ -4984,6 +5374,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" @@ -4996,6 +5392,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" @@ -5008,6 +5410,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" @@ -5020,6 +5428,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" @@ -5051,7 +5465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ "cfg-if", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5084,7 +5498,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -5131,6 +5545,16 @@ dependencies = [ "pkg-config", ] +[[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" @@ -5245,6 +5669,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 1c7173c..9d68d88 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ specta-typescript = "=0.0.9" tauri = { version = "2.12", features = ["tray-icon"] } tauri-plugin-dialog = "2.8" tauri-plugin-single-instance = "2" +tauri-plugin-updater = { version = "2.14", default-features = false, features = ["rustls-tls", "system-proxy"] } tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] } tokio = { version = "1", features = ["sync", "macros", "time", "process", "rt"] } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 00212fe..b915a12 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -20,6 +20,7 @@ mod services; mod settings; mod targets; mod tray; +mod updates; mod wsl; pub use targets::DAEMON_ARGUMENT; @@ -63,8 +64,8 @@ impl Core { } /// What the commands share: the core, or why it couldn't be opened (the UI -/// says so), the open log streams, the launch questions, the settings and -/// the app's lifetime. +/// says so), the open log streams, the launch questions, the settings, the +/// update on offer and the app's lifetime. pub struct AppState { core: Result, logs: logs::LogStreams, @@ -72,6 +73,7 @@ pub struct AppState { lifecycle: lifecycle::Lifecycle, launch: Mutex, settings: settings::SettingsStore, + updates: updates::Updates, perf: Option, } @@ -150,8 +152,13 @@ fn specta_builder() -> tauri_specta::Builder { perf::finish_perf_run, lifecycle::quit_app, lifecycle::take_reveal, + updates::update_state, + updates::check_for_updates, + updates::put_off_update, + updates::download_update, + updates::install_update, ]) - .events(collect_events![lifecycle::Reveal, launch::OrphansFound]) + .events(collect_events![lifecycle::Reveal, launch::OrphansFound, updates::UpdateState]) } pub fn run() { @@ -161,6 +168,7 @@ pub fn run() { // First, so a second launch only brings the window forward. .plugin(tauri_plugin_single_instance::init(|app, _, _| lifecycle::show_window(app, None))) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) .invoke_handler(builder.invoke_handler()) .setup(move |app| { builder.mount_events(app); @@ -177,6 +185,7 @@ pub fn run() { Ok(core) if perf.is_none() => core.targets.orphans(), _ => Vec::new(), }; + let is_measuring = perf.is_some(); let settings = settings::SettingsStore::open( app.path().app_config_dir().ok().map(|folder| folder.join("settings.json")), ); @@ -190,6 +199,7 @@ pub fn run() { lifecycle: lifecycle::Lifecycle::default(), launch: launch::Launch::new(orphans), settings, + updates: updates::Updates::default(), perf, }); if let Some(window) = app.get_webview_window(lifecycle::MAIN_WINDOW) { @@ -197,6 +207,9 @@ pub fn run() { } tray::install(app)?; launch::watch_found_orphans(app.handle()); + if !is_measuring { + updates::watch(app.handle()); + } Ok(()) }) .on_window_event(|window, event| match event { diff --git a/apps/desktop/src-tauri/src/lifecycle.rs b/apps/desktop/src-tauri/src/lifecycle.rs index 6d3c66b..8b90351 100644 --- a/apps/desktop/src-tauri/src/lifecycle.rs +++ b/apps/desktop/src-tauri/src/lifecycle.rs @@ -227,6 +227,12 @@ pub fn exit_now(app: &AppHandle) { app.exit(0); } +/// Lets the next exit through without asking, e.g. the restart that +/// installs an update, once it has stopped every Run itself. +pub fn allow_exit(app: &AppHandle) { + app.state::().lifecycle.is_exiting.store(true, Ordering::SeqCst); +} + /// Whether an exit Tauri is about to make may go ahead. `code` is `None` /// when the last window closed, which keeps the app running; an /// `AppHandle::exit` that didn't come through `quit` asks first. diff --git a/apps/desktop/src-tauri/src/settings.rs b/apps/desktop/src-tauri/src/settings.rs index af1abd5..c455f28 100644 --- a/apps/desktop/src-tauri/src/settings.rs +++ b/apps/desktop/src-tauri/src/settings.rs @@ -24,6 +24,8 @@ pub struct Settings { pub appearance: Appearance, /// Points, for the log and the commands in Service rows. pub log_font_size: u8, + /// Whether localhost checks for a new version at launch and every few hours. + pub automatic_update_checks: bool, } #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, Type)] @@ -41,7 +43,13 @@ pub const LOG_FONT_SIZES: [u8; 6] = [10, 11, 12, 13, 14, 16]; impl Default for Settings { fn default() -> Settings { - Settings { editor_command: None, automatic_port_move: true, appearance: Appearance::System, log_font_size: 12 } + Settings { + editor_command: None, + automatic_port_move: true, + appearance: Appearance::System, + log_font_size: 12, + automatic_update_checks: true, + } } } @@ -155,6 +163,7 @@ mod tests { automatic_port_move: false, appearance: Appearance::Dark, log_font_size: 14, + automatic_update_checks: false, }; store.save(changed.clone()).unwrap(); assert_eq!(SettingsStore::open(Some(path)).get(), changed); diff --git a/apps/desktop/src-tauri/src/updates.rs b/apps/desktop/src-tauri/src/updates.rs new file mode 100644 index 0000000..f5ab92d --- /dev/null +++ b/apps/desktop/src-tauri/src/updates.rs @@ -0,0 +1,524 @@ +//! Updates from GitHub Releases. The release workflow publishes +//! `latest.json` with every release, each package signed with the +//! updater key; the app checks it at launch and every few hours (unless +//! turned off in Settings), and on Check for Updates. A newer version shows +//! a notice; Update Now downloads it in the background and verifies it +//! against the public key built into the app; Restart to Update installs it. +//! +//! The AppImage and the Windows installer update in place. A `.deb` or an +//! `.rpm` belongs to the package manager, so its notice links to the +//! release instead. A build without the public key (any local build) +//! doesn't check at all. +//! +//! Installing restarts the app, so, like quitting, it stops every Service +//! first, after saying which ones are running. Then it retires the shared +//! core, so the new version starts a core of its own instead of reaching +//! the old one; on Windows the installer would close that core anyway, as +//! it runs from the app's executable. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant, SystemTime}; + +use localhost_core::{Id, Origin, Project, ServiceStatus}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use tauri::utils::config::BundleType; +use tauri::{AppHandle, Manager}; +use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; +use tauri_plugin_updater::{Update, UpdaterExt}; +use tauri_specta::Event; + +use crate::{AppState, lifecycle}; + +/// How long after launch the first automatic check waits, so it doesn't +/// compete with the window opening. +const FIRST_CHECK_DELAY: Duration = Duration::from_secs(10); +/// How often automatic checks run. +const CHECK_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60); +/// How often the automatic checker wakes to see whether a check is due. +/// Wall-clock time, so a check missed while the computer slept runs soon +/// after it wakes. +const CHECKER_TICK: Duration = Duration::from_secs(15 * 60); +/// How often download progress reaches the window, at most. +const PROGRESS_INTERVAL: Duration = Duration::from_millis(250); + +/// Where a release's notes are when `latest.json` doesn't say. +const RELEASES: &str = "https://github.com/marcellosso/localhost/releases"; + +/// What the update notice shows; also the event sent when it changes. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type, Event)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum UpdateState { + /// No newer version known, or the notice was put off with Later. + #[default] + None, + /// A newer version. `can_install` is false where a package manager + /// installs it (`.deb`, `.rpm`); `problem` says why a download failed. + #[serde(rename_all = "camelCase")] + Available { version: String, notes_url: String, can_install: bool, problem: Option }, + /// `progress` is from 0 to 1, `None` while the size isn't known. + #[serde(rename_all = "camelCase")] + Downloading { version: String, notes_url: String, progress: Option }, + /// Downloaded and verified: restarting installs it. + #[serde(rename_all = "camelCase")] + Ready { version: String, notes_url: String }, +} + +/// What Check for Updates found. +#[derive(Clone, Debug, PartialEq, Serialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum UpdateCheck { + /// The notice shows it. + Available { + version: String, + }, + UpToDate { + version: String, + }, + /// The check couldn't be made, or this build doesn't update itself. + Failed { + message: String, + }, +} + +#[derive(Default)] +pub struct Updates { + inner: Mutex, +} + +#[derive(Default)] +struct Inner { + state: UpdateState, + /// The update the notice offers, and its package once downloaded. + update: Option, + package: Option>, + /// The version Later put off; automatic checks don't offer it again. + put_off: Option, + last_check: Option, + is_checking: bool, + is_installing: bool, +} + +impl Updates { + fn inner(&self) -> std::sync::MutexGuard<'_, Inner> { + self.inner.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + pub fn state(&self) -> UpdateState { + self.inner().state.clone() + } +} + +/// Whether this build can check for updates: only the release workflow +/// builds it with the updater's public key. +fn is_configured(app: &AppHandle) -> bool { + let config = app.config().plugins.0.get("updater"); + config + .and_then(|config| config.get("pubkey")) + .and_then(|key| key.as_str()) + .is_some_and(|key| !key.trim().is_empty()) +} + +/// Whether this package replaces itself: the AppImage and the Windows +/// installer do, the package manager installs a `.deb` or an `.rpm`. +fn installs_itself(bundle: Option) -> bool { + matches!(bundle, Some(BundleType::AppImage | BundleType::Nsis)) +} + +/// The release's page: `releaseNotesUrl` in `latest.json`, which the +/// release workflow writes, else the tag's page on GitHub. +fn notes_url(manifest: &serde_json::Value, version: &str) -> String { + match manifest.get("releaseNotesUrl").and_then(|url| url.as_str()) { + Some(url) if url.starts_with("https://") => url.to_string(), + _ => format!("{RELEASES}/tag/v{version}"), + } +} + +fn set_state(app: &AppHandle, inner: &mut Inner, state: UpdateState) { + if inner.state != state { + inner.state = state.clone(); + let _ = state.emit(app); + } +} + +/// Checks at launch and then every few hours, while Settings allows it. +pub fn watch(app: &AppHandle) { + if !is_configured(app) { + return; + } + let app = app.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(FIRST_CHECK_DELAY).await; + loop { + if is_check_due(&app) { + // An automatic check's failure (offline, say) waits for the next one. + if let Err(message) = check(&app, false).await { + eprintln!("localhost: couldn't check for updates: {message}"); + } + } + tokio::time::sleep(CHECKER_TICK).await; + } + }); +} + +fn is_check_due(app: &AppHandle) -> bool { + let state = app.state::(); + if !state.settings.get().automatic_update_checks { + return false; + } + let inner = state.updates.inner(); + let is_busy = matches!(inner.state, UpdateState::Downloading { .. } | UpdateState::Ready { .. }); + let is_recent = inner.last_check.and_then(|last| last.elapsed().ok()).is_some_and(|since| since < CHECK_INTERVAL); + !is_busy && !is_recent +} + +/// Asks for `latest.json` and updates the notice. A version put off with +/// Later shows again only when `is_manual`. +async fn check(app: &AppHandle, is_manual: bool) -> Result, String> { + let updates = &app.state::().updates; + { + let mut inner = updates.inner(); + if inner.is_checking { + return Err("localhost is already checking for updates.".into()); + } + inner.is_checking = true; + } + let found = match app.updater() { + Ok(updater) => updater.check().await.map_err(|error| error.to_string()), + Err(error) => Err(error.to_string()), + }; + let mut inner = updates.inner(); + inner.is_checking = false; + inner.last_check = Some(SystemTime::now()); + let update = found?; + let Some(update) = update else { + if matches!(inner.state, UpdateState::Available { .. }) { + inner.update = None; + set_state(app, &mut inner, UpdateState::None); + } + return Ok(None); + }; + let version = update.version.clone(); + // Already downloading or downloaded: keep going. + let is_known = inner.update.as_ref().is_some_and(|known| known.version == version); + if is_known && !matches!(inner.state, UpdateState::Available { .. } | UpdateState::None) { + return Ok(Some(version)); + } + if is_manual { + inner.put_off = None; + } + let state = if inner.put_off.as_deref() == Some(version.as_str()) { + UpdateState::None + } else { + UpdateState::Available { + version: version.clone(), + notes_url: notes_url(&update.raw_json, &version), + can_install: installs_itself(tauri::utils::platform::bundle_type()), + problem: None, + } + }; + inner.update = Some(update); + inner.package = None; + set_state(app, &mut inner, state); + Ok(Some(version)) +} + +/// The notice's current state, e.g. for a window that just opened. +#[tauri::command] +#[specta::specta] +pub fn update_state(state: tauri::State<'_, AppState>) -> UpdateState { + state.updates.state() +} + +/// Check for Updates: checks now, and shows the notice again even for a +/// version put off with Later. +#[tauri::command] +#[specta::specta] +pub async fn check_for_updates(app: AppHandle) -> UpdateCheck { + if !is_configured(&app) { + return UpdateCheck::Failed { + message: "This build of localhost doesn’t update itself. Download new versions from GitHub Releases." + .into(), + }; + } + match check(&app, true).await { + Ok(Some(version)) => UpdateCheck::Available { version }, + Ok(None) => UpdateCheck::UpToDate { version: app.package_info().version.to_string() }, + Err(message) => UpdateCheck::Failed { + message: format!("localhost couldn’t check for updates: {message}. Check your connection and try again."), + }, + } +} + +/// Later: hides the notice until a newer version, or Check for Updates. +#[tauri::command] +#[specta::specta] +pub fn put_off_update(app: AppHandle) { + let updates = &app.state::().updates; + let mut inner = updates.inner(); + if let UpdateState::Available { version, .. } = &inner.state { + inner.put_off = Some(version.clone()); + set_state(&app, &mut inner, UpdateState::None); + } +} + +/// Update Now: downloads the update in the background and verifies its +/// signature. Nothing is installed until Restart to Update. +#[tauri::command] +#[specta::specta] +pub fn download_update(app: AppHandle) { + let updates = &app.state::().updates; + let (update, notes_url) = { + let mut inner = updates.inner(); + let UpdateState::Available { notes_url, can_install: true, .. } = inner.state.clone() else { return }; + let Some(update) = inner.update.clone() else { return }; + let state = + UpdateState::Downloading { version: update.version.clone(), notes_url: notes_url.clone(), progress: None }; + set_state(&app, &mut inner, state); + (update, notes_url) + }; + tauri::async_runtime::spawn(async move { + let version = update.version.clone(); + let mut received = 0_u64; + let mut last_progress = Instant::now(); + let progress_app = app.clone(); + let progress_notes = notes_url.clone(); + let downloaded = update + .download( + |chunk, total| { + received += chunk as u64; + if last_progress.elapsed() < PROGRESS_INTERVAL { + return; + } + last_progress = Instant::now(); + let progress = + total.filter(|total| *total > 0).map(|total| (received as f64 / total as f64).min(1.0)); + let updates = &progress_app.state::().updates; + let mut inner = updates.inner(); + if matches!(inner.state, UpdateState::Downloading { .. }) { + let state = UpdateState::Downloading { + version: version.clone(), + notes_url: progress_notes.clone(), + progress, + }; + set_state(&progress_app, &mut inner, state); + } + }, + || {}, + ) + .await; + let updates = &app.state::().updates; + let mut inner = updates.inner(); + let state = match downloaded { + Ok(package) => { + inner.package = Some(package); + UpdateState::Ready { version: update.version.clone(), notes_url } + } + Err(error) => UpdateState::Available { + version: update.version.clone(), + notes_url, + can_install: true, + problem: Some(format!("The download failed: {error}. Try again.")), + }, + }; + set_state(&app, &mut inner, state); + }); +} + +/// Restart to Update: asks first if any Service is active, since the +/// restart stops them, then installs and relaunches. +#[tauri::command] +#[specta::specta] +pub fn install_update(app: AppHandle) { + let state = app.state::(); + let version = match &state.updates.state() { + UpdateState::Ready { version, .. } => version.clone(), + _ => return, + }; + let active = state.core().map_or_else( + |_| Vec::new(), + |core| { + let statuses: HashMap = core + .library + .projects() + .iter() + .flat_map(|project| &project.services) + .map(|service| (service.id, core.targets.status(service.id))) + .collect(); + active_services(&core.library.projects(), &statuses) + }, + ); + if active.is_empty() { + install(&app); + return; + } + let count = active.iter().map(|(_, services)| services.len()).sum::(); + let title = if count == 1 { + "Stop 1 service and update?".to_string() + } else { + format!("Stop {count} services and update?") + }; + let message = format!( + "localhost restarts to install version {version}. These services are stopped first:\n\n{}", + active + .iter() + .map(|(project, services)| format!("{project}: {}", services.join(", "))) + .collect::>() + .join("\n") + ); + let mut dialog = app + .dialog() + .message(message) + .title(title) + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::OkCancelCustom("Stop and Update".into(), "Cancel".into())); + if let Some(window) = app.get_webview_window(lifecycle::MAIN_WINDOW) { + dialog = dialog.parent(&window); + } + let app = app.clone(); + dialog.show(move |is_confirmed| { + if is_confirmed { + install(&app); + } + }); +} + +/// The active Services' names by Project, in the library's order. +fn active_services(projects: &[Project], statuses: &HashMap) -> Vec<(String, Vec)> { + projects + .iter() + .filter_map(|project| { + let names: Vec = project + .services + .iter() + .filter(|service| statuses.get(&service.id).is_some_and(|status| status.state.is_active())) + .map(|service| service.name.clone()) + .collect(); + (!names.is_empty()).then(|| (project.name.clone(), names)) + }) + .collect() +} + +/// Stops every Run and retires the shared core, then installs: the +/// AppImage is replaced and the app relaunched; on Windows the installer +/// takes over and relaunches it. +fn install(app: &AppHandle) { + let (update, package) = { + let updates = &app.state::().updates; + let mut inner = updates.inner(); + if inner.is_installing { + return; + } + let (Some(update), Some(package)) = (inner.update.clone(), inner.package.clone()) else { return }; + inner.is_installing = true; + (update, package) + }; + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let state = app.state::(); + if let Ok(core) = state.core() { + core.targets.stop_every_run(Origin::MainWindow).await; + core.targets.retire_shared_core().await; + } + match update.install(&package) { + Ok(()) => { + lifecycle::allow_exit(&app); + app.restart(); + } + Err(error) => { + let mut inner = state.updates.inner(); + inner.is_installing = false; + let failed = UpdateState::Available { + version: update.version.clone(), + notes_url: notes_url(&update.raw_json, &update.version), + can_install: true, + problem: Some(format!("The update couldn’t be installed: {error}.")), + }; + inner.package = None; + set_state(&app, &mut inner, failed); + } + } + }); +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use localhost_core::{Key, MemorySecretStore, NewService, ProjectLibrary, ServiceState}; + use tauri_plugin_updater::RemoteRelease; + + use super::*; + + /// The shape of the `latest.json` that `scripts/updater-manifest.sh` writes. + const MANIFEST: &str = r#"{ + "version": "0.5.0", + "notes": "localhost 0.5.0", + "pub_date": "2026-11-02T10:00:00Z", + "releaseNotesUrl": "https://github.com/marcellosso/localhost/releases/tag/v0.5.0", + "platforms": { + "linux-x86_64-appimage": { "url": "https://example.com/a.AppImage", "signature": "a" }, + "linux-x86_64-deb": { "url": "https://example.com/a.deb", "signature": "d" }, + "linux-x86_64-rpm": { "url": "https://example.com/a.rpm", "signature": "r" }, + "windows-x86_64-nsis": { "url": "https://example.com/a-setup.exe", "signature": "w" } + } + }"#; + + #[test] + fn the_manifest_names_each_package_by_its_bundle() { + let manifest: serde_json::Value = serde_json::from_str(MANIFEST).unwrap(); + let release: RemoteRelease = serde_json::from_value(manifest.clone()).unwrap(); + assert_eq!(release.version.to_string(), "0.5.0"); + assert_eq!(release.download_url("linux-x86_64-appimage").unwrap().as_str(), "https://example.com/a.AppImage"); + assert_eq!(release.signature("windows-x86_64-nsis").unwrap(), "w"); + // No bundle-less entry: a `.deb` must never find the AppImage. + assert!(release.download_url("linux-x86_64").is_err()); + assert_eq!(notes_url(&manifest, "0.5.0"), "https://github.com/marcellosso/localhost/releases/tag/v0.5.0"); + } + + #[test] + fn notes_default_to_the_tag_page() { + assert_eq!(notes_url(&serde_json::json!({}), "1.2.3"), format!("{RELEASES}/tag/v1.2.3")); + let insecure = serde_json::json!({ "releaseNotesUrl": "http://example.com" }); + assert_eq!(notes_url(&insecure, "1.2.3"), format!("{RELEASES}/tag/v1.2.3")); + } + + #[test] + fn only_the_appimage_and_the_windows_installer_install_themselves() { + assert!(installs_itself(Some(BundleType::AppImage))); + assert!(installs_itself(Some(BundleType::Nsis))); + assert!(!installs_itself(Some(BundleType::Deb))); + assert!(!installs_itself(Some(BundleType::Rpm))); + assert!(!installs_itself(None)); + } + + #[test] + fn lists_the_active_services_by_project() { + let root = std::env::temp_dir().join(format!("localhost-updates-{}-{}", std::process::id(), Id::new())); + std::fs::create_dir_all(&root).unwrap(); + let library = ProjectLibrary::open(&root, Arc::new(MemorySecretStore::new())).unwrap(); + let add = |name: &str, services: &[&str]| { + let key = |name: &str| Key::new(name.to_lowercase()).unwrap(); + let services = services.iter().map(|service| NewService::new(*service, key(service), "sleep 1")).collect(); + library.add_project(name, key(name), &root.to_string_lossy(), services, Origin::Cli).unwrap() + }; + let shop = add("Shop", &["Web", "API", "Worker"]); + let blog = add("Blog", &["Site"]); + let status = |service_id: Id, state: ServiceState| { + (service_id, ServiceStatus { state, ..ServiceStatus::never_run(service_id) }) + }; + let statuses = HashMap::from([ + status(shop.services[0].id, ServiceState::Running), + status(shop.services[1].id, ServiceState::Failed), + status(shop.services[2].id, ServiceState::Stopping), + status(blog.services[0].id, ServiceState::Exited), + ]); + + assert_eq!( + active_services(&library.projects(), &statuses), + vec![("Shop".to_string(), vec!["Web".to_string(), "Worker".to_string()])] + ); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 80c7d81..1058443 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -38,5 +38,13 @@ "publisher": "Marcel Losso", "copyright": "Copyright © 2026 Marcel Losso. MIT License.", "license": "MIT" + }, + "plugins": { + "updater": { + "pubkey": "", + "endpoints": ["https://github.com/marcellosso/localhost/releases/latest/download/latest.json"], + "requireSignedVersion": true, + "windows": { "installMode": "passive" } + } } } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 4cdfe14..e287bd0 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -22,6 +22,7 @@ import type { CoreView } from "./core"; import type { Navigator } from "./palette"; import { useWindowShortcuts } from "./shortcuts"; import { startCore, useCore } from "./store"; +import { startUpdates } from "./updates"; import { showSheet, useUi } from "./ui"; let started: Promise | null = null; @@ -31,7 +32,7 @@ let started: Promise | null = null; * A WSL distro's Orphans are asked about when it connects, which may be later. */ function start(): Promise { - started ??= Promise.all([loadSettings(), loadHome(), startCore()]).then(async () => { + started ??= Promise.all([loadSettings(), loadHome(), startCore(), startUpdates()]).then(async () => { void events.orphansFound.listen(() => void askAboutFoundOrphans()); await runLaunchChecks(); await runPerfMeasurement(); diff --git a/apps/desktop/src/bindings.ts b/apps/desktop/src/bindings.ts index 44398f6..88a36fb 100644 --- a/apps/desktop/src/bindings.ts +++ b/apps/desktop/src/bindings.ts @@ -512,6 +512,39 @@ async quitApp() : Promise { */ async takeReveal() : Promise { return await TAURI_INVOKE("take_reveal"); +}, +/** + * The notice's current state, e.g. for a window that just opened. + */ +async updateState() : Promise { + return await TAURI_INVOKE("update_state"); +}, +/** + * Check for Updates: checks now, and shows the notice again even for a + * version put off with Later. + */ +async checkForUpdates() : Promise { + return await TAURI_INVOKE("check_for_updates"); +}, +/** + * Later: hides the notice until a newer version, or Check for Updates. + */ +async putOffUpdate() : Promise { + await TAURI_INVOKE("put_off_update"); +}, +/** + * Update Now: downloads the update in the background and verifies its + * signature. Nothing is installed until Restart to Update. + */ +async downloadUpdate() : Promise { + await TAURI_INVOKE("download_update"); +}, +/** + * Restart to Update: asks first if any Service is active, since the + * restart stops them, then installs and relaunches. + */ +async installUpdate() : Promise { + await TAURI_INVOKE("install_update"); } } @@ -520,10 +553,12 @@ async takeReveal() : Promise { export const events = __makeEvents__<{ orphansFound: OrphansFound, -reveal: Reveal +reveal: Reveal, +updateState: UpdateState }>({ orphansFound: "orphans-found", -reveal: "reveal" +reveal: "reveal", +updateState: "update-state" }) /** user-defined constants **/ @@ -1194,7 +1229,11 @@ automaticPortMove: boolean; appearance: Appearance; /** * Points, for the log and the commands in Service rows. */ -logFontSize: number } +logFontSize: number; +/** + * Whether localhost checks for a new version at launch and every few hours. + */ +automaticUpdateChecks: boolean } /** * Everything the UI shows, at the moment it subscribed. */ @@ -1244,6 +1283,39 @@ export type Termination = { kind: "exited"; code: number } | { kind: "signaled"; * The text attributes set by ANSI SGR sequences. */ export type TextStyle = { foreground: AnsiColor | null; background: AnsiColor | null; isBold: boolean; isDim: boolean; isItalic: boolean; isUnderlined: boolean; isInverse: boolean; isStrikethrough: boolean } +/** + * What Check for Updates found. + */ +export type UpdateCheck = +/** + * The notice shows it. + */ +{ kind: "available"; version: string } | { kind: "upToDate"; version: string } | +/** + * The check couldn't be made, or this build doesn't update itself. + */ +{ kind: "failed"; message: string } +/** + * What the update notice shows; also the event sent when it changes. + */ +export type UpdateState = +/** + * No newer version known, or the notice was put off with Later. + */ +{ kind: "none" } | +/** + * A newer version. `can_install` is false where a package manager + * installs it (`.deb`, `.rpm`); `problem` says why a download failed. + */ +{ kind: "available"; version: string; notesUrl: string; canInstall: boolean; problem: string | null } | +/** + * `progress` is from 0 to 1, `None` while the size isn't known. + */ +{ kind: "downloading"; version: string; notesUrl: string; progress: number | null } | +/** + * Downloaded and verified: restarting installs it. + */ +{ kind: "ready"; version: string; notesUrl: string } /** * The kind of value a Project file field takes. */ diff --git a/apps/desktop/src/components/Alerts.tsx b/apps/desktop/src/components/Alerts.tsx index cc59ffc..d427ec6 100644 --- a/apps/desktop/src/components/Alerts.tsx +++ b/apps/desktop/src/components/Alerts.tsx @@ -216,6 +216,21 @@ function AlertDialog({ alert, view }: { alert: Alert; view: CoreView }) { ); + case "upToDate": + return ( + + OK + + } + > +

Version {alert.version} is the latest.

+
+ ); + case "settings": return alert.forEditor ? ( diff --git a/apps/desktop/src/components/SettingsDialog.tsx b/apps/desktop/src/components/SettingsDialog.tsx index 654ad4c..862a37b 100644 --- a/apps/desktop/src/components/SettingsDialog.tsx +++ b/apps/desktop/src/components/SettingsDialog.tsx @@ -1,6 +1,7 @@ import { type ReactNode, useId, useRef, useState } from "react"; import type { Appearance } from "../bindings"; import { saveSettings, useSettings } from "../settings"; +import { checkForUpdates } from "../updates"; import { Dialog, useModal } from "./Dialog"; import { CircleHalfIcon, GearIcon, GlobeIcon } from "./Icons"; import styles from "./SettingsDialog.module.css"; @@ -114,6 +115,21 @@ function GeneralSettings() { onKeyDown={(event) => event.key === "Enter" && save()} /> + + void saveSettings({ automaticUpdateChecks })} + /> + + + + ); } diff --git a/apps/desktop/src/components/Sidebar.tsx b/apps/desktop/src/components/Sidebar.tsx index 66ed730..3ef95de 100644 --- a/apps/desktop/src/components/Sidebar.tsx +++ b/apps/desktop/src/components/Sidebar.tsx @@ -6,11 +6,13 @@ import { importProject } from "../editors"; import { SHORTCUTS, shortcutLabel } from "../keyboard"; import { isActive, projectServicesSummary } from "../status"; import { showAlert, showSheet } from "../ui"; +import { useUpdate } from "../updates"; import { ClearIcon, GearIcon, GlobeIcon, KeyboardIcon, PlusIcon, SearchIcon } from "./Icons"; import { Menu, useContextMenu } from "./Menu"; import { projectMenuItems } from "./ServicesColumn"; import { ShortcutBadge } from "./ShortcutBadge"; import { StateGlyph } from "./StateGlyph"; +import { UpdateNotice } from "./UpdateNotice"; import styles from "./Sidebar.module.css"; type Props = { @@ -19,9 +21,13 @@ type Props = { onSelect: (selection: SidebarSelection) => void; }; -/** The wordmark and project search, Ports and the Projects, and a footer with Settings and how many Services run. */ +/** + * The wordmark and project search, Ports and the Projects, a newer version + * when there is one, and a footer with Settings and how many Services run. + */ export function Sidebar({ view, selection, onSelect }: Props) { const [search, setSearch] = useState(""); + const update = useUpdate(); const query = search.trim().toLowerCase(); const projects = query ? view.projects.filter((p) => p.name.toLowerCase().includes(query) || p.key.includes(query)) @@ -113,6 +119,7 @@ export function Sidebar({ view, selection, onSelect }: Props) { )} +