diff --git a/apps/desktop/loopx-control-plane/README.md b/apps/desktop/loopx-control-plane/README.md index 9b2d2470b4..3f6f343fe9 100644 --- a/apps/desktop/loopx-control-plane/README.md +++ b/apps/desktop/loopx-control-plane/README.md @@ -12,14 +12,32 @@ desktop release workflow succeeds: - macOS: `.dmg` plus a zipped `.app` bundle; - Windows: `.msi` plus an NSIS `.exe` installer. -On Apple Silicon macOS, the App checks its current official channel on launch, -verifies a newer App's updater signature, installs it and restarts automatically. +On Apple Silicon macOS, the App opens its qualified local runtime first, then +checks its current official channel and downloads a newer App in the background. +It verifies the updater signature and prepares the archive locally. The next +normal open verifies that archive again and installs it before starting services; +ordinary background preparation never replaces or restarts an active workspace. An offline or failed check keeps the installed App usable; it does not certify that the App is current. Main points to the latest complete signed build, not arbitrary Git HEAD. Builds without the updater need a one-time replacement. Windows preview retains manual CLI installation; browser/PWA users continue with `loopx update`. +Background network requests do not hold service supervision. A slow or failed +check/download cannot prevent local startup or recovery of an owned child. +The existing update panel reports background progress or a prepared update; +manual actions cancel/supersede background work and retain their original +installation, restart, backup and rollback controls. A CLI-requested App upgrade +remains an explicit transaction with its original channel and service-ready +readback. A permission-restricted prepared installation stays on the verified +current App; use the existing explicit update action for native authorization. +The disposable cache is not an update authority: its signed bytes, channel, +version order and sealed bundle version are checked again before replacement. +Rejected cached signatures and verified-safe replacement failures keep local +startup moving, with the failure retained in diagnostics. They do not display +a stopped-startup state while services are connecting. An incomplete target, +failed journal retirement or unknown installation failure still requires recovery. + ## Updates And Recovery From a terminal, `loopx upgrade` coordinates the owning CLI installer and this @@ -30,8 +48,8 @@ to the App. A private request receipt contains versions, source revisions, update phase and the qualified service runtime identity; it contains no Goal content or credentials. An update check failure remains a failed terminal upgrade even when the workspace can still open. Normal launches without that -request retain their existing three-second optional startup check; an explicit -terminal upgrade uses the existing thirty-second interactive check budget. +request perform their network check after local services are ready; an explicit +terminal upgrade retains its thirty-second interactive check budget. The existing update panel shows the same native maintenance phases and recovery actions. Main is the latest complete signed build, which can lag Git HEAD. @@ -278,10 +296,10 @@ secret. Updater signing does not provide Apple notarization. It also does not change Goal authority, grant capabilities, or stop running agents on behalf of the user. Services may briefly disconnect during reconciliation. -The optional startup feed check has a three-second deadline so an unavailable -update server cannot hold workspace startup indefinitely. A manual **Check for updates** -allows 45 seconds for the same official feed, including redirects; the panel -keeps showing its checking state while the existing workspace remains readable. +The background feed check has a thirty-second deadline and is outside local +workspace startup. A manual **Check for updates** allows 45 seconds for the same +official feed, including redirects; the panel keeps showing its checking state +while the existing workspace remains readable. A timeout remains an explicit retryable error, never a claim that the App is current. The separate 30-second download/signature-verification deadline and installation/recovery boundaries are unchanged. @@ -387,6 +405,16 @@ updates through the actual App, then terminate its exact owned Chat and Status children separately. Read back both HTTP identities, unchanged peer PID, exact registry, retained Session and update failure, then normal quit/reopen cleanup. Never substitute an installed App, real Goal or shared listener as a fixture. +Bind isolation at the QA bundle's executable entrypoint, so a window tool or OS +relaunch cannot drop a caller-only environment. The native services request +`--global-registry`; `LOOPX_REGISTRY` only changes the project default and does +not override that explicit selection. A QA-only CLI launcher must route through +Core's explicit `--registry` and `--runtime-root` arguments instead of that flag. +Fail closed when its fixture binding is absent. Before any UI operation or +public capture, read both service identities, the actual registry/runtime route +and the empty Goal set. A source mismatch invalidates the sample; retain it +privately and stop rather than operating on or publishing the real workspace. +Do not change the user's default registry or `HOME` to obtain isolation. This qualifies child-exit recovery, not an interrupted model Turn, an unresponsive process, a signed update installation or cross-OS operation. diff --git a/apps/desktop/loopx-control-plane/docs/assets/background-update-desktop.png b/apps/desktop/loopx-control-plane/docs/assets/background-update-desktop.png new file mode 100644 index 0000000000..66c372a096 Binary files /dev/null and b/apps/desktop/loopx-control-plane/docs/assets/background-update-desktop.png differ diff --git a/apps/desktop/loopx-control-plane/docs/assets/background-update-mobile.png b/apps/desktop/loopx-control-plane/docs/assets/background-update-mobile.png new file mode 100644 index 0000000000..a95ae7a6a5 Binary files /dev/null and b/apps/desktop/loopx-control-plane/docs/assets/background-update-mobile.png differ diff --git a/apps/desktop/loopx-control-plane/docs/assets/native-prepared-activation.jpg b/apps/desktop/loopx-control-plane/docs/assets/native-prepared-activation.jpg new file mode 100644 index 0000000000..eb724c0129 Binary files /dev/null and b/apps/desktop/loopx-control-plane/docs/assets/native-prepared-activation.jpg differ diff --git a/apps/desktop/loopx-control-plane/docs/assets/update-channel-selection-desktop.png b/apps/desktop/loopx-control-plane/docs/assets/update-channel-selection-desktop.png new file mode 100644 index 0000000000..79971d4779 Binary files /dev/null and b/apps/desktop/loopx-control-plane/docs/assets/update-channel-selection-desktop.png differ diff --git a/apps/desktop/loopx-control-plane/docs/assets/update-channel-selection-mobile.png b/apps/desktop/loopx-control-plane/docs/assets/update-channel-selection-mobile.png new file mode 100644 index 0000000000..8f7d04b0df Binary files /dev/null and b/apps/desktop/loopx-control-plane/docs/assets/update-channel-selection-mobile.png differ diff --git a/apps/desktop/loopx-control-plane/src-tauri/Cargo.lock b/apps/desktop/loopx-control-plane/src-tauri/Cargo.lock index 9be88a3d72..ea749da794 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/Cargo.lock +++ b/apps/desktop/loopx-control-plane/src-tauri/Cargo.lock @@ -2171,6 +2171,7 @@ dependencies = [ "open", "pep440_rs", "rustix", + "semver", "serde_json", "sha2 0.11.0", "tar", diff --git a/apps/desktop/loopx-control-plane/src-tauri/Cargo.toml b/apps/desktop/loopx-control-plane/src-tauri/Cargo.toml index 5eb605b73f..3b4f7f715f 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/Cargo.toml +++ b/apps/desktop/loopx-control-plane/src-tauri/Cargo.toml @@ -30,10 +30,9 @@ flate2 = "1.1.10" tar = "0.4.46" sha2 = "0.11.0" tempfile = "3.27.0" - -[dev-dependencies] base64 = "0.22" minisign-verify = "0.2.5" +semver = "1" [target.'cfg(target_os = "macos")'.dependencies] rustix = { version = "1.1.4", features = ["fs"] } diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs b/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs index 775fa19872..fd882f6689 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod bundled_runtime; #[cfg(target_os = "macos")] mod macos_update; mod maintenance; +mod prepared_update; mod runtime_ancestry; mod runtime_selection; mod service_endpoints; @@ -238,6 +239,7 @@ pub fn run() { .expect("workspace handoff lock") .services_ready(); WorkspaceHandoff::reconcile(&handoff, &handle); + maintenance::start_background_update(&handle); if healed { let _ = handle .notification() diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/macos_update.rs b/apps/desktop/loopx-control-plane/src-tauri/src/macos_update.rs index 8b8f7ca3b9..4a876cbb2b 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/macos_update.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/macos_update.rs @@ -19,7 +19,7 @@ enum InstallError { pub(crate) fn install(update: &Update, verified_bytes: &[u8]) -> Result<(), String> { let executable = std::env::current_exe().map_err(|_| "app_install_failed")?; - match install_verified_archive(&executable, verified_bytes) { + match install_archive(&executable, verified_bytes, Some(&update.version)) { Ok(()) => Ok(()), // An administrator-owned installation retains the existing native // authorization path. Never fall back after extraction or a failed @@ -31,7 +31,25 @@ pub(crate) fn install(update: &Update, verified_bytes: &[u8]) -> Result<(), Stri } } +// Prepared downloads are activated before this App starts any service. A +// permission-restricted target stays on the working App; its explicit update +// action retains the updater's existing native authorization adapter above. +pub(crate) fn install_prepared(version: &str, verified_bytes: &[u8]) -> Result<(), String> { + let executable = std::env::current_exe().map_err(|_| "app_install_failed")?; + install_archive(&executable, verified_bytes, Some(version)) + .map_err(|_| "app_install_failed".into()) +} + +#[cfg(test)] fn install_verified_archive(executable: &Path, verified_bytes: &[u8]) -> Result<(), InstallError> { + install_archive(executable, verified_bytes, None) +} + +fn install_archive( + executable: &Path, + verified_bytes: &[u8], + expected_version: Option<&str>, +) -> Result<(), InstallError> { let failed = || InstallError::Failed; let target = crate::update_backup::installed_bundle_at(executable).map_err(|_| failed())?; let parent = target.parent().ok_or_else(failed)?; @@ -74,6 +92,21 @@ fn install_verified_archive(executable: &Path, verified_bytes: &[u8]) -> Result< { return Err(failed()); } + // The archive is signed, but its local cache metadata is not. Bind the + // announced version to the sealed bundle before any replacement so a + // modified cache cannot relabel a valid older archive as a newer update. + if let Some(expected) = expected_version { + let mut command = std::process::Command::new("/usr/libexec/PlistBuddy"); + command + .args(["-c", "Print :CFBundleShortVersionString"]) + .arg(candidate.join("Contents/Info.plist")); + let matches = crate::services::timed_output(command).is_some_and(|output| { + output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == expected + }); + if !matches { + return Err(failed()); + } + } // Retain both sides before the only commit operation. The same atomic // exchange as rollback keeps the install path occupied, even across a @@ -219,6 +252,37 @@ mod tests { )); } + #[test] + fn local_metadata_cannot_relabel_a_sealed_older_bundle() { + let root = tempfile::tempdir().unwrap(); + let target = ad_hoc_signed_synthetic_app(root.path(), "Installed.app"); + let candidate = ad_hoc_signed_synthetic_app(root.path(), "Candidate.app"); + let plist = candidate.join("Contents/Info.plist"); + let content = fs::read_to_string(&plist).unwrap().replace( + "", + "CFBundleShortVersionString1.1.0", + ); + fs::write(&plist, content).unwrap(); + assert!(std::process::Command::new("codesign") + .args(["--sign", "-", "--force"]) + .arg(&candidate) + .status() + .unwrap() + .success()); + let bytes = archive(&candidate); + assert_eq!( + install_archive(&synthetic_executable(&target), &bytes, Some("9.0.0")), + Err(InstallError::Failed) + ); + assert!(!fs::read_to_string(target.join("Contents/Info.plist")) + .unwrap() + .contains("1.1.0")); + install_archive(&synthetic_executable(&target), &bytes, Some("1.1.0")).unwrap(); + assert!(fs::read_to_string(target.join("Contents/Info.plist")) + .unwrap() + .contains("1.1.0")); + } + #[test] fn only_parent_permission_denial_requests_the_existing_authorization_path() { use std::os::unix::fs::PermissionsExt; diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs index a95b55bd10..427d2191e7 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs @@ -25,11 +25,20 @@ pub struct Maintenance { phase_started: Mutex>, separately_managed_runtime: AtomicBool, automatic_update_checked: AtomicBool, + prepared_update_checked: AtomicBool, + background_update: Mutex, incomplete_app_installation: AtomicBool, manual_failure_pending: AtomicBool, upgrade_request: std::sync::OnceLock>, } +#[derive(Default)] +struct BackgroundUpdate { + generation: u64, + snapshot: Value, + task: Option>, +} + #[derive(Default)] struct RuntimeRetry { attempts: u8, @@ -55,8 +64,88 @@ impl Maintenance { if self.incomplete_app_installation.load(Ordering::Acquire) { json!({"phase":"error", "details":{"code":"app_install_incomplete"}}) } else { - self.snapshot.lock().unwrap().clone() + let snapshot = self.snapshot.lock().unwrap().clone(); + if snapshot["phase"] == "ready" && !self.busy.load(Ordering::Acquire) { + let background = self.background_update.lock().unwrap(); + if !background.snapshot.is_null() { + return background.snapshot.clone(); + } + } + snapshot + } + } + + fn admit_background_update(&self) -> Option { + // Admission and its generation must share the cancellation lock. A + // starter paused before admission cannot adopt a manual action's new + // generation after that action has already completed. + let background = self.background_update.lock().unwrap(); + if self.busy.load(Ordering::Acquire) + || self.automatic_update_checked.swap(true, Ordering::AcqRel) + { + return None; + } + Some(background.generation) + } + + fn cancel_background_update(&self) { + self.automatic_update_checked.store(true, Ordering::Release); + let mut background = self.background_update.lock().unwrap(); + background.generation += 1; + background.snapshot = Value::Null; + if let Some(task) = background.task.take() { + task.abort(); + } + } + + fn publish_background(&self, generation: u64, phase: &str, mut details: Value) -> bool { + let mut background = self.background_update.lock().unwrap(); + if background.generation != generation || self.busy.load(Ordering::Acquire) { + return false; + } + details["background"] = true.into(); + let value = json!({"phase":phase, "details":details}); + if phase == "error" { + *self.last_failure.lock().unwrap() = value.clone(); + } + background.snapshot = value; + true + } + + async fn run_background_update( + &self, + generation: u64, + channel: &str, + check: Check, + prepare: Prepare, + ) -> Result<(), String> + where + Check: std::future::Future, String>>, + Prepare: FnOnce(T) -> PrepareFuture, + PrepareFuture: std::future::Future>, + { + // Network IO never owns `busy` or `supervision`. Its separate snapshot + // cannot block startup, child recovery or the next explicit action. + if !self.publish_background(generation, "checking", json!({"channel":channel})) { + return Ok(()); + } + let result: Result<(), String> = async { + if let Some(update) = check.await? { + if !self.publish_background(generation, "downloading", json!({"channel":channel})) { + return Ok(()); + } + let details = prepare(update).await?; + self.publish_background(generation, "available", details); + } else { + self.publish_background(generation, "up_to_date", json!({"channel":channel})); + } + Ok(()) + } + .await; + if let Err(error) = &result { + self.publish_background(generation, "error", json!({"code":error,"channel":channel})); } + result } // Only a completed native replacement/verified restore can retire this @@ -117,7 +206,27 @@ impl Maintenance { .await; // A queued explicit action must become the final snapshot writer. if let Err(error) = &result { - self.publish_failure(error, channel); + let details = self.failure_details(error, channel); + // A rejected prepared archive has not replaced the running App. + // app_install_failed additionally certifies the previous target, + // qualified runtime and retired journal. Keep its diagnostics, + // but do not tell the boot page startup has stopped while local + // service startup is about to continue. Unknown/incomplete and + // explicit-operation failures retain their recovery boundary. + if self.prepared_update_checked.load(Ordering::Acquire) + && matches!( + error.as_str(), + "update_download_or_signature_failed" | "app_install_failed" + ) + && !self.incomplete_app_installation.load(Ordering::Acquire) + && !self.manual_failure_pending.load(Ordering::Acquire) + && !self.busy.load(Ordering::Acquire) + { + *self.last_failure.lock().unwrap() = json!({"phase":"error","details":details}); + self.publish("connecting", json!({})); + } else { + self.publish("error", details); + } } result.map(Some) } @@ -390,6 +499,7 @@ pub async fn desktop_update( } let state = app.state::(); let _guard = state.acquire()?; + state.cancel_background_update(); // An explicit action takes priority over the next retry, while awaiting // the current bounded service attempt instead of failing with update_busy. let _supervision = state.supervision.lock().await; @@ -425,6 +535,10 @@ impl Maintenance { // App-install failures discard the stale continuation journal (see // perform); surface that in the failure diagnostics exactly once. fn publish_failure(&self, code: &str, channel: &str) -> Value { + self.publish("error", self.failure_details(code, channel)) + } + + fn failure_details(&self, code: &str, channel: &str) -> Value { let mut details = json!({"code": code, "channel": channel}); if code == "app_install_failed" { details["journal_discarded"] = self @@ -432,7 +546,7 @@ impl Maintenance { .swap(false, Ordering::AcqRel) .into(); } - self.publish("error", details) + details } } // Bounded reinstall of this App's bundled snapshot. Persist the qualified @@ -479,24 +593,7 @@ async fn perform( if action == "check" { state.publish("checking", json!({"channel":channel})); *state.pending.lock().unwrap() = None; - let main_channel = channel == "main"; - let update = app - .updater_builder() - .version_comparator(move |current, remote| { - if main_channel && !current.pre.as_str().starts_with("main.") { - remote.version.pre.as_str().starts_with("main.") - } else { - remote.version > current - } - }) - .endpoints(vec![url]) - .map_err(|_| "update_unavailable")? - .timeout(check_timeout) - .build() - .map_err(|_| "update_unavailable")? - .check() - .await - .map_err(check_error)?; + let update = find_update(app, channel, url, check_timeout).await?; let details = json!({"channel":channel,"version":update.as_ref().map(|v| &v.version),"current_version":app.package_info().version.to_string()}); let phase = if update.is_some() { "available" @@ -511,6 +608,10 @@ async fn perform( return Ok(state.publish(phase, details)); } if action == "rollback" { + // A requested rollback must not silently reapply the prepared newer + // App on its next open. Optional cache failure never gates a runtime + // repair or an update check; only this conflicting replacement needs it. + crate::prepared_update::clear(&prepared_directory(app)?)?; state.publish("installing_app", json!({})); let handle = app.clone(); tauri::async_runtime::spawn_blocking(move || crate::update_backup::restore(&handle)) @@ -537,42 +638,106 @@ async fn perform( .ok_or("update_check_required")?; state.publish("downloading", json!({"version":update.version})); let mut received = 0u64; - let bytes = update - .download( - |chunk, total| { - received += chunk as u64; - state.publish( - "downloading", - json!({"received":received,"total":total,"version":update.version}), - ); - }, - || {}, - ) + let cached = crate::prepared_update::load( + &prepared_directory(app)?, + &app.package_info().version.to_string(), + channel, + updater_public_key(app)?, + ) + .ok() + .flatten() + .filter(|cached| cached.version == update.version && cached.signature == update.signature); + let bytes = if let Some(cached) = cached { + cached.bytes + } else { + update + .download( + |chunk, total| { + received += chunk as u64; + state.publish( + "downloading", + json!({"received":received,"total":total,"version":update.version}), + ); + }, + || {}, + ) + .await + .map_err(|_| "update_download_or_signature_failed")? + }; + let target = update.version.clone(); + install_verified_app(app, target, move || { + #[cfg(target_os = "macos")] + return crate::macos_update::install(&update, &bytes); + #[cfg(not(target_os = "macos"))] + update + .install(bytes) + .map_err(|_| "app_install_failed".into()) + }) + .await +} + +fn prepared_directory(app: &AppHandle) -> Result { + app.path() + .app_local_data_dir() + .map(|root| root.join("prepared-app-update")) + .map_err(|_| "update_unavailable".into()) +} + +fn updater_public_key(app: &AppHandle) -> Result<&str, String> { + app.config() + .plugins + .0 + .get("updater") + .and_then(|v| v["pubkey"].as_str()) + .ok_or_else(|| "update_unavailable".into()) +} + +async fn find_update( + app: &AppHandle, + channel: &str, + url: tauri::Url, + timeout: Duration, +) -> Result, String> { + let channel = channel.to_owned(); + app.updater_builder() + .version_comparator(move |current, remote| { + crate::prepared_update::is_newer( + ¤t.to_string(), + &remote.version.to_string(), + &channel, + ) + }) + .endpoints(vec![url]) + .map_err(|_| "update_unavailable")? + .timeout(timeout) + .build() + .map_err(|_| "update_unavailable")? + .check() .await - .map_err(|_| "update_download_or_signature_failed")?; + .map_err(|error| check_error(error).to_string()) +} + +async fn install_verified_app( + app: &AppHandle, + target: String, + install: impl FnOnce() -> Result<(), String> + Send + 'static, +) -> Result { + let state = app.state::(); // Archive signature is now verified. Persist continuation before replacement. - state.publish("installing_app", json!({"version":update.version})); + state.publish("installing_app", json!({"version":target})); let handle = app.clone(); tauri::async_runtime::spawn_blocking(move || crate::update_backup::prepare(&handle)) .await .map_err(|_| "backup_failed")??; // After restart, freshness coordination prepares the App-owned runtime // only when it is newer; it cannot reassign the system CLI's owner. - let target = update.version.clone(); // A JoinError (task panic/cancellation) is an unknown-state failure just // like an install error: both must clear the same verification below, so // neither returns ahead of the recovery decision. - let installed: Result<(), String> = tauri::async_runtime::spawn_blocking(move || { - #[cfg(target_os = "macos")] - return crate::macos_update::install(&update, &bytes); - #[cfg(not(target_os = "macos"))] - update - .install(bytes) - .map_err(|_| "app_install_failed".into()) - }) - .await - .map_err(|_| "app_install_failed".to_string()) - .and_then(|result| result); + let installed: Result<(), String> = tauri::async_runtime::spawn_blocking(install) + .await + .map_err(|_| "app_install_failed".to_string()) + .and_then(|result| result); if installed.is_err() { // The pinned macOS installer renames the old App away before moving // the new one in, so a failed install does not by itself prove the @@ -588,6 +753,11 @@ async fn perform( ) .into()); } + // Replacement already committed. A disposable-cache cleanup failure must + // not turn the installed result into a failed operation or strand restart. + if let Ok(root) = prepared_directory(app) { + let _ = crate::prepared_update::clear(&root); + } *state.pending.lock().unwrap() = None; Ok(state.complete_app_replacement(json!({"version":target}))) } @@ -843,7 +1013,6 @@ fn automatic_app_update(app: &AppHandle) { if cfg!(dev) || !cfg!(target_os = "macos") || app.config().identifier != "io.loopx.control-plane" - || state.automatic_update_checked.swap(true, Ordering::AcqRel) { return; } @@ -855,6 +1024,47 @@ fn automatic_app_update(app: &AppHandle) { } else { "stable" }; + if request.is_none() { + if state.prepared_update_checked.swap(true, Ordering::AcqRel) { + return; + } + // Ordinary launch consumes only an already prepared local archive. + // No network request precedes the usable local runtime. Installation + // happens before any App-owned service exists, never during a Turn. + #[cfg(target_os = "macos")] + { + let result = tauri::async_runtime::block_on(state.run_automatic_update( + channel, + async { + crate::prepared_update::load( + &prepared_directory(app)?, + &app.package_info().version.to_string(), + channel, + updater_public_key(app)?, + ) + }, + |prepared| async move { + let target = prepared.version; + let version = target.clone(); + install_verified_app(app, target, move || { + crate::macos_update::install_prepared(&version, &prepared.bytes) + }) + .await?; + Ok(()) + }, + || app.restart(), + )); + if let Err(error) = result { + eprintln!("LoopX prepared App update deferred: {error}"); + } + } + return; + } + if state.automatic_update_checked.swap(true, Ordering::AcqRel) { + return; + } + // A CLI-requested upgrade is an explicit operation, not an ordinary open. + // Preserve its requested channel and exact service-ready return contract. let result: Result, String> = tauri::async_runtime::block_on(state.run_automatic_update( channel, @@ -884,6 +1094,71 @@ fn automatic_app_update(app: &AppHandle) { // of the installed App. Diagnostics retain the failed check. } +pub fn start_background_update(app: &AppHandle) { + let state = app.state::(); + if cfg!(dev) + || !cfg!(target_os = "macos") + || app.config().identifier != "io.loopx.control-plane" + || state.upgrade_request.get().is_some_and(Option::is_some) + { + return; + } + let Some(generation) = state.admit_background_update() else { + return; + }; + let handle = app.clone(); + let task = tauri::async_runtime::spawn(async move { + let state = handle.state::(); + let channel = if handle + .package_info() + .version + .pre + .as_str() + .starts_with("main.") + { + "main" + } else { + "stable" + }; + let result = state.run_background_update(generation, channel, + async { + find_update(&handle, channel, endpoint(channel)?, Duration::from_secs(30)).await + }, + |mut update| async { + update.timeout = Some(Duration::from_secs(30)); + let mut received = 0u64; + let bytes = update.download(|chunk, total| { + received += chunk as u64; + state.publish_background(generation, "downloading", + json!({"channel":channel,"version":update.version,"received":received,"total":total})); + }, || {}).await.map_err(|_| "update_download_or_signature_failed")?; + let root = prepared_directory(&handle)?; + let app = handle.clone(); + tauri::async_runtime::spawn_blocking(move || { + let state = app.state::(); + let background = state.background_update.lock().unwrap(); + if background.generation != generation || state.busy.load(Ordering::Acquire) { + return Err("update_busy".into()); + } + crate::prepared_update::save(&root, channel, &update.version, &update.signature, &bytes)?; + let details = json!({"channel":channel,"version":update.version,"prepared":true}); + *state.pending.lock().unwrap() = Some((channel.into(), update)); + Ok(details) + }).await.map_err(|_| "update_download_or_signature_failed".to_string())? + } + ).await; + if let Err(error) = result { + eprintln!("LoopX background App update deferred: {error}"); + } + }); + let mut background = state.background_update.lock().unwrap(); + if background.generation == generation { + background.task = Some(task); + } else { + task.abort(); + } +} + pub fn start_services( app: &AppHandle, endpoints: &crate::service_endpoints::ServiceEndpoints, @@ -959,6 +1234,159 @@ pub fn recover_services( mod tests { use super::*; + #[test] + fn completed_manual_choice_prevents_a_delayed_background_start() { + let state = std::sync::Arc::new(Maintenance::default()); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let worker_state = state.clone(); + let worker = std::thread::spawn(move || { + started_tx.send(()).unwrap(); + release_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + worker_state.admit_background_update() + }); + started_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + { + let _manual = state.acquire().unwrap(); + state.cancel_background_update(); + state.publish("available", json!({"channel":"main","version":"1.1.0"})); + } + release_tx.send(()).unwrap(); + assert_eq!(worker.join().unwrap(), None); + assert_eq!(state.status_snapshot()["details"]["channel"], "main"); + } + + #[test] + fn manual_choice_invalidates_admitted_but_not_yet_started_background_io() { + let state = Maintenance::default(); + let generation = state.admit_background_update().unwrap(); + assert_eq!(state.admit_background_update(), None); + { + let _manual = state.acquire().unwrap(); + state.cancel_background_update(); + state.publish("available", json!({"channel":"main","version":"1.1.0"})); + } + tauri::async_runtime::block_on(state.run_background_update( + generation, + "stable", + async { panic!("cancelled startup must not query the feed") }, + |_: ()| async { panic!("cancelled startup must not prepare an update") }, + )) + .unwrap(); + assert_eq!(state.status_snapshot()["details"]["channel"], "main"); + assert!(state.background_update.lock().unwrap().snapshot.is_null()); + } + + #[test] + fn busy_background_admission_preserves_the_later_startup_attempt() { + let state = Maintenance::default(); + { + let _busy = state.acquire().unwrap(); + assert_eq!(state.admit_background_update(), None); + } + assert!(state.admit_background_update().is_some()); + assert_eq!(state.admit_background_update(), None); + } + + #[test] + fn blocked_background_io_cannot_hold_service_recovery_or_manual_admission() { + for block_check in [true, false] { + let state = std::sync::Arc::new(Maintenance::default()); + assert_eq!(state.reconcile_services(|| Ok(1)).unwrap(), Some(1)); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let worker_state = state.clone(); + let worker = std::thread::spawn(move || { + let (check_release, prepare_release) = if block_check { + (Some(release_rx), None) + } else { + (None, Some(release_rx)) + }; + let prepare_started = started_tx.clone(); + tauri::async_runtime::block_on(worker_state.run_background_update( + 0, + "stable", + async move { + if let Some(release) = check_release { + started_tx.send(()).unwrap(); + release.recv_timeout(Duration::from_secs(5)).unwrap(); + } + Ok(Some(())) + }, + |_| async move { + if let Some(release) = prepare_release { + prepare_started.send(()).unwrap(); + release.recv_timeout(Duration::from_secs(5)).unwrap(); + } + Ok(json!({"channel":"stable","prepared":true})) + }, + )) + .unwrap(); + }); + started_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + // These operations must complete while the network future is + // still held, rather than relying on a wall-clock speed threshold. + assert_eq!(state.recover_connected(|| 2), Some(2)); + assert_eq!(state.reconcile_services(|| Ok(3)).unwrap(), Some(3)); + { + let _manual = state.acquire().unwrap(); + let _supervision = state + .supervision + .try_lock() + .expect("background owns no supervisor lock"); + state.cancel_background_update(); + state.publish( + "available", + json!({"channel":"main","version":"new manual choice"}), + ); + } + release_tx.send(()).unwrap(); + worker.join().unwrap(); + assert_eq!(state.status_snapshot()["details"]["channel"], "main"); + assert!(!state.status_snapshot()["details"]["prepared"] + .as_bool() + .unwrap_or(false)); + } + } + + #[test] + fn prepared_background_update_is_visible_without_becoming_a_service_gate() { + let state = Maintenance::default(); + state.reconcile_services(|| Ok(())).unwrap(); + tauri::async_runtime::block_on(state.run_background_update( + 0, + "stable", + async { Ok(Some(())) }, + |_| async { Ok(json!({"channel":"stable","version":"1.1.0","prepared":true})) }, + )) + .unwrap(); + assert_eq!(state.status_snapshot()["phase"], "available"); + assert_eq!(state.status_snapshot()["details"]["background"], true); + assert_eq!(state.snapshot.lock().unwrap()["phase"], "ready"); + assert_eq!(state.reconcile_services(|| Ok(4)).unwrap(), Some(4)); + assert_eq!(state.recover_connected(|| 5), Some(5)); + } + + #[test] + fn background_failure_retains_evidence_but_not_a_runtime_failure_latch() { + let state = Maintenance::default(); + state.reconcile_services(|| Ok(())).unwrap(); + let result = tauri::async_runtime::block_on(state.run_background_update( + 0, + "stable", + async { Err::, String>("update_download_or_signature_failed".into()) }, + |_| async { panic!("failed check cannot prepare an update") }, + )); + assert!(result.is_err()); + assert_eq!( + state.last_failure.lock().unwrap()["details"]["code"], + "update_download_or_signature_failed" + ); + assert!(!state.manual_failure_pending.load(Ordering::Acquire)); + assert_eq!(state.reconcile_services(|| Ok(6)).unwrap(), Some(6)); + assert_eq!(state.recover_connected(|| 7), Some(7)); + } + fn finish_queued_manual_after_automatic_failure( manual: impl FnOnce(&Maintenance) + Send + 'static, ) -> std::sync::Arc { @@ -1161,6 +1589,67 @@ mod tests { ); } + #[test] + fn rejected_prepared_update_keeps_diagnostics_without_false_boot_stop() { + for (usable, discard_ok, prepared) in [ + (true, true, true), + (false, true, true), + (true, false, true), + (true, true, false), + ] { + let state = Maintenance::default(); + state + .prepared_update_checked + .store(prepared, Ordering::Release); + let code = finalize_install_failure(&state, usable, || { + if discard_ok { + Ok(true) + } else { + Err("update_state_unavailable".into()) + } + }); + let result = tauri::async_runtime::block_on(state.run_automatic_update( + "stable", + async { Err::, String>(code.into()) }, + |_| async { panic!("rejected update cannot apply") }, + || panic!("rejected update cannot restart"), + )); + assert_eq!(result.unwrap_err(), code); + assert_eq!(state.last_failure.lock().unwrap()["details"]["code"], code); + if usable && discard_ok && prepared { + assert_eq!(state.status_snapshot()["phase"], "connecting"); + assert_eq!( + state.last_failure.lock().unwrap()["details"]["journal_discarded"], + true + ); + assert_eq!(state.reconcile_services(|| Ok(())).unwrap(), Some(())); + } else { + assert_eq!(state.status_snapshot()["phase"], "error"); + } + } + } + + #[test] + fn prepared_signature_rejection_is_optional_but_unknown_failures_are_not() { + for (code, phase) in [ + ("update_download_or_signature_failed", "connecting"), + ("backup_failed", "error"), + ] { + let state = Maintenance::default(); + state.prepared_update_checked.store(true, Ordering::Release); + let error = tauri::async_runtime::block_on(state.run_automatic_update( + "stable", + async { Err::, String>(code.into()) }, + |_| async { panic!("rejected cache cannot install") }, + || panic!("rejected cache cannot restart"), + )) + .unwrap_err(); + assert_eq!(error, code); + assert_eq!(state.status_snapshot()["phase"], phase); + assert_eq!(state.last_failure.lock().unwrap()["details"]["code"], code); + } + } + #[test] fn automatic_update_yields_to_an_admitted_manual_action() { let state = Maintenance::default(); diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/prepared_update.rs b/apps/desktop/loopx-control-plane/src-tauri/src/prepared_update.rs new file mode 100644 index 0000000000..e4045b4780 --- /dev/null +++ b/apps/desktop/loopx-control-plane/src-tauri/src/prepared_update.rs @@ -0,0 +1,249 @@ +//! Disposable native updater cache. It grants no installation authority: +//! every read verifies the archive again with the running App's shipped key. +use base64::{engine::general_purpose::STANDARD, Engine}; +use minisign_verify::{PublicKey, Signature}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::{fs, io::Write, path::Path}; + +const INVALID: &str = "update_download_or_signature_failed"; + +pub(crate) struct PreparedUpdate { + pub version: String, + pub signature: String, + pub bytes: Vec, +} + +pub(crate) fn is_newer(current: &str, remote: &str, channel: &str) -> bool { + let (Ok(current), Ok(remote)) = ( + semver::Version::parse(current), + semver::Version::parse(remote), + ) else { + return false; + }; + if channel == "main" && !current.pre.as_str().starts_with("main.") { + remote.pre.as_str().starts_with("main.") + } else { + remote > current + } +} + +fn digest(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn archive_name(metadata: &Value) -> Result { + let hash = metadata["sha256"].as_str().ok_or(INVALID)?; + if hash.len() != 64 + || !hash + .bytes() + .all(|v| v.is_ascii_hexdigit() && !v.is_ascii_uppercase()) + { + return Err(INVALID.into()); + } + Ok(format!("{hash}.tar.gz")) +} + +fn metadata(root: &Path) -> Result, String> { + let path = root.join("prepared.json"); + let file = match fs::File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(INVALID.into()), + }; + use std::io::Read; + let value: Value = serde_json::from_reader(file.take(32 * 1024)).map_err(|_| INVALID)?; + if value["schema_version"] != "desktop_prepared_update_v1" + || !matches!(value["channel"].as_str(), Some("main" | "stable")) + { + return Err(INVALID.into()); + } + archive_name(&value)?; + Ok(Some(value)) +} + +pub(crate) fn save( + root: &Path, + channel: &str, + version: &str, + signature: &str, + bytes: &[u8], +) -> Result<(), String> { + if !matches!(channel, "main" | "stable") || semver::Version::parse(version).is_err() { + return Err(INVALID.into()); + } + fs::create_dir_all(root).map_err(|_| INVALID)?; + let old = metadata(root).ok().flatten(); + let value = json!({"schema_version":"desktop_prepared_update_v1", "channel":channel, + "version":version, "signature":signature, "sha256":digest(bytes)}); + let name = archive_name(&value)?; + // Publish the pointer only after all archive bytes are durable. An + // interrupted download/write cannot replace a previous complete cache. + let mut archive = tempfile::NamedTempFile::new_in(root).map_err(|_| INVALID)?; + archive.write_all(bytes).map_err(|_| INVALID)?; + archive.as_file().sync_all().map_err(|_| INVALID)?; + archive.persist(root.join(&name)).map_err(|_| INVALID)?; + let mut pointer = tempfile::NamedTempFile::new_in(root).map_err(|_| INVALID)?; + serde_json::to_writer(&mut pointer, &value).map_err(|_| INVALID)?; + pointer.as_file().sync_all().map_err(|_| INVALID)?; + pointer + .persist(root.join("prepared.json")) + .map_err(|_| INVALID)?; + if let Some(old_name) = old + .and_then(|v| archive_name(&v).ok()) + .filter(|v| v != &name) + { + let _ = fs::remove_file(root.join(old_name)); + } + Ok(()) +} + +pub(crate) fn load( + root: &Path, + current: &str, + channel: &str, + public_key: &str, +) -> Result, String> { + let Some(value) = metadata(root)? else { + return Ok(None); + }; + let version = value["version"].as_str().ok_or(INVALID)?; + if value["channel"] != channel || !is_newer(current, version, channel) { + clear(root)?; + return Ok(None); + } + let bytes = fs::read(root.join(archive_name(&value)?)).map_err(|_| INVALID)?; + if digest(&bytes) != value["sha256"] { + return Err(INVALID.into()); + } + let key = STANDARD.decode(public_key).map_err(|_| INVALID)?; + let key = + PublicKey::decode(std::str::from_utf8(&key).map_err(|_| INVALID)?).map_err(|_| INVALID)?; + let signature = STANDARD + .decode(value["signature"].as_str().ok_or(INVALID)?) + .map_err(|_| INVALID)?; + let signature = Signature::decode(std::str::from_utf8(&signature).map_err(|_| INVALID)?) + .map_err(|_| INVALID)?; + key.verify(&bytes, &signature, true).map_err(|_| INVALID)?; + Ok(Some(PreparedUpdate { + version: version.into(), + signature: value["signature"].as_str().ok_or(INVALID)?.into(), + bytes, + })) +} + +pub(crate) fn clear(root: &Path) -> Result<(), String> { + let previous = metadata(root) + .ok() + .flatten() + .and_then(|v| archive_name(&v).ok()); + match fs::remove_file(root.join("prepared.json")) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(INVALID.into()), + } + if let Some(name) = previous { + let _ = fs::remove_file(root.join(name)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn update_order_matches_explicit_channel_selection() { + assert!(is_newer("1.0.0", "1.1.0", "stable")); + assert!(!is_newer("1.1.0", "1.0.0", "stable")); + assert!(!is_newer("1.1.0", "1.1.0", "stable")); + assert!(is_newer("1.1.0", "0.0.0-main.20261019", "main")); + assert!(!is_newer( + "0.0.0-main.20261019", + "0.0.0-main.20261018", + "main" + )); + assert!(!is_newer("1.1.0", "invalid", "stable")); + } + + #[test] + fn missing_cache_is_ordinary_and_obsolete_cache_is_retired() { + let root = tempfile::tempdir().unwrap(); + assert!(load(root.path(), "1.0.0", "stable", "").unwrap().is_none()); + save(root.path(), "stable", "1.1.0", "unused", b"old").unwrap(); + assert!(load(root.path(), "1.2.0", "stable", "").unwrap().is_none()); + assert_eq!(fs::read_dir(root.path()).unwrap().count(), 0); + } + + #[test] + fn cached_data_without_a_valid_shipped_signature_is_never_activated() { + let root = tempfile::tempdir().unwrap(); + save(root.path(), "stable", "1.1.0", "invalid", b"untrusted").unwrap(); + assert!(load(root.path(), "1.0.0", "stable", "invalid").is_err()); + fs::write( + root.path().join(format!("{}.tar.gz", digest(b"untrusted"))), + b"changed", + ) + .unwrap(); + assert!(load(root.path(), "1.0.0", "stable", "invalid").is_err()); + } + + #[test] + fn invalid_metadata_cannot_select_an_arbitrary_file() { + let root = tempfile::tempdir().unwrap(); + fs::write( + root.path().join("prepared.json"), + json!({ + "schema_version":"desktop_prepared_update_v1", "channel":"stable", + "version":"1.1.0", "sha256":"../../outside"}) + .to_string(), + ) + .unwrap(); + assert!(load(root.path(), "1.0.0", "stable", "").is_err()); + clear(root.path()).unwrap(); + } + + #[test] + fn replacing_a_prepared_download_preserves_only_the_new_complete_archive() { + let root = tempfile::tempdir().unwrap(); + save(root.path(), "stable", "1.1.0", "signature", b"first").unwrap(); + save(root.path(), "stable", "1.2.0", "signature", b"second").unwrap(); + let value = metadata(root.path()).unwrap().unwrap(); + assert_eq!(value["version"], "1.2.0"); + assert_eq!( + fs::read(root.path().join(archive_name(&value).unwrap())).unwrap(), + b"second" + ); + assert_eq!(fs::read_dir(root.path()).unwrap().count(), 2); + clear(root.path()).unwrap(); + assert_eq!(fs::read_dir(root.path()).unwrap().count(), 0); + } + + #[test] + #[ignore = "requires LOOPX_TEST_SIGNED_ARCHIVE from an actual signed release"] + fn signed_release_cache_is_reverified_after_reopen_and_rejects_tampering() { + let archive = std::env::var("LOOPX_TEST_SIGNED_ARCHIVE").unwrap(); + let bytes = fs::read(&archive).unwrap(); + let signature = fs::read_to_string(format!("{archive}.sig")).unwrap(); + let config: Value = serde_json::from_str(include_str!("../tauri.conf.json")).unwrap(); + let key = config["plugins"]["updater"]["pubkey"].as_str().unwrap(); + let root = tempfile::tempdir().unwrap(); + save(root.path(), "stable", "1.1.0", signature.trim(), &bytes).unwrap(); + assert_eq!( + load(root.path(), "1.0.0", "stable", key) + .unwrap() + .unwrap() + .bytes, + bytes + ); + // Even relabelling a modified archive with its new digest cannot pass + // the retained publisher signature on the next process's read. + let mut changed = bytes; + changed[0] ^= 1; + save(root.path(), "stable", "1.1.0", signature.trim(), &changed).unwrap(); + assert!(load(root.path(), "1.0.0", "stable", key).is_err()); + } +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/desktop-update.tsx b/apps/presentation/dashboard/src/features/personal-workspace/desktop-update.tsx index f08b76ba11..ff022d5fc3 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/desktop-update.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/desktop-update.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react"; import { useWorkspaceI18n } from "./i18n"; type Phase = "idle" | "runtime_required" | "runtime_pairing_required" | "connecting" | "service_error" | "checking" | "available" | "up_to_date" | "downloading" | "installing_app" | "installing_runtime" | "restart_required" | "ready" | "error"; -type UpdateState = { phase: Phase; details?: { version?: string; channel?: string; received?: number; total?: number; code?: string } }; +type UpdateState = { phase: Phase; details?: { version?: string; channel?: string; received?: number; total?: number; code?: string; background?: boolean; prepared?: boolean } }; type RuntimeSelection = { explicit?: boolean; remembered?: boolean; bundled_repair_available?: boolean }; type UpdateStatus = { state: UpdateState | null; app_version: string; rollback_available?: boolean; runtime_selection?: RuntimeSelection }; type DesktopWindow = Window & { __TAURI__?: { core: { invoke: (command: string, args?: Record) => Promise } } }; @@ -41,8 +41,10 @@ export function DesktopUpdate() { const [runtimeSelection, setRuntimeSelection] = useState(); const busy = useRef(false); const statusGeneration = useRef(0); + const selectedChannel = useRef(null); const invoke = (window as DesktopWindow).__TAURI__?.core.invoke; - const working = workingPhases.includes(state.phase); + const observing = workingPhases.includes(state.phase); + const working = observing && state.details?.background !== true; const repairAvailable = runtimeSelection?.explicit !== true && runtimeSelection?.bundled_repair_available !== false; const restoreInPlace = state.phase === "error" && state.details?.code === "app_install_incomplete" && rollbackAvailable; @@ -55,7 +57,9 @@ export function DesktopUpdate() { function commitStatus(value: UpdateStatus, generation: number, includeState = true) { if (generation !== statusGeneration.current) return false; - const stateIsCurrent = includeState && (!busy.current || !value.state?.phase || workingPhases.includes(value.state.phase)); + const reportedChannel = value.state?.details?.channel; + const channelIsCurrent = selectedChannel.current === null || !reportedChannel || reportedChannel === selectedChannel.current; + const stateIsCurrent = includeState && channelIsCurrent && (!busy.current || !value.state?.phase || (workingPhases.includes(value.state.phase) && value.state.details?.background !== true)); readStatus(value, stateIsCurrent); return true; } @@ -75,6 +79,7 @@ export function DesktopUpdate() { void invoke("desktop_update_status").then((value) => { if (!alive || !commitStatus(value, generation)) return; const selected = value.state?.details?.channel ?? (value.app_version.includes("-main.") ? "main" : "stable"); + selectedChannel.current = selected; setChannel(selected); if (!value.state?.phase) { const actionGeneration = ++statusGeneration.current; @@ -91,7 +96,7 @@ export function DesktopUpdate() { }, [invoke]); useEffect(() => { - if (!invoke || !working) return; + if (!invoke || !observing) return; const timer = window.setInterval(() => { const generation = ++statusGeneration.current; void invoke("desktop_update_status").then((value) => { @@ -99,11 +104,12 @@ export function DesktopUpdate() { }).catch(() => {}); }, 1000); return () => window.clearInterval(timer); - }, [invoke, working]); + }, [invoke, observing]); async function run(action: "check" | "apply" | "repair" | "restart" | "rollback" | "forget_runtime_selection") { if (!invoke || busy.current) return; busy.current = true; + selectedChannel.current = channel; statusGeneration.current++; setState({ phase: action === "check" ? "checking" : action === "repair" ? "installing_runtime" : action === "rollback" ? "installing_app" : action === "restart" ? "restart_required" : action === "forget_runtime_selection" ? "connecting" : "downloading" }); try { @@ -134,13 +140,13 @@ export function DesktopUpdate() { const message: Record = { service_error: zh ? "运行时已安装,但服务尚未连接。可重试更新、修复或恢复上版。" : "Runtime installed, but services are unavailable. Retry updates, repair, or restore the previous version.", runtime_pairing_required: zh ? "旧版启动器未能自动选择可用组件。请检查 App 更新,或通过恢复入口重新连接。" : "The older launcher could not select a usable runtime. Check for an App update or reconnect through recovery.", - idle: zh ? "App 启动时会检查并安装当前通道的可用更新;也可在此手动检查。" : "On launch, the App checks and installs available updates on its current channel. You can also check here.", + idle: zh ? "先打开本地版本,更新在后台准备,下次打开时生效。也可在此手动更新。" : "Open the local version first. Updates prepare in the background and apply on the next open. You can also update here.", runtime_required: zh ? "请完成匹配组件安装,或检查 App 更新。" : "Install matching components or check for an App update.", connecting: zh ? "正在连接本地服务…" : "Connecting to local services…", - checking: zh ? "正在检查更新…" : "Checking for updates…", - available: zh ? "新版本已就绪,一次更新 App 与匹配的运行时。" : "Update the App and its matching runtime together.", + checking: state.details?.background ? (zh ? "正在后台检查更新,当前版本可继续使用。" : "Checking in the background. Keep using this version.") : (zh ? "正在检查更新…" : "Checking for updates…"), + available: state.details?.prepared ? (zh ? "更新已下载并通过签名校验,下次打开时自动安装。" : "Update downloaded and signature verified. It installs on the next open.") : (zh ? "新版本已就绪,一次更新 App 与匹配的运行时。" : "Update the App and its matching runtime together."), up_to_date: zh ? "当前通道暂无更新。" : "No newer update on this channel.", - downloading: zh ? "正在下载并校验签名…" : "Downloading and verifying signature…", + downloading: state.details?.background ? (zh ? "正在后台下载并校验签名,当前版本可继续使用。" : "Downloading and verifying in the background. Keep using this version.") : (zh ? "正在下载并校验签名…" : "Downloading and verifying signature…"), installing_app: zh ? "正在安装 App,请保持窗口打开。" : "Installing the App. Keep this window open.", installing_runtime: zh ? "正在安装匹配的运行时,请稍候…" : "Installing the matching runtime…", restart_required: zh ? "重启后将自动完成运行时安装与服务连接。" : "Restart to finish runtime installation and reconnect services.", @@ -149,7 +155,7 @@ export function DesktopUpdate() { ? (zh ? "更新未完成。请重试;启动失败可尝试修复当前版本。" : "Update incomplete. Retry; repair this version if startup fails.") : (zh ? "更新未完成。请重新检查更新;若仍失败,请重新安装完整 App。" : "Update incomplete. Check for updates again; if it still fails, reinstall the complete App.")), }; - const label = state.phase === "connecting" ? (zh ? "正在连接…" : "Connecting…") : working ? (zh ? "正在更新…" : "Updating…") : state.phase === "available" ? (zh ? "有可用更新" : "Update available") : state.phase === "restart_required" ? (zh ? "重启完成更新" : "Restart to finish") : state.phase === "error" ? (zh ? "更新需重试" : "Retry update") : (zh ? "更新 LoopX" : "Update LoopX"); + const label = state.phase === "connecting" ? (zh ? "正在连接…" : "Connecting…") : observing ? (state.details?.background ? (zh ? "后台更新中" : "Preparing update") : (zh ? "正在更新…" : "Updating…")) : state.phase === "available" ? (state.details?.prepared ? (zh ? "更新已准备" : "Update prepared") : (zh ? "有可用更新" : "Update available")) : state.phase === "restart_required" ? (zh ? "重启完成更新" : "Restart to finish") : state.phase === "error" ? (zh ? "更新需重试" : "Retry update") : (zh ? "更新 LoopX" : "Update LoopX"); return
{version} · {channel === "main" ? (zh ? "main 预览版" : "main preview") : (zh ? "稳定版" : "Stable")} {state.details?.version ?

{zh ? "目标版本:" : "Target: "}{state.details.version}

: null} -

{working ?

+

{observing ?

{state.phase === "downloading" && state.details?.total ? : null} {invoke ?
{state.phase === "restart_required" ? : <> @@ -173,7 +179,12 @@ export function DesktopUpdate() {
:

{zh ? "请在 LoopX App 中更新;浏览器自身无需安装包。" : "Update from the LoopX App; the browser needs no installer."}

}
{zh ? "高级选项" : "Advanced options"}