From 77736367e5edd23d42445ea977a1b1b63054ec68 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:13:21 +0800 Subject: [PATCH] fix(desktop): automatically use newer qualified runtimes Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/desktop/loopx-control-plane/README.md | 123 ++- .../src-tauri/src/bundled_runtime.rs | 120 +-- .../loopx-control-plane/src-tauri/src/lib.rs | 41 +- .../src-tauri/src/maintenance.rs | 807 ++++++------------ .../src-tauri/src/runtime_selection.rs | 283 ++++++ .../src-tauri/src/services.rs | 157 +++- .../loopx-control-plane/static/boot.css | 17 - .../loopx-control-plane/static/boot.js | 117 +-- .../loopx-control-plane/static/index.html | 20 +- .../desktop-recovery-diagnostics-test.mjs | 55 +- examples/desktop-update-browser-smoke.mjs | 96 +-- 11 files changed, 922 insertions(+), 914 deletions(-) create mode 100644 apps/desktop/loopx-control-plane/src-tauri/src/runtime_selection.rs diff --git a/apps/desktop/loopx-control-plane/README.md b/apps/desktop/loopx-control-plane/README.md index 84e7a9495b..7b1b907992 100644 --- a/apps/desktop/loopx-control-plane/README.md +++ b/apps/desktop/loopx-control-plane/README.md @@ -12,51 +12,50 @@ desktop release workflow succeeds: - macOS: `.dmg` plus a zipped `.app` bundle; - Windows: `.msi` plus an NSIS `.exe` installer. -On Apple Silicon macOS, signed updater builds carry an exact matching runtime -source snapshot. Open **Update LoopX** in the bottom-left corner, check for an -update, select **Install update**, then **Restart to finish**. The App verifies -the archive signature before replacing itself; the restarted App installs its -bundled runtime and verifies the selected CLI revision before reconnecting. -This updates both layers without asking the operator to run a terminal command. -The boot screen shows runtime installation separately from status/chat service -connection, and reports elapsed startup time across WebView reloads. Slow -startup exposes recovery guidance in the first screen instead of hiding all -progress in the collapsed update panel. -Existing desktop builds without this updater need a one-time App replacement. -Windows preview installers retain the manual CLI installation path; they are -not advertised in the signed update feed until their runtime installer is -qualified. Browser/PWA users continue to use `loopx update`. +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. +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`. ## Updates And Recovery -The update panel is collapsed by default and opens above the sidebar without -reducing the Goal list height. Automatic update checks never replace the App. Its -advanced options expose stable/main channels, repair, and macOS rollback. -The main channel points to the latest **complete signed build**, not arbitrary -moving Git HEAD. A missing feed or failed signature is an error, not proof that -the App is up to date. Release artifacts and matching runtime stay immutable; -only the main channel feed pointer is replaced. - -The App binary owns native windowing, service startup, IPC and update/recovery. -The bundled runtime owns the CLI, HTTP APIs and workspace assets. A runtime-only -CLI update cannot patch native startup or updater bugs; those require an App -update. The App update workflow packages both layers from one Git revision. - -On macOS, opening the App prepares its bundled runtime automatically only when -no default CLI runtime is installed: with nothing to replace, a fresh machine -still bootstraps in one launch. When a *different* runtime is already selected, -the first screen asks the operator instead of replacing it, because that -default CLI may be the newer layer. The two choices are **Update App and -runtime** (check this App's channel, then install the signed App and its -matching snapshot together) and **Use this App's runtime** (install the -snapshot this App carries, which aligns the CLI to the App's revision and can -move it backwards). Services stay stopped until one of them is chosen, so an -App that lags the CLI can no longer silently downgrade the CLI on open. Neither -choice downloads another App on its own or selects another update channel, and -both leave Goal data untouched. A channel with no newer build says so and -leaves the CLI choice standing. Explicit `LOOPX_BIN` overrides are retained and -are never replaced automatically: a mismatched override must be corrected by -its owner. +Startup automatically uses the newest qualified local runtime it can establish. +It compares package versions; equal versions with source revisions use ancestry +from the fixed official GitHub repository. Installation time and lexical SHA +order never determine freshness. If the bounded check is offline, rate limited +or revisions diverge, an already usable runtime keeps working without a version +selection screen or downgrade. Both local HTTP services must expose the selected +artifact's identity before the workspace opens. + +If the bundle is newer, or no selected CLI qualifies, the App prepares its +bundled snapshot in its own data directory through the existing installer. +This updates the CLI used by the App without replacing uv/pip/pipx's command or +editing the user's shell profile. An optional failed upgrade retains a qualified +previous runtime. Core's installation-only doctor qualifies non-editable wheel +fingerprints: a local reuse fence, not publisher or source attestation. Older +CLIs without that readback can use the automatic App-owned fallback. + +A launch-time `LOOPX_BIN` remains the developer's explicit pin. Development mode +also accepts a source runtime without promoting an installation. A previously +saved path is a discovery candidate, not a permanent version pin. Corrupt +preferences fall back to discovery. Runtime selection does not grant Goal, +Todo, capability or account authority. + +Terminal failures stop the wait counter and expose recovery immediately. +**Repair this version** prepares the App-owned runtime and reconnects the same +window; separately managed or explicitly pinned CLIs remain with their owner. +**Forget runtime choice** removes the saved discovery candidate, not a current +`LOOPX_BIN` value. Channels, rollback and copyable diagnostics remain in Recovery +& updates and the workspace's existing update panel. Browser callers supply only +fixed native actions, never commands, paths or download URLs. + +The App binary owns windowing, startup, IPC and update/recovery. Its runtime owns +the CLI, HTTP APIs and workspace assets. A CLI update cannot patch the native +shell, and a shell update does not certify Goal acceptance or provider readiness. +The release workflow packages both layers from one Git revision. The installer and App-owned services use the same bounded tool search, including standard Homebrew locations on macOS, without loading interactive @@ -72,8 +71,9 @@ installation still revalidates enabled extensions by default; the App sets Failed runtime preparation remains supervised, with at most three automatic install attempts per App process and at least 30 seconds between attempts. Existing recovery controls remain available after that budget is exhausted, -and externally corrected installations are still detected. A matching runtime -completes a pending installation journal without reinstalling it. +and externally corrected installations are still detected. Legacy journals +are cleared only after the actual App installation verifies; they do not pin +the runtime to an older source revision. A listener that accepts TCP but does not answer HTTP is given a 15-second startup grace period. The supervisor can then replace it only after verifying @@ -93,27 +93,13 @@ the bundled runtime and then reconnects the same window automatically; it no longer requires a second App restart. Reloading the WebView cannot terminate the native startup supervisor. -An update journal resumes an approved runtime installation after restart. -If the app replacement fails with the previous App verified still in place -(the installed bundle's layout and `codesign` signature both verify on the -actual installed target, and the installed runtime still pairs with the -bundled snapshot), -the journal is discarded instead of resuming and the App keeps starting -normally; a failure that cannot verify the previous App keeps the journal and -surfaces the distinct `app_install_incomplete` recovery state, because the -macOS installer moves the old App away before installing the new one and an -error alone does not prove the original location is intact. A journal naming a -version that never shipped is likewise discarded on resume, but that same -start must then re-run the App/runtime pairing check the no-journal startup -path enforces before services connect. Concurrent transactions and additional -installs before a required restart are rejected. Service readiness is distinct -from installer completion. macOS keeps -a verified previous App for **Restore previous version**; the backup copy is -signature-verified before anything is swapped, while the damaged installation -being repaired is only located, never required to be intact — that is the -state rollback exists to fix. Restart restores its matching runtime too. Goal state is neither deleted nor migrated backwards by -this action, so data-schema compatibility still governs rollback suitability. -Older backup directories are retained for manual recovery and can consume disk. +Legacy update/rollback journals are resolved against the running App before +startup. An incomplete App installation retains its recovery state; runtime +freshness must prevent a journal from silently downgrading a newer CLI. +Concurrent transactions and another install before restart remain rejected. +macOS keeps a signature-verified previous App for **Restore previous version**; +Goal data is not deleted or migrated backwards, so data-schema compatibility +still governs rollback suitability. Older backups may consume disk space. The embedded Recovery & updates section includes selectable, copyable diagnostics with the App version, last failure category, installer exit code when available, @@ -128,9 +114,10 @@ terminal-selected runtime; it does not prove that Desktop selected the same installation or matching revision. When the update snapshot stays in a terminal phase, the boot screen itself stops -presenting an endless loading state: after several poll rounds the main status -line switches to the error projection of the current snapshot code and points at -the Recovery & updates panel. It returns to the loading shape as soon as the +presenting an endless loading state: the main status line immediately shows the current error, stops its wait +counter and expands Recovery & updates. Unchanged invalid selections are +rechecked at most every 30 seconds; an explicit recovery action wakes the +supervisor immediately. It returns to the loading shape as soon as the snapshot leaves the terminal phase (for example while an explicit repair runs). ## Known Issues diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/bundled_runtime.rs b/apps/desktop/loopx-control-plane/src-tauri/src/bundled_runtime.rs index afbfdadd76..7559d828e3 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/bundled_runtime.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/bundled_runtime.rs @@ -57,31 +57,18 @@ pub fn record_pending(app: &AppHandle, version: &str, channel: &str) -> Result<( Ok(()) } -/// Outcome of resolving the persisted update journal against the running App. -#[derive(Debug, PartialEq, Eq)] -pub enum Resume { - /// No journal existed; nothing was resumed. - Absent, - /// The approved journal was applied and the bundled runtime installed. - Applied, - /// A journal naming another App version was discarded. The on-disk - /// runtime was not touched; the caller must re-run the App/runtime - /// pairing gate on this same start before any service connects. - StaleDiscarded, -} - -pub fn resume_pending(app: &AppHandle) -> Result { +// Resolve old App-owned global-runtime journals without reviving their +// exact-revision downgrade policy. An incomplete App retains recovery. +pub(crate) fn finish_legacy_journal(app: &AppHandle) -> Result<(), String> { let path = journal(app)?; match resolve_journal(&path, &app.package_info().version.to_string())? { - JournalResolution::Absent => Ok(Resume::Absent), - JournalResolution::StaleDiscarded => Ok(Resume::StaleDiscarded), + JournalResolution::Absent | JournalResolution::StaleDiscarded => Ok(()), JournalResolution::Approved => { - let metadata = identity(app)?; - if !selected_revision_matches(&metadata) { - install(app)?; + let executable = std::env::current_exe().map_err(|_| "app_install_incomplete")?; + if !crate::update_backup::installed_bundle_verifies(&executable) { + return Err("app_install_incomplete".into()); } - fs::remove_file(&path).map_err(|_| "update_state_unavailable")?; - Ok(Resume::Applied) + discard_journal_at(&path).map(|_| ()) } } } @@ -132,7 +119,17 @@ pub(crate) fn discard_journal_at(path: &Path) -> Result { } } -pub fn install(app: &AppHandle) -> Result<(), String> { +pub(crate) fn private_executable(app: &AppHandle) -> Result { + Ok(app + .path() + .app_local_data_dir() + .map_err(|_| "runtime_selection_unavailable")? + .join("runtime") + .join("bin") + .join("loopx")) +} + +pub(crate) fn install_private(app: &AppHandle) -> Result { let metadata = identity(app)?; let archive = app .path() @@ -140,17 +137,16 @@ pub fn install(app: &AppHandle) -> Result<(), String> { .map_err(|_| "runtime_bundle_missing")? .join("runtime/runtime-source.tar.gz"); let bytes = fs::read(archive).map_err(|_| "runtime_bundle_missing")?; - install_snapshot(&bytes, &metadata) -} - -pub(crate) fn selected_revision_matches(metadata: &Value) -> bool { - crate::services::runtime_identity_for_executable(&crate::services::loopx_executable()) - .as_ref() - .and_then(|value| value["source_revision"].as_str()) - .is_some_and(|revision| metadata["source_revision"].as_str() == Some(revision)) + let executable = private_executable(app)?; + let root = executable + .parent() + .and_then(Path::parent) + .ok_or("runtime_selection_unavailable")?; + install_snapshot(&bytes, &metadata, root)?; + Ok(executable.to_string_lossy().into_owned()) } -fn install_snapshot(bytes: &[u8], metadata: &Value) -> Result<(), String> { +fn install_snapshot(bytes: &[u8], metadata: &Value, private_root: &Path) -> Result<(), String> { let digest: String = Sha256::digest(bytes) .iter() .map(|byte| format!("{byte:02x}")) @@ -174,8 +170,17 @@ fn install_snapshot(bytes: &[u8], metadata: &Value) -> Result<(), String> { c }; crate::services::configure_runtime_environment(&mut command); + let root = private_root; + fs::create_dir_all(root).map_err(|_| "runtime_selection_unavailable")?; + command + .env("LOOPX_BIN_DIR", root.join("bin")) + .env("LOOPX_RELEASES_DIR", root.join("releases")) + .env("LOOPX_SHELL_PROFILE", root.join("shell-profile")) + .env("LOOPX_MAN_ROOT", root.join("man")) + .env("LOOPX_MAN_DIR", root.join("man/man1")); + let selected_executable = root.join("bin/loopx").to_string_lossy().into_owned(); // Preserve the working interpreter of an existing managed snapshot. - if let Ok(executable) = fs::canonicalize(crate::services::loopx_executable()) { + if let Ok(executable) = fs::canonicalize(&selected_executable) { if let Some(release) = executable.parent().and_then(Path::parent) { if let Ok(python) = fs::read_to_string(release.join(".loopx-python")) { command.env("LOOPX_PYTHON", python.trim()); @@ -215,9 +220,8 @@ fn install_snapshot(bytes: &[u8], metadata: &Value) -> Result<(), String> { match child.try_wait() { Ok(Some(status)) => { return if status.success() { - let installed = crate::services::runtime_identity_for_executable( - &crate::services::loopx_executable(), - ); + let installed = + crate::services::runtime_identity_for_executable(&selected_executable); if installed.as_ref().map(|v| &v["source_revision"]) == Some(&metadata["source_revision"]) { @@ -275,28 +279,34 @@ fn extract(bytes: &[u8], destination: &Path) -> Result<(), String> { mod tests { use super::*; #[test] - #[ignore = "requires isolated installer paths and a built App runtime bundle"] - fn real_bundled_installer_qualifies_selected_cli() { - let root = std::env::var("LOOPX_TEST_BUNDLE").expect("isolated bundle path"); - for key in [ - "LOOPX_BIN", - "LOOPX_BIN_DIR", - "LOOPX_RELEASES_DIR", - "LOOPX_REGISTRY", - "LOOPX_RUNTIME_ROOT", - "LOOPX_MAN_DIR", - "LOOPX_SKILLS_DIR", - ] { - assert!( - std::env::var(key).is_ok(), - "explicit isolated {key} required" - ); + #[ignore = "requires an isolated private installer root and built App bundle"] + fn real_private_installer_keeps_the_default_cli_owner() { + let bundle = std::path::PathBuf::from(std::env::var("LOOPX_TEST_BUNDLE").unwrap()); + let root = std::path::PathBuf::from(std::env::var("LOOPX_TEST_PRIVATE_RUNTIME").unwrap()); + for key in ["LOOPX_REGISTRY", "LOOPX_RUNTIME_ROOT"] { + assert!(std::env::var(key).is_ok()); } - let root = Path::new(&root); + let default = crate::services::loopx_executable(); + let original = fs::read(&default).unwrap(); let metadata: Value = - serde_json::from_slice(&fs::read(root.join("identity.json")).unwrap()).unwrap(); - let bytes = fs::read(root.join("runtime-source.tar.gz")).unwrap(); - install_snapshot(&bytes, &metadata).unwrap(); + serde_json::from_slice(&fs::read(bundle.join("identity.json")).unwrap()).unwrap(); + install_snapshot( + &fs::read(bundle.join("runtime-source.tar.gz")).unwrap(), + &metadata, + &root, + ) + .unwrap(); + let observed = crate::services::runtime_identity_for_executable( + root.join("bin/loopx").to_str().unwrap(), + ) + .unwrap(); + assert_eq!(observed["source_revision"], metadata["source_revision"]); + assert_eq!( + fs::read(default).unwrap(), + original, + "separate package manager remains untouched" + ); + assert!(root.join("shell-profile").exists()); } #[test] fn corrupt_archive_cannot_reach_installer() { 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 3bcf2bac4e..a1cc44a483 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod bundled_runtime; mod maintenance; +mod runtime_selection; mod services; mod update_backup; @@ -124,7 +125,7 @@ fn boot_failure_message(error: &str) -> String { // The pairing decision is not a failure: the window is waiting for the // operator to choose between updating the App and aligning the CLI. if error == "runtime_pairing_required" { - return "本机 LoopX 运行时与 App 自带的运行时不一致,请在上方选择「更新 App 与运行时」或「回退 CLI 到本 App 版本」后继续。" + return "请选择继续使用已安装的运行时,或更新 App;选择后同一个窗口会继续打开工作区。" .to_string(); } let is_stable_code = !error.is_empty() @@ -308,10 +309,27 @@ pub fn run() { window.eval(format!("window.loopxBootFailed({encoded})")); } } - for _ in 0..10 { + // A blocked runtime choice is not work to repeat + // every two seconds. Keep recovery responsive while + // bounding Core doctor probes of an unchanged CLI. + let rounds = if matches!( + error.as_str(), + "runtime_identity_unavailable" + | "runtime_pairing_required" + | "runtime_selection_invalid" + | "runtime_selection_unavailable" + ) { + 150 + } else { + 10 + }; + for _ in 0..rounds { if shutting_down_for_setup.load(Ordering::Acquire) { return; } + if maintenance::reconnect_requested(&handle) { + break; + } std::thread::sleep(std::time::Duration::from_millis(200)); } if let Some(window) = handle.get_webview_window("main") { @@ -532,7 +550,7 @@ mod tests { let style = include_str!("../../static/boot.css"); let script = include_str!("../../static/boot.js"); - assert!(html.contains("正在启动本地控制面")); + assert!(html.contains("正在打开 LoopX")); assert!(html.contains("aria-busy=\"true\"")); assert!(html.contains("aria-live=\"polite\"")); assert!(html.contains("class=\"status-dots\"")); @@ -540,21 +558,12 @@ mod tests { assert!(style.contains("@keyframes mark-breathe")); assert!(style.contains("prefers-reduced-motion: reduce")); assert!(style.contains("main[data-state=\"error\"] .progress::after")); - assert!(style.contains("main[data-state=\"decision\"] .progress")); assert!(style.contains("--warning: #f5a623")); assert!(script.contains("desktop_update_status")); assert!(script.contains("window.loopxBootRetrying")); // Services connect concurrently, so the phase names the loopback set // until one connection outlives its peer and can be named on its own. - assert!(script.contains("正在连接本地服务")); - assert!(script.contains("正在连接状态服务")); - assert!(script.contains("正在连接管家对话服务")); - // The first screen must offer both operator choices, not a repair path - // that silently replaces the CLI runtime. - assert!(html.contains("id=\"pairing-align\"")); - assert!(html.contains("回退 CLI")); - assert!(script.contains("runtime_pairing_required")); - assert!(script.contains("\"align_runtime\"")); + assert!(script.contains("正在打开工作区")); // The boot surface must derive its error projection from the polled // snapshot itself and name the known fresh-Mac installer failure. assert!(script.contains("runtime_install_exit_2")); @@ -564,11 +573,11 @@ mod tests { #[test] fn boot_failure_message_appends_stable_codes_only() { use super::boot_failure_message; - // The pairing decision names both operator choices instead of the + // The pairing decision names the forward choices instead of the // generic startup failure text. let pairing = boot_failure_message("runtime_pairing_required"); - assert!(pairing.contains("更新 App 与运行时")); - assert!(pairing.contains("回退 CLI 到本 App 版本")); + assert!(pairing.contains("已安装的运行时")); + assert!(pairing.contains("更新 App")); assert!(!pairing.contains("错误码")); assert_eq!( boot_failure_message("runtime_install_exit_2"), 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 34828b8f36..d2aaffc876 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs @@ -23,6 +23,9 @@ pub struct Maintenance { environment_cache: Mutex>, startup_started: std::sync::OnceLock, phase_started: Mutex>, + separately_managed_runtime: AtomicBool, + automatic_update_checked: AtomicBool, + incomplete_app_installation: AtomicBool, } #[derive(Default)] @@ -92,30 +95,9 @@ impl Maintenance { fn prepare_runtime( &self, - step: RuntimeStep, - explicit_override: bool, now: Instant, - pairing: Value, install: impl FnOnce() -> Result<(), String>, ) -> Result<(), String> { - if step == RuntimeStep::AlreadyPaired { - *self.runtime_retry.lock().unwrap() = RuntimeRetry::default(); - return Ok(()); - } - if explicit_override { - self.publish( - "runtime_required", - json!({"code":"runtime_identity_mismatch", "revision_matches":false}), - ); - return Err("runtime_identity_mismatch".into()); - } - if step == RuntimeStep::AskOperator { - // Replacing a different installed runtime is the operator's call: - // the boot surface offers updating the App or aligning the CLI to - // this App's snapshot. Fail closed without installing anything. - self.publish("runtime_pairing_required", pairing); - return Err("runtime_pairing_required".into()); - } if !self.runtime_retry.lock().unwrap().admit(now) { // Keep the last actionable install error while the live supervisor // observes external correction and permits an explicit repair. @@ -141,7 +123,9 @@ impl Maintenance { &self, start: impl FnOnce() -> Result, ) -> Result, String> { - if self.busy.load(Ordering::Acquire) { + if self.busy.load(Ordering::Acquire) + || self.incomplete_app_installation.load(Ordering::Acquire) + { return Ok(None); } let Ok(_guard) = self.supervision.try_lock() else { @@ -259,7 +243,11 @@ fn detect_environment() -> Value { #[tauri::command] pub fn desktop_update_status(app: AppHandle, state: State<'_, Maintenance>) -> Value { - let snapshot = state.snapshot.lock().unwrap().clone(); + let snapshot = if state.incomplete_app_installation.load(Ordering::Acquire) { + json!({"phase":"error", "details":{"code":"app_install_incomplete"}}) + } else { + state.snapshot.lock().unwrap().clone() + }; let last_failure = state.last_failure.lock().unwrap().clone(); // Probing spawns bounded sub-processes; the boot page polls every second, // so serve the cached block and refresh at most every ENVIRONMENT_TTL. @@ -271,7 +259,8 @@ pub fn desktop_update_status(app: AppHandle, state: State<'_, Maintenance>) -> V } cache.as_ref().expect("refreshed above").1.clone() }; - json!({"state": snapshot, "startup": state.startup_timing(), "last_failure": last_failure, "app_version": app.package_info().version.to_string(), "runtime": bundled_runtime::identity(&app).ok(), "rollback_available": crate::update_backup::available(&app), "environment": environment}) + let selection = crate::runtime_selection::selected(&app).ok(); + json!({"state": snapshot, "startup": state.startup_timing(), "last_failure": last_failure, "app_version": app.package_info().version.to_string(), "runtime": bundled_runtime::identity(&app).ok(), "runtime_selection":{"explicit":selection.as_ref().is_some_and(|selected| selected.environment_override), "remembered":selection.as_ref().is_some_and(|selected| selected.explicit), "bundled_repair_available":selection.is_some() && !state.separately_managed_runtime.load(Ordering::Acquire)}, "rollback_available": crate::update_backup::available(&app), "environment": environment}) } #[tauri::command] pub async fn desktop_update( @@ -285,7 +274,13 @@ pub async fn desktop_update( let url = endpoint(&channel)?; if !matches!( action.as_str(), - "check" | "apply" | "repair" | "align_runtime" | "restart" | "rollback" + "check" + | "apply" + | "repair" + | "align_runtime" + | "forget_runtime_selection" + | "restart" + | "rollback" ) { return Err("invalid_update_action".into()); } @@ -331,17 +326,17 @@ impl Maintenance { // interrupted install resumable and can only ever authorize this App's own // snapshot. async fn reinstall_bundled_runtime(app: &AppHandle) -> Result { + // A separate CLI selection belongs to its installation owner. Never + // promote a snapshot that this same window cannot select afterwards. + let selection = crate::runtime_selection::selected(app)?; let state = app.state::(); + if selection.environment_override || state.separately_managed_runtime.load(Ordering::Acquire) { + return Err("runtime_selection_explicit".into()); + } state.publish("installing_runtime", json!({})); let handle = app.clone(); tauri::async_runtime::spawn_blocking(move || { - bundled_runtime::record_pending( - &handle, - &handle.package_info().version.to_string(), - "bundled", - )?; - bundled_runtime::install(&handle)?; - bundled_runtime::resume_pending(&handle).map(|_| ()) + bundled_runtime::install_private(&handle).map(|_| ()) }) .await .map_err(|_| "runtime_install_failed".to_string())??; @@ -356,6 +351,10 @@ async fn perform( url: tauri::Url, ) -> Result { let state = app.state::(); + if action == "forget_runtime_selection" { + crate::runtime_selection::forget(app)?; + return Ok(state.publish("connecting", json!({}))); + } if action == "check" { state.publish("checking", json!({"channel":channel})); *state.pending.lock().unwrap() = None; @@ -371,7 +370,7 @@ async fn perform( }) .endpoints(vec![url]) .map_err(|_| "update_unavailable")? - .timeout(Duration::from_secs(30)) + .timeout(Duration::from_secs(3)) .build() .map_err(|_| "update_unavailable")? .check() @@ -383,7 +382,11 @@ async fn perform( } else { "up_to_date" }; - *state.pending.lock().unwrap() = update.map(|u| (channel.to_string(), u)); + *state.pending.lock().unwrap() = update.map(|mut update| { + // A quick availability check must not become the download budget. + update.timeout = Some(Duration::from_secs(30)); + (channel.to_string(), update) + }); return Ok(state.publish(phase, details)); } if action == "rollback" { @@ -432,7 +435,8 @@ async fn perform( tauri::async_runtime::spawn_blocking(move || crate::update_backup::prepare(&handle)) .await .map_err(|_| "backup_failed")??; - bundled_runtime::record_pending(app, &update.version, channel)?; + // 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 @@ -446,7 +450,7 @@ async fn perform( // 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 // previously installed App is still in place. Only a verified - // previous App (bundle present, runtime still pairing with it) may + // previous App (bundle intact, a qualified runtime available) may // discard the journal and promise a safe restart; anything else keeps // the journal and surfaces the distinct recovery state so the // verified backup remains the rollback path. @@ -466,25 +470,34 @@ async fn perform( // the second rename failing leaves the original location empty -- its actual // installed target to pass the same signature/integrity verification the // backup boundary uses (a surviving Info.plist and executable do not prove -// sealed resources are intact), and the installed runtime to still pair with -// this App's bundled snapshot. A verified backup copy can never substitute for +// sealed resources are intact), and a qualified runtime must be available. +// A newer runtime need not share this App's source revision. A verified backup copy can never substitute for // verifying the current installation. fn failed_install_left_previous_app_usable(app: &AppHandle) -> bool { let Ok(executable) = std::env::current_exe() else { return false; }; + let selected = crate::runtime_selection::selected(app) + .ok() + .and_then(|selected| crate::services::runtime_identity_for_executable(&selected.executable)) + .or_else(|| { + bundled_runtime::private_executable(app) + .ok() + .and_then(|path| { + crate::services::runtime_identity_for_executable(&path.to_string_lossy()) + }) + }); previous_installation_is_usable( &executable, bundled_runtime::identity(app).ok().as_ref(), - crate::services::runtime_identity_for_executable(&crate::services::loopx_executable()) - .as_ref(), + selected.as_ref(), ) } // Path-level safe-restart predicate shared by the release failure path and // tests: the actual installed target (located from the executable, never a // caller path) must verify layout AND codesign integrity, and the installed -// runtime must still pair with the bundled snapshot. Unverified keeps the +// runtime must have a qualified identity. Unverified keeps the // journal and the `app_install_incomplete` recovery state. fn previous_installation_is_usable( executable: &std::path::Path, @@ -492,7 +505,8 @@ fn previous_installation_is_usable( installed: Option<&Value>, ) -> bool { crate::update_backup::installed_bundle_verifies(executable) - && runtime_revisions_pair(bundled, installed) + && bundled.is_some() + && installed.is_some() } // Recovery classification for a failed app replacement: only a verified @@ -536,6 +550,9 @@ fn finalize_install_failure( ) -> &'static str { let (code, may_discard_journal) = install_failure_recovery(previous_app_usable); if !may_discard_journal { + state + .incomplete_app_installation + .store(true, Ordering::Release); return code; } let journal_result = discard_journal(); @@ -543,213 +560,190 @@ fn finalize_install_failure( state .install_journal_discarded .store(journal_removed, Ordering::Release); - install_failure_state(previous_app_usable, journal_result) -} - -// True when the installed runtime's source_revision equals the bundled -// snapshot's. Any missing identity counts as unpaired: fail closed. -fn runtime_revisions_pair(bundled: Option<&Value>, installed: Option<&Value>) -> bool { - match (bundled, installed) { - (Some(bundled), Some(installed)) => { - bundled["source_revision"] == installed["source_revision"] - } - _ => false, - } -} - -// What this start may do about the runtime, decided before the bounded install -// budget and the explicit `LOOPX_BIN` override are applied. -#[derive(Debug, PartialEq, Eq)] -enum RuntimeStep { - /// The installed runtime already is this App's snapshot. - AlreadyPaired, - /// Installing the bundled snapshot replaces nothing the operator chose: no - /// runtime is installed yet, or an approved journal already carries their - /// consent for this App's snapshot. - InstallBundled, - /// A different runtime is installed and nobody has chosen yet. This is a - /// decision, not a failure: the CLI may be the newer layer, so the App - /// asks instead of replacing it. - AskOperator, -} - -fn classify_runtime_step( - bundled: &Value, - installed: Option<&Value>, - approved_journal: bool, -) -> RuntimeStep { - if runtime_revisions_pair(Some(bundled), installed) { - RuntimeStep::AlreadyPaired - } else if installed.is_none() || approved_journal { - RuntimeStep::InstallBundled - } else { - RuntimeStep::AskOperator - } -} - -// Bounded, non-PII evidence for the operator choice: the two revisions, never -// a path, command or environment value. -fn pairing_details(bundled: &Value, installed: Option<&Value>, app_version: &str) -> Value { - json!({ - "code": "runtime_pairing_required", - "app_version": app_version, - "installed_revision": installed.and_then(|value| value["source_revision"].as_str()), - "bundled_revision": bundled["source_revision"], - "installed_identity_available": installed.is_some(), - "revision_matches": false, - }) + let code = install_failure_state(previous_app_usable, journal_result); + state + .incomplete_app_installation + .store(code == "app_install_incomplete", Ordering::Release); + code } -// Shared App/runtime pairing gate for both release startup entrances: the -// journal-absent path and the start that just discarded a stale journal may -// connect only when the installed runtime pairs with the bundled snapshot. // A runtime state that already published its own phase must not be relabelled // by the supervisor's generic error publication: the boot surface renders the // repair guidance and the operator decision by their own rules. fn runtime_state_publishes_own_phase(error: &str) -> bool { matches!( error, - "runtime_setup_required" | "runtime_pairing_required" + "runtime_setup_required" | "runtime_pairing_required" | "runtime_identity_unavailable" ) } -fn require_paired_runtime(state: &Maintenance, app: &AppHandle) -> Result<(), String> { - let bundled = bundled_runtime::identity(app)?; - let installed = - crate::services::runtime_identity_for_executable(&crate::services::loopx_executable()); - // A journal that reached this gate was just discarded, so no approval - // applies to the runtime that is still on disk. - match classify_runtime_step(&bundled, installed.as_ref(), false) { - RuntimeStep::AlreadyPaired => Ok(()), - RuntimeStep::AskOperator => { - state.publish( - "runtime_pairing_required", - pairing_details( - &bundled, - installed.as_ref(), - &app.package_info().version.to_string(), - ), - ); - Err("runtime_pairing_required".into()) - } - RuntimeStep::InstallBundled => { - state.publish( - "runtime_required", - json!({ - "code":"runtime_setup_required", - "installed_identity_available": false, - "revision_matches": false - }), - ); - Err("runtime_setup_required".into()) - } +fn resume_runtime(app: &AppHandle) -> Result { + use crate::runtime_selection::{compare_official_commits, compare_runtimes, Selection}; + use std::cmp::Ordering as VersionOrder; + let mut selection = crate::runtime_selection::selected(app)?; + let mut installed = crate::services::runtime_identity_for_executable(&selection.executable); + if cfg!(dev) || !cfg!(target_os = "macos") { + return Ok(crate::services::SelectedRuntime { + executable: selection.executable, + identity: installed, + }); } -} + let state = app.state::(); + let _guard = state.acquire()?; + let bundled = bundled_runtime::identity(app)?; + let candidate = json!({"package_version":app.package_info().version.to_string(), "source_revision":bundled["source_revision"]}); + // Only a launch-time developer override pins a runtime. Ordinary launches + // reconcile discovery, a previous choice, and the App-owned installation. + if !selection.environment_override { + let mut candidates = crate::services::discovered_loopx_executables(); + candidates.push( + bundled_runtime::private_executable(app)? + .to_string_lossy() + .into_owned(), + ); + for executable in candidates { + if executable == selection.executable { + continue; + } + if let Some(identity) = crate::services::runtime_identity_for_executable(&executable) { + let replace = installed.as_ref().is_none_or(|current| { + compare_runtimes( + app.package_info(), + current, + &identity, + compare_official_commits, + ) == Some(VersionOrder::Less) + }); + if replace { + selection = Selection { + executable, -// Startup decision after the journal has been resolved. The pairing gate is -// injected so headless tests drive the exact release startup branches: a -// discarded stale journal may connect only through the same gate the -// no-journal entrance enforces, and a failed gate leaves services stopped. -fn startup_after_resume( - state: &Maintenance, - resolved: Result, - pairing_gate: impl FnOnce() -> Result<(), String>, -) -> Result<(), String> { - match resolved { - Ok(bundled_runtime::Resume::Applied) | Ok(bundled_runtime::Resume::Absent) => { - state.publish("connecting", json!({})); - Ok(()) + explicit: false, + environment_override: false, + }; + installed = Some(identity); + } + } } - Ok(bundled_runtime::Resume::StaleDiscarded) => { - pairing_gate()?; - state.publish("connecting", json!({})); + } + if selection.environment_override && installed.is_none() { + state.publish( + "runtime_required", + json!({"code":"runtime_identity_unavailable", "bundled_repair_available":false}), + ); + return Err("runtime_identity_unavailable".into()); + } + let newer_bundle = installed.as_ref().is_none_or(|current| { + compare_runtimes( + app.package_info(), + current, + &candidate, + compare_official_commits, + ) == Some(VersionOrder::Less) + }); + if newer_bundle && !selection.environment_override { + let mut prepared = None; + let result = state.prepare_runtime(Instant::now(), || { + prepared = Some(bundled_runtime::install_private(app)?); Ok(()) - } - Err(error) => { - state.publish("error", json!({"code":error})); - Err(error) + }); + match result { + Ok(()) => { + selection = Selection { + executable: prepared.ok_or("runtime_install_failed")?, + + explicit: false, + environment_override: false, + }; + installed = crate::services::runtime_identity_for_executable(&selection.executable); + if installed.is_none() { + return Err("runtime_identity_unavailable".into()); + } + } + Err(error) if installed.is_none() => return Err(error), + // An optional upgrade must not strand an already usable runtime. + Err(_) => {} } } -} -fn resume_runtime(app: &AppHandle) -> Result<(), String> { - // Development intentionally pairs a live frontend with a developer-selected - // runtime; it must neither replace itself nor force release installation. - if cfg!(dev) || !cfg!(target_os = "macos") { - return Ok(()); + if installed.is_none() { + return Err("runtime_identity_unavailable".into()); } - let state = app.state::(); - let _guard = state.acquire()?; - let bundled = bundled_runtime::identity(app)?; - let installed = - crate::services::runtime_identity_for_executable(&crate::services::loopx_executable()); - let pairing = pairing_details( - &bundled, - installed.as_ref(), - &app.package_info().version.to_string(), + // A previous bundled-install approval cannot silently downgrade a runtime + // chosen by the automatic freshness rule. Private preparation promotes + // only after qualification; interruption is retried normally. + bundled_runtime::finish_legacy_journal(app)?; + if let Err(error) = crate::runtime_selection::remember(app, &selection) { + // A preference is an optimization, not a requirement to use a + // qualified runtime. Re-discovery remains available on next launch. + eprintln!("LoopX runtime preference was not saved: {error}"); + } + state.separately_managed_runtime.store( + selection.environment_override + || installed + .as_ref() + .is_some_and(crate::services::is_owned_package_identity), + Ordering::Release, ); - let explicit_override = std::env::var("LOOPX_BIN").is_ok_and(|v| !v.trim().is_empty()); - // Validate an existing approved target before any automatic installation. - // A journal for another App must never authorize this App's bundle. - let journal = bundled_runtime::journal(app)?; - // A journal naming *this* App version is the operator's standing consent to - // install the snapshot the App carries -- it is how an App update they - // approved finishes. Only a journal for another App is discarded; that path - // must then pass the same gate as a start with no journal at all. - let mut approved_journal = false; - if journal.exists() { - let pending: Value = serde_json::from_slice( - &std::fs::read(&journal).map_err(|_| "update_state_unavailable")?, - ) - .map_err(|_| "update_state_invalid")?; - if pending["version"] != app.package_info().version.to_string() { - state.publish("installing_runtime", json!({})); - let resolved = bundled_runtime::resume_pending(app); - return startup_after_resume(&state, resolved, || require_paired_runtime(&state, app)); - } - approved_journal = true; + Ok(crate::services::SelectedRuntime { + executable: selection.executable, + identity: installed, + }) +} + +fn automatic_app_update(app: &AppHandle) { + let state = app.state::(); + if cfg!(dev) + || !cfg!(target_os = "macos") + || app.config().identifier != "io.loopx.control-plane" + || state.automatic_update_checked.swap(true, Ordering::AcqRel) + { + return; } - let step = classify_runtime_step(&bundled, installed.as_ref(), approved_journal); - state.prepare_runtime( - step, - explicit_override, - Instant::now(), - pairing, - || { - if !journal.exists() { - bundled_runtime::record_pending( - app, - &app.package_info().version.to_string(), - "bundled", - )?; - } - bundled_runtime::resume_pending(app).map(|_| ()) - }, - )?; - if journal.exists() { - // Idempotent completion after a crash between promotion and journal - // removal: do not reinstall an already matching runtime. - bundled_runtime::resume_pending(app).map(|_| ())?; + let channel = if app.package_info().version.pre.as_str().starts_with("main.") { + "main" + } else { + "stable" + }; + let result: Result<(), String> = tauri::async_runtime::block_on(async { + let _guard = state.acquire()?; + let url = endpoint(channel)?; + let checked = perform(app, "check", channel, url.clone()).await?; + if checked["phase"] == "available" { + perform(app, "apply", channel, url).await?; + app.restart(); + } + Ok(()) + }); + if let Err(error) = result { + state.publish_failure(&error, channel); + eprintln!("LoopX automatic App update deferred: {error}"); } - Ok(()) + // An unavailable feed/signature never certifies freshness or prevents use + // of the installed App. Diagnostics retain the failed check. } + pub fn start_services(app: &AppHandle) -> Result, String> { app.state::() .startup_started .get_or_init(Instant::now); + automatic_app_update(app); app.state::().reconcile_services(|| { - if let Err(error) = resume_runtime(app) { - // Runtime states publish the phase that explains them before they - // return: a missing runtime is the repair guidance, and a different - // installed runtime is the operator decision. Relabelling either as - // a generic error would replace the surface that offers the next - // step with a failure notice. - if !runtime_state_publishes_own_phase(&error) { - app.state::() - .publish("error", json!({"code":error})); + let runtime = match resume_runtime(app) { + Ok(runtime) => runtime, + Err(error) => { + // Runtime states publish the phase that explains them before they + // return: a missing runtime is the repair guidance, and a different + // installed runtime is the operator decision. Relabelling either as + // a generic error would replace the surface that offers the next + // step with a failure notice. + if !runtime_state_publishes_own_phase(&error) { + app.state::() + .publish("error", json!({"code":error})); + } + return Err(error); } - return Err(error); - } - crate::services::ServiceSet::start(|pending| { + }; + crate::services::ServiceSet::start(&runtime, |pending| { let service = crate::services::ServiceKind::pending_label(pending); app.state::() .publish("connecting", json!({"service":service})); @@ -766,135 +760,66 @@ pub fn reconnect_requested(app: &AppHandle) -> bool { mod tests { use super::*; #[test] - fn startup_clock_survives_status_reads_and_repeated_phase_publication() { - let state = Maintenance::default(); - assert!(state.startup_timing()["elapsed_ms"].is_null()); - state - .startup_started - .set(Instant::now() - Duration::from_secs(35)) - .unwrap(); - state.publish("installing_runtime", json!({})); - let first = *state.phase_started.lock().unwrap(); - state.publish("installing_runtime", json!({})); - assert_eq!(*state.phase_started.lock().unwrap(), first); - assert!(state.startup_timing()["elapsed_ms"].as_u64().unwrap() >= 35000); - state.publish("connecting", json!({"service":"chat"})); - assert!(state.phase_started.lock().unwrap().unwrap() >= first.unwrap()); - assert!(state.startup_timing()["elapsed_ms"].as_u64().unwrap() >= 35000); - } - #[test] - fn runtime_failure_can_recover_without_restarting_the_supervisor() { + fn automatic_preparation_is_bounded_and_recovery_reuses_the_supervisor() { let state = Maintenance::default(); let now = Instant::now(); - let attempt = |relation, now, install: fn() -> Result<(), String>| { - state.reconcile_services(|| { - state.prepare_runtime(relation, false, now, json!({}), install)?; - Ok(()) - }) - }; - assert!(attempt( - RuntimeStep::InstallBundled, - now, - || Err("runtime_install_exit_1".into()) - ) - .is_err()); - assert!( - state.acquire().is_ok(), - "failed automatic install releases maintenance" - ); - assert!( - attempt(RuntimeStep::InstallBundled, now + Duration::from_secs(2), || panic!( - "backoff must not reinstall" - )) - .is_err() - ); + assert!(state + .reconcile_services( + || state.prepare_runtime(now, || Err("runtime_install_exit_1".into())) + ) + .is_err()); + assert!(state.acquire().is_ok()); + assert!(state + .prepare_runtime(now + Duration::from_secs(2), || panic!("backoff")) + .is_err()); assert_eq!( state.snapshot.lock().unwrap()["details"]["code"], "runtime_install_exit_1" ); assert_eq!( - attempt( - RuntimeStep::InstallBundled, - now + Duration::from_secs(31), - || Ok(()) - ) - .unwrap(), - Some(()) - ); - assert_eq!(state.snapshot.lock().unwrap()["phase"], "ready"); - assert_eq!( - attempt( - RuntimeStep::AlreadyPaired, - now + Duration::from_secs(32), - || panic!("matching runtime must not reinstall") - ) - .unwrap(), + state + .reconcile_services( + || state.prepare_runtime(now + Duration::from_secs(31), || Ok(())) + ) + .unwrap(), Some(()) ); assert_eq!(state.snapshot.lock().unwrap()["phase"], "ready"); - } - - #[test] - fn automatic_install_is_bounded_but_external_repair_is_still_observed() { - let state = Maintenance::default(); - let now = Instant::now(); - for seconds in [0, 31, 62] { + for seconds in [62, 93, 124] { assert!(state - .prepare_runtime( - RuntimeStep::InstallBundled, - false, - now + Duration::from_secs(seconds), - json!({}), - || Err("runtime_install_exit_1".into()) - ) + .prepare_runtime(now + Duration::from_secs(seconds), || Err( + "runtime_install_exit_1".into() + )) .is_err()); } assert!(state - .prepare_runtime( - RuntimeStep::InstallBundled, - false, - now + Duration::from_secs(1000), - json!({}), - || panic!("retry budget exhausted") - ) + .prepare_runtime(now + Duration::from_secs(1000), || panic!( + "budget exhausted" + )) .is_err()); - assert!(state - .prepare_runtime( - RuntimeStep::AlreadyPaired, - false, - now + Duration::from_secs(1001), - json!({}), - || panic!("external correction needs no install") - ) - .is_ok()); + assert_eq!( + state.reconcile_services(|| Ok(())).unwrap(), + Some(()), + "an externally repaired usable runtime needs no new install" + ); } - #[test] - fn explicit_runtime_override_is_never_replaced_automatically() { + fn startup_clock_survives_status_reads_and_repeated_phase_publication() { let state = Maintenance::default(); - assert_eq!( - state - .prepare_runtime( - RuntimeStep::InstallBundled, - true, - Instant::now(), - json!({}), - || panic!("explicit selection must be respected") - ) - .unwrap_err(), - "runtime_identity_mismatch" - ); - assert!(state - .prepare_runtime( - RuntimeStep::AlreadyPaired, - true, - Instant::now(), - json!({}), - || panic!("already matches") - ) - .is_ok()); + assert!(state.startup_timing()["elapsed_ms"].is_null()); + state + .startup_started + .set(Instant::now() - Duration::from_secs(35)) + .unwrap(); + state.publish("installing_runtime", json!({})); + let first = *state.phase_started.lock().unwrap(); + state.publish("installing_runtime", json!({})); + assert_eq!(*state.phase_started.lock().unwrap(), first); + assert!(state.startup_timing()["elapsed_ms"].as_u64().unwrap() >= 35000); + state.publish("connecting", json!({"service":"chat"})); + assert!(state.phase_started.lock().unwrap().unwrap() >= first.unwrap()); + assert!(state.startup_timing()["elapsed_ms"].as_u64().unwrap() >= 35000); } - #[test] fn diagnostics_retain_failure_after_successful_update_check() { let state = Maintenance::default(); @@ -1072,144 +997,6 @@ mod tests { assert_eq!(install["details"]["journal_discarded"], true); } - #[test] - fn pairing_requires_both_identities_to_agree() { - let bundled = |revision: &str| json!({"source_revision": revision}); - assert!(runtime_revisions_pair( - Some(&bundled("a")), - Some(&bundled("a")) - )); - assert!(!runtime_revisions_pair( - Some(&bundled("a")), - Some(&bundled("b")) - )); - // A missing installed runtime identity (or a missing bundle identity) - // is never paired: fail closed. - assert!(!runtime_revisions_pair(Some(&bundled("a")), None)); - assert!(!runtime_revisions_pair(None, Some(&bundled("a")))); - } - - #[test] - fn only_replacing_nothing_is_installed_without_the_operator() { - let bundled = json!({"source_revision": "b".repeat(40)}); - let installed = |revision: &str| json!({"source_revision": revision}); - assert_eq!( - classify_runtime_step(&bundled, Some(&installed(&"b".repeat(40))), false), - RuntimeStep::AlreadyPaired - ); - assert_eq!( - classify_runtime_step(&bundled, Some(&installed(&"a".repeat(40))), false), - RuntimeStep::AskOperator - ); - // No readable runtime identity at all: nothing is replaced, so the - // App may install its own snapshot (fresh machine bootstrap). - assert_eq!( - classify_runtime_step(&bundled, None, false), - RuntimeStep::InstallBundled - ); - // An approved journal for this App version is standing consent: the - // update the operator started must finish instead of asking again. - assert_eq!( - classify_runtime_step(&bundled, Some(&installed(&"a".repeat(40))), true), - RuntimeStep::InstallBundled - ); - assert_eq!( - classify_runtime_step(&bundled, Some(&installed(&"b".repeat(40))), true), - RuntimeStep::AlreadyPaired - ); - } - - #[test] - fn different_installed_runtime_asks_instead_of_replacing_it() { - let state = Maintenance::default(); - let now = Instant::now(); - let pairing = json!({ - "code": "runtime_pairing_required", - "installed_revision": "a".repeat(40), - "bundled_revision": "b".repeat(40), - }); - // Reinstalling here would silently move the host CLI backwards, so the - // install closure must never run -- not even after the retry window, - // which stays untouched because this is a decision, not a failure. - for seconds in [0, 31, 62, 1000] { - assert_eq!( - state - .prepare_runtime( - RuntimeStep::AskOperator, - false, - now + Duration::from_secs(seconds), - pairing.clone(), - || panic!("a different installed runtime must never be replaced"), - ) - .unwrap_err(), - "runtime_pairing_required" - ); - } - let snapshot = state.snapshot.lock().unwrap().clone(); - assert_eq!(snapshot["phase"], "runtime_pairing_required"); - assert_eq!(snapshot["details"], pairing); - // Diagnostics keep the decision reachable for the recovery panel. - assert_eq!(state.last_failure.lock().unwrap()["phase"], "runtime_pairing_required"); - } - - #[test] - fn pairing_evidence_carries_only_the_two_revisions() { - let bundled = json!({"source_revision": "b".repeat(40), "sha256": "PRIVATE"}); - let installed = json!({"source_revision": "a".repeat(40)}); - let details = pairing_details(&bundled, Some(&installed), "1.0.5"); - assert_eq!(details["app_version"], "1.0.5"); - assert_eq!(details["installed_revision"], "a".repeat(40)); - assert_eq!(details["bundled_revision"], "b".repeat(40)); - assert_eq!(details["revision_matches"], false); - assert_eq!(details["installed_identity_available"], true); - assert!(!details.to_string().contains("PRIVATE")); - let mut keys: Vec<&str> = details - .as_object() - .expect("pairing details object") - .keys() - .map(String::as_str) - .collect(); - keys.sort_unstable(); - assert_eq!( - keys, - [ - "app_version", - "bundled_revision", - "code", - "installed_identity_available", - "installed_revision", - "revision_matches" - ] - ); - // An unreadable installed identity is reported as absent, never as a - // fabricated revision. - let absent = pairing_details(&bundled, None, "1.0.5"); - assert_eq!(absent["installed_revision"], Value::Null); - assert_eq!(absent["installed_identity_available"], false); - } - - #[test] - fn pairing_decision_survives_the_service_failure_classifier() { - // The gate's Err reaches reconcile_services as a runtime state, not as - // a service-start failure: the choice must not be relabelled. - let state = Maintenance::default(); - assert!(state - .reconcile_services(|| { - state.prepare_runtime( - RuntimeStep::AskOperator, - false, - Instant::now(), - json!({"code":"runtime_pairing_required"}), - || panic!("must not install"), - ) - }) - .is_err()); - assert_eq!( - state.snapshot.lock().unwrap()["phase"], - "runtime_pairing_required" - ); - } - #[test] fn supervisor_keeps_the_phase_that_explains_a_runtime_state() { // Both runtime states publish their own phase before resume_runtime @@ -1226,59 +1013,11 @@ mod tests { "update_state_invalid", "service_start_failed", ] { - assert!(!runtime_state_publishes_own_phase(relabelled), "{relabelled}"); - } - } - - #[test] - fn stale_journal_start_connects_only_through_the_pairing_gate() { - // Stale journal + paired App/runtime: the start may connect. - let state = Maintenance::default(); - assert!( - startup_after_resume(&state, Ok(bundled_runtime::Resume::StaleDiscarded), || Ok( - () - )) - .is_ok() - ); - assert_eq!(state.snapshot.lock().unwrap()["phase"], "connecting"); - - // Stale journal + mismatched or missing runtime identity: no - // connecting; the gate's runtime_required state stands (an Err from - // resume keeps the service startup thread on the boot-failure path, - // so no service starts). - let state = Maintenance::default(); - assert_eq!( - startup_after_resume(&state, Ok(bundled_runtime::Resume::StaleDiscarded), || { - Err("runtime_setup_required".into()) - }), - Err("runtime_setup_required".into()) - ); - assert_ne!(state.snapshot.lock().unwrap()["phase"], json!("connecting")); - } - - #[test] - fn applied_journals_connect_and_resume_errors_surface_without_connecting() { - for resolved in [ - Ok(bundled_runtime::Resume::Applied), - Ok(bundled_runtime::Resume::Absent), - ] { - let state = Maintenance::default(); - assert_eq!( - startup_after_resume(&state, resolved, || panic!("gate must not rerun")), - Ok(()) + assert!( + !runtime_state_publishes_own_phase(relabelled), + "{relabelled}" ); - assert_eq!(state.snapshot.lock().unwrap()["phase"], "connecting"); } - let state = Maintenance::default(); - assert_eq!( - startup_after_resume(&state, Err("update_state_invalid".into()), || Ok(())), - Err("update_state_invalid".into()) - ); - assert_eq!(state.snapshot.lock().unwrap()["phase"], json!("error")); - assert_eq!( - state.snapshot.lock().unwrap()["details"]["code"], - json!("update_state_invalid") - ); } #[test] @@ -1325,13 +1064,13 @@ mod tests { #[test] #[cfg(target_os = "macos")] - fn safe_restart_promise_requires_a_signature_verified_paired_installation() { + fn safe_restart_requires_an_intact_app_and_a_qualified_runtime() { // Review round 4: drive the production safe-restart predicate // (previous_installation_is_usable — the same function the failed // install path calls) over a real ad-hoc signed synthetic // installation. Layout-only evidence accepted a bundle whose sealed // resource was deleted; the shared codesign gate must reject it, and - // only a signature-intact, runtime-paired installation may discard + // only a signature-intact installation with a qualified runtime may discard // the journal and carry the "可直接重启" (safe restart) promise. use crate::update_backup::signed_app_test_support as support; @@ -1370,18 +1109,23 @@ mod tests { ("app_install_incomplete", false) ); - // Identity mismatch: the installation's signature is intact but the - // installed runtime no longer pairs with the bundled snapshot. - let mismatched = previous_installation_is_usable( + // A different qualified runtime is usable: automatic selection may + // legitimately have selected a newer CLI than this App's snapshot. + let different = previous_installation_is_usable( &support::synthetic_executable(&intact), Some(&bundled("a")), Some(&bundled("b")), ); - assert!(!mismatched); + assert!(different); assert_eq!( - install_failure_recovery(mismatched), - ("app_install_incomplete", false) + install_failure_recovery(different), + ("app_install_failed", true) ); + assert!(!previous_installation_is_usable( + &support::synthetic_executable(&intact), + Some(&bundled("a")), + None, + )); } } @@ -1434,6 +1178,13 @@ mod install_failure_state_tests { }); assert_eq!(code, "app_install_incomplete"); + state.publish("checking", serde_json::json!({})); + assert_eq!( + state + .reconcile_services::<()>(|| panic!("an incomplete App cannot resume")) + .unwrap(), + None + ); assert!( journal.exists(), "the failed effect must preserve the journal" diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/runtime_selection.rs b/apps/desktop/loopx-control-plane/src-tauri/src/runtime_selection.rs new file mode 100644 index 0000000000..b4f4a6f28e --- /dev/null +++ b/apps/desktop/loopx-control-plane/src-tauri/src/runtime_selection.rs @@ -0,0 +1,283 @@ +//! Native launch preference only; installation identity remains owned by Core. +use serde_json::{json, Value}; +use std::{ + fs, + path::{Path, PathBuf}, +}; +use tauri::{AppHandle, Manager}; + +pub(crate) struct Selection { + pub executable: String, + pub explicit: bool, + pub environment_override: bool, +} + +fn preference_path(app: &AppHandle) -> Result { + Ok(app + .path() + .app_local_data_dir() + .map_err(|_| "runtime_selection_unavailable")? + .join("runtime-selection.json")) +} + +fn read_preference(path: &Path) -> Result, String> { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err("runtime_selection_unavailable".into()), + }; + let value: Value = serde_json::from_slice(&bytes).map_err(|_| "runtime_selection_invalid")?; + let executable = value["executable"] + .as_str() + .filter(|s| !s.is_empty()) + .ok_or("runtime_selection_invalid")?; + if value["schema_version"] != "desktop_runtime_selection_v1" + || !Path::new(executable).is_absolute() + { + return Err("runtime_selection_invalid".into()); + } + Ok(Some(executable.to_owned())) +} + +pub(crate) fn selected(app: &AppHandle) -> Result { + if let Ok(executable) = std::env::var("LOOPX_BIN") { + if !executable.trim().is_empty() { + let executable = crate::services::loopx_executable(); + let executable = fs::canonicalize(&executable) + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or(executable); + return Ok(Selection { + executable, + explicit: true, + environment_override: true, + }); + } + } + // A corrupt preference is not an installation failure. Automatic discovery + // can still find a usable CLI or prepare the App-owned runtime. + let remembered = match read_preference(&preference_path(app)?) { + Err(error) => { + eprintln!("LoopX launch preference ignored: {error}"); + None + } + Ok(value) => value, + }; + if let Some(executable) = remembered { + return Ok(Selection { + executable, + explicit: true, + environment_override: false, + }); + } + Ok(Selection { + executable: crate::services::loopx_executable(), + explicit: false, + environment_override: false, + }) +} + +fn write_preference(path: &Path, executable: &str) -> Result<(), String> { + let executable = fs::canonicalize(executable).map_err(|_| "runtime_selection_unavailable")?; + let value = json!({"schema_version":"desktop_runtime_selection_v1", + "executable":executable.to_string_lossy()}); + let directory = path.parent().ok_or("runtime_selection_unavailable")?; + fs::create_dir_all(directory).map_err(|_| "runtime_selection_unavailable")?; + let mut file = + tempfile::NamedTempFile::new_in(directory).map_err(|_| "runtime_selection_unavailable")?; + use std::io::Write; + file.write_all(value.to_string().as_bytes()) + .map_err(|_| "runtime_selection_unavailable")?; + file.as_file() + .sync_all() + .map_err(|_| "runtime_selection_unavailable")?; + file.persist(path) + .map_err(|_| "runtime_selection_unavailable")?; + Ok(()) +} + +pub(crate) fn remember(app: &AppHandle, selection: &Selection) -> Result<(), String> { + write_preference(&preference_path(app)?, &selection.executable) +} + +pub(crate) fn forget(app: &AppHandle) -> Result<(), String> { + match fs::remove_file(preference_path(app)?) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err("runtime_selection_unavailable".into()), + } +} + +// Native launch coordination only. Core continues to qualify installation +// identity. Equal package versions need Git ancestry, never install timestamps +// or lexical SHA order; an offline/diverged answer keeps the usable runtime. +pub(crate) fn compare_runtimes( + package: &tauri::utils::PackageInfo, + installed: &Value, + candidate: &Value, + compare_commits: impl FnOnce(&str, &str) -> Option, +) -> Option { + use std::cmp::Ordering; + fn parse_like(_: &T, text: &str) -> Option { + text.parse().ok() + } + let mut left = parse_like(&package.version, installed["package_version"].as_str()?)?; + let mut right = parse_like(&package.version, candidate["package_version"].as_str()?)?; + // Core wheels carry the release base; main-channel shell prereleases do + // not make that same Core version older than the stable package string. + left.pre = Default::default(); + left.build = Default::default(); + right.pre = Default::default(); + right.build = Default::default(); + match left.cmp(&right) { + Ordering::Equal => { + let left = installed["source_revision"].as_str()?; + let right = candidate["source_revision"].as_str()?; + if left == right { + Some(Ordering::Equal) + } else { + compare_commits(left, right) + } + } + order => Some(order), + } +} + +pub(crate) fn compare_official_commits(left: &str, right: &str) -> Option { + if ![left, right] + .iter() + .all(|revision| revision.len() == 40 && revision.bytes().all(|c| c.is_ascii_hexdigit())) + { + return None; + } + // GitHub includes file patches only on page one. Page two retains the + // comparison relation without downloading a potentially huge source diff. + // https://docs.github.com/en/rest/commits/commits#compare-two-commits + let url = format!( + "https://api.github.com/repos/loopx-project/loopx/compare/{left}...{right}?per_page=1&page=2" + ); + let mut command = std::process::Command::new("curl"); + crate::services::configure_runtime_environment(&mut command); + command.args([ + "--fail", + "--silent", + "--show-error", + "--proto", + "=https", + "--max-time", + "2", + "--header", + "Accept: application/vnd.github+json", + &url, + ]); + let output = + crate::services::timed_output_with_timeout(command, std::time::Duration::from_secs(3))?; + if !output.status.success() { + return None; + } + let payload: Value = serde_json::from_slice(&output.stdout).ok()?; + match payload["status"].as_str()? { + "ahead" => Some(std::cmp::Ordering::Less), + "behind" => Some(std::cmp::Ordering::Greater), + "identical" => Some(std::cmp::Ordering::Equal), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn newer_runtime_uses_versions_then_ancestry_and_never_install_time() { + use std::cmp::Ordering; + let package = tauri::utils::PackageInfo { + name: "LoopX".into(), + version: "1.2.4-main.20261004".parse().unwrap(), + authors: "contributors", + description: "desktop", + crate_name: "desktop", + }; + let identity = |version: &str, revision: Option<&str>| json!({"package_version":version,"source_revision":revision}); + for (left, right, expected) in [ + ("1.2.10", "1.2.9", Ordering::Greater), + ("1.2.3", "1.2.4", Ordering::Less), + ] { + assert_eq!( + compare_runtimes( + &package, + &identity(left, None), + &identity(right, None), + |_, _| panic!("different versions do not need a network") + ), + Some(expected) + ); + } + let old = identity("1.2.4", Some("older")); + let main = identity("1.2.4-main.20261004", Some("newer")); + assert_eq!( + compare_runtimes(&package, &old, &main, |left, right| { + assert_eq!((left, right), ("older", "newer")); + Some(Ordering::Less) + }), + Some(Ordering::Less) + ); + assert_eq!( + compare_runtimes(&package, &old, &main, |_, _| None), + None, + "offline or diverged is not permission to replace" + ); + assert_eq!( + compare_runtimes(&package, &old, &old, |_, _| panic!( + "same revision needs no lookup" + )), + Some(Ordering::Equal) + ); + assert_eq!( + compare_runtimes(&package, &identity("1.2.4", None), &main, |_, _| panic!( + "wheel has no source attestation" + )), + None + ); + assert_eq!( + compare_runtimes(&package, &identity("invalid", None), &main, |_, _| panic!( + "invalid version" + )), + None + ); + } + #[test] + fn preference_survives_restart_but_does_not_store_an_identity_or_authority() { + let root = tempfile::tempdir().unwrap(); + let executable = root.path().join("loopx"); + fs::write(&executable, b"installed CLI").unwrap(); + let path = root.path().join("runtime-selection.json"); + write_preference(&path, executable.to_str().unwrap()).unwrap(); + assert_eq!( + read_preference(&path).unwrap(), + Some( + fs::canonicalize(&executable) + .unwrap() + .to_string_lossy() + .into_owned() + ) + ); + let value: Value = serde_json::from_slice(&fs::read(path).unwrap()).unwrap(); + assert_eq!(value.as_object().unwrap().len(), 2); + } + #[test] + fn broken_selection_is_not_treated_as_a_missing_installation() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("runtime-selection.json"); + assert!(read_preference(&path).unwrap().is_none()); + for bytes in [ + "broken", + "{}", + r#"{"schema_version":"desktop_runtime_selection_v1","executable":"relative/loopx","use_installed":true}"#, + ] { + fs::write(&path, bytes).unwrap(); + assert_eq!( + read_preference(&path).unwrap_err(), + "runtime_selection_invalid" + ); + } + } +} diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/services.rs b/apps/desktop/loopx-control-plane/src-tauri/src/services.rs index a187b027c5..2c62eb0540 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/services.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/services.rs @@ -132,9 +132,22 @@ pub struct ServiceSet { pub healed: bool, } +/// Freeze one selected artifact for both concurrently started services. +pub(crate) struct SelectedRuntime { + pub executable: String, + pub identity: Option, +} + impl ServiceSet { - pub fn start(progress: impl Fn(&[ServiceKind]) + Sync) -> Result { - Self::collect(connect_all(SERVICE_KINDS, connect, progress)) + pub(crate) fn start( + runtime: &SelectedRuntime, + progress: impl Fn(&[ServiceKind]) + Sync, + ) -> Result { + Self::collect(connect_all( + SERVICE_KINDS, + |kind| connect(kind, runtime), + progress, + )) } /// Fold finished connection attempts into one owned set. Every outcome @@ -218,10 +231,10 @@ fn connect_all( }) } -fn connect(kind: ServiceKind) -> ServiceOutcome { +fn connect(kind: ServiceKind, runtime: &SelectedRuntime) -> ServiceOutcome { let mut owned = None; let mut healed = false; - let result = connect_service(kind, &mut owned, &mut healed); + let result = connect_service(kind, runtime, &mut owned, &mut healed); ServiceOutcome { owned, healed, @@ -231,11 +244,12 @@ fn connect(kind: ServiceKind) -> ServiceOutcome { fn connect_service( kind: ServiceKind, + runtime: &SelectedRuntime, owned: &mut Option, healed: &mut bool, ) -> Result<(), ServiceError> { - let executable = loopx_executable(); - let expected_runtime_identity = runtime_identity_for_executable(&executable); + let executable = &runtime.executable; + let expected_runtime_identity = &runtime.identity; let stale_deadline = Instant::now() + STARTUP_TIMEOUT; loop { match probe(kind, expected_runtime_identity.as_ref()) { @@ -256,7 +270,7 @@ fn connect_service( // service (KeepAlive + throttle) has time to restart on the // current release; unknown (Foreign) processes keep the // hard error. - terminate_verified_listener(kind, &executable, kind.port())?; + terminate_verified_listener(kind, executable, kind.port())?; *healed = true; if Instant::now() >= stale_deadline { return Err(ServiceError(format!( @@ -275,7 +289,7 @@ fn connect_service( thread::sleep(Duration::from_millis(100)); continue; } - terminate_verified_listener(kind, &executable, kind.port())?; + terminate_verified_listener(kind, executable, kind.port())?; *healed = true; break; } @@ -297,7 +311,7 @@ fn connect_service( ))); } Probe::Stale => { - terminate_verified_listener(kind, &executable, kind.port())?; + terminate_verified_listener(kind, executable, kind.port())?; *healed = true; request_platform_managed_start(kind); } @@ -312,7 +326,7 @@ fn connect_service( ))); } - let mut command = Command::new(&executable); + let mut command = Command::new(executable); configure_runtime_environment(&mut command); command .args(kind.command_args()) @@ -340,7 +354,7 @@ fn connect_service( ))); } Probe::Stale => { - terminate_verified_listener(kind, &executable, kind.port())?; + terminate_verified_listener(kind, executable, kind.port())?; *healed = true; thread::sleep(Duration::from_millis(200)); } @@ -638,6 +652,13 @@ pub(crate) fn loopx_executable() -> String { .into_owned(); } } + discovered_loopx_executables() + .into_iter() + .next() + .unwrap_or_else(|| "loopx".to_string()) +} + +pub(crate) fn discovered_loopx_executables() -> Vec { let mut candidates = vec![ PathBuf::from("/usr/local/bin/loopx"), PathBuf::from("/opt/homebrew/bin/loopx"), @@ -645,12 +666,20 @@ pub(crate) fn loopx_executable() -> String { if let Some(home) = env::var_os("HOME") { candidates.insert(0, PathBuf::from(home).join(".local/bin/loopx")); } - candidates - .into_iter() - .find(|candidate| candidate.is_file()) - .or_else(|| resolve_executable_path("loopx", env::var_os("PATH").as_deref())) - .map(|candidate| candidate.to_string_lossy().into_owned()) - .unwrap_or_else(|| "loopx".to_string()) + if let Some(path) = resolve_executable_path("loopx", env::var_os("PATH").as_deref()) { + candidates.push(path); + } + let mut discovered = Vec::new(); + for path in candidates.into_iter().filter(|path| path.is_file()) { + let executable = fs::canonicalize(&path) + .unwrap_or(path) + .to_string_lossy() + .into_owned(); + if !discovered.contains(&executable) { + discovered.push(executable); + } + } + discovered } // Finder/launchd do not load a user's interactive shell profile. Use the same @@ -870,9 +899,55 @@ fn runtime_identity_for_executable_with_path( let resolved = resolve_executable_path(executable, search_path)?; let canonical = fs::canonicalize(resolved).ok()?; let release_root = canonical.parent()?.parent()?; - let manifest = fs::read_to_string(Path::new(release_root).join("release.json")).ok()?; - let payload = serde_json::from_str::(&manifest).ok()?; - runtime_identity_from_manifest(&payload) + if let Ok(manifest) = fs::read_to_string(Path::new(release_root).join("release.json")) { + if let Ok(payload) = serde_json::from_str::(&manifest) { + if let Some(identity) = runtime_identity_from_manifest(&payload) { + return Some(identity); + } + } + } + // Core owns package qualification and hashing. Do not recreate RECORD, + // editable-install or byte-fingerprint rules inside the native shell. + let mut command = Command::new(canonical); + configure_runtime_environment(&mut command); + command.args(["--format", "json", "doctor", "--installation-only"]); + let output = timed_output(command)?; + if !output.status.success() { + return None; + } + let payload = serde_json::from_slice(&output.stdout).ok()?; + identity_from_installation_doctor(&payload) +} + +fn identity_from_installation_doctor(payload: &serde_json::Value) -> Option { + if payload["ok"] != true || payload["scope"] != "installation_only" { + return None; + } + let identity = payload.get("service_runtime_identity")?; + if identity["schema_version"] != "loopx_runtime_identity_v1" + || !identity["package_version"].is_string() + || !is_owned_package_identity(identity) + { + return None; + } + // This shape is shared with the HTTP startup fence, not source attestation. + Some(serde_json::json!({ + "schema_version": identity["schema_version"], + "package_version": identity["package_version"], + "release_id": identity["release_id"], + "source_revision": identity["source_revision"], + "package_fingerprint": identity["package_fingerprint"], + })) +} + +pub(crate) fn is_owned_package_identity(identity: &serde_json::Value) -> bool { + identity["release_id"].is_null() + && identity["package_fingerprint"] + .as_str() + .and_then(|value| value.strip_prefix("sha256:")) + .is_some_and(|digest| { + digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) } fn status_readiness_error(kind: ServiceKind) -> ServiceError { @@ -988,6 +1063,48 @@ fn classify_response( #[cfg(test)] mod tests { use super::*; + #[test] + fn package_identity_requires_core_installation_qualification() { + let identity = serde_json::json!({"schema_version":"loopx_runtime_identity_v1", + "package_version":"1.2.4", "release_id":null, "source_revision":null, + "package_fingerprint":format!("sha256:{}", "a".repeat(64)), "path":"PRIVATE"}); + let qualified = serde_json::json!({"ok":true,"scope":"installation_only","service_runtime_identity":identity}); + let observed = identity_from_installation_doctor(&qualified).unwrap(); + assert!(is_owned_package_identity(&observed)); + assert!(observed.get("path").is_none()); + let mut rejected = qualified.clone(); + rejected["ok"] = serde_json::json!(false); + assert!(identity_from_installation_doctor(&rejected).is_none()); + rejected = qualified.clone(); + rejected["scope"] = serde_json::json!("global"); + assert!(identity_from_installation_doctor(&rejected).is_none()); + for value in [ + serde_json::Value::Null, + serde_json::json!("sha256:not-a-digest"), + ] { + rejected = qualified.clone(); + rejected["service_runtime_identity"]["package_fingerprint"] = value; + assert!(identity_from_installation_doctor(&rejected).is_none()); + } + } + + #[test] + fn same_version_package_replacement_cannot_reuse_old_http_services() { + let installed = serde_json::json!({"schema_version":"loopx_runtime_identity_v1", + "package_version":"1.2.4", "release_id":null, "source_revision":null, + "package_fingerprint":format!("sha256:{}", "a".repeat(64))}); + let mut running = installed.clone(); + running["package_fingerprint"] = serde_json::json!(format!("sha256:{}", "b".repeat(64))); + let response = format!( + "HTTP/1.1 200 OK\r\n\r\n{}", + serde_json::json!({ + "schema_version":"loopx_chat_capabilities_v1", "runtime_identity":running}) + ); + assert_eq!( + classify_response(ServiceKind::Chat, &response, Some(&installed)), + Probe::Stale + ); + } #[test] fn service_commands_stay_loopback_and_global() { diff --git a/apps/desktop/loopx-control-plane/static/boot.css b/apps/desktop/loopx-control-plane/static/boot.css index 8bab4928ca..c097ab3751 100644 --- a/apps/desktop/loopx-control-plane/static/boot.css +++ b/apps/desktop/loopx-control-plane/static/boot.css @@ -76,19 +76,6 @@ p { margin: 16px 0 0; color: var(--muted); font-size: 14px; line-height: 1.55; } animation: boot-progress 1.25s ease-in-out infinite; } .boot-note { margin-top: 14px; font-size: 12px; } -.pairing { width: 100%; margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--hairline); text-align: left; } -.pairing[hidden] { display: none; } -.pairing h2 { margin: 0; font-size: 15px; letter-spacing: -.01em; line-height: 1.4; } -.pairing-lead { margin-top: 10px; font-size: 13px; } -.pairing-revisions { display: grid; gap: 6px; margin: 14px 0 0; padding: 12px; border: 1px solid var(--hairline); border-radius: 8px; font-size: 12px; } -.pairing-revisions div { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } -.pairing-revisions dt { color: var(--muted); } -.pairing-revisions dd { margin: 0; font-family: "Geist Mono", monospace; } -.pairing-actions { display: flex; flex-direction: column; margin-top: 14px; } -.pairing-actions button { width: 100%; margin-top: 8px; text-align: center; } -.pairing-actions .pairing-primary { border-color: var(--indicator); background: var(--indicator); color: var(--surface); } -.pairing-status { min-height: 20px; margin-top: 10px; font-size: 12px; } -.pairing-note { margin-top: 10px; font-size: 11px; } .recovery { margin-top: 24px; padding-top: 8px; border-top: 1px solid var(--hairline); font-size: 14px; } details { margin-top: 20px; font-size: 14px; } summary { cursor: pointer; padding: 10px 0; } @@ -101,10 +88,6 @@ button:disabled { cursor: wait; opacity: .6; } main[data-state="error"] .mark { box-shadow: none; animation: none; } main[data-state="error"] .status-dots { display: none; } main[data-state="error"] .progress::after { width: 100%; transform: none; background: var(--warning); animation: none; } -/* The pairing decision is a question, not a failure or a wait. */ -main[data-state="decision"] .mark { box-shadow: none; animation: none; } -main[data-state="decision"] .status-dots { display: none; } -main[data-state="decision"] .progress { display: none; } @keyframes boot-progress { 0% { transform: translateX(-60px); } 50% { transform: translateX(86px); } diff --git a/apps/desktop/loopx-control-plane/static/boot.js b/apps/desktop/loopx-control-plane/static/boot.js index dfc609d7ca..454d4b73aa 100644 --- a/apps/desktop/loopx-control-plane/static/boot.js +++ b/apps/desktop/loopx-control-plane/static/boot.js @@ -3,10 +3,6 @@ const status = document.querySelector("#status"); const bootElapsed = document.querySelector("#boot-elapsed"); const bootDetail = document.querySelector("#boot-detail"); const pageStarted = performance.now(); -// The App publishes this phase when the CLI runtime installed on this host and -// the snapshot bundled with the App are different revisions. It is a decision -// the operator owns, never an error to wait through. -const DECISION_PHASE = "runtime_pairing_required"; let lastTiming = null; let timingObservedAt = pageStarted; let bootState = null; @@ -18,8 +14,11 @@ function renderStartup(result) { timingObservedAt = performance.now(); } const titles = { - installing_runtime: "正在安装 App 配套运行时", - connecting: state?.details?.service === "status" ? "正在连接状态服务" : state?.details?.service === "chat" ? "正在连接管家对话服务" : "正在连接本地服务", + installing_runtime: "正在准备所需组件", + checking: "正在检查更新", + downloading: "正在下载更新", + installing_app: "正在更新 LoopX", + connecting: "正在打开工作区", ready: "本地服务已就绪,正在打开工作区", service_error: "本地服务连接失败,正在等待重试", }; @@ -31,30 +30,31 @@ function renderStartup(result) { updateStartupElapsed(); } function updateStartupElapsed() { + if (["error", "runtime_required"].includes(bootState?.phase)) { + bootElapsed.textContent = "等待恢复"; + bootDetail.textContent = "启动已停止等待。可在下方恢复,不必反复重开 App。"; + return; + } const elapsed = lastTiming === null ? performance.now() - pageStarted : lastTiming + performance.now() - timingObservedAt; const seconds = Math.floor(elapsed / 1000); bootElapsed.textContent = `${lastTiming === null ? "此页面已等待" : "启动已用时"} ${seconds} 秒`; bootDetail.textContent = bootState?.phase === "installing_runtime" - ? "正在安装此 App 随附的组件,并切换本机运行时;无需重复打开 App。" - : bootState?.phase === DECISION_PHASE - ? "选择后同一个窗口会继续启动,不需要重新打开 App。" + ? "正在准备最新可用组件,完成后会自动打开工作区。" : bootState?.phase === "service_error" ? "启动器会自动重试;可展开「恢复与更新」查看诊断。" : seconds >= 15 ? "启动用时较长。当前步骤尚未完成,可展开「恢复与更新」查看诊断。" - : "正在检查 App 配套组件和本地服务。"; + : "正在准备最新可用版本。"; } setInterval(updateStartupElapsed, 1000); window.loopxBootFailed = (message) => { - // The pairing decision owns this state: it is a question for the operator, - // not a startup failure, and the polled snapshot is its only source. - if (bootState?.phase === DECISION_PHASE || bootState?.phase === "connecting") return; + if (bootState?.phase === "connecting") return; panel.dataset.state = "error"; panel.setAttribute("aria-busy", "false"); status.textContent = message; }; window.loopxBootRetrying = () => { - if (bootState?.phase === DECISION_PHASE) return; + if (["error", "runtime_required"].includes(bootState?.phase)) return; panel.dataset.state = "loading"; panel.setAttribute("aria-busy", "true"); status.textContent = "正在重新连接本地控制面"; @@ -64,33 +64,22 @@ const channel = document.querySelector("#channel"); const repair = document.querySelector("#repair"); const rollback = document.querySelector("#rollback"); const updateStatus = document.querySelector("#update-status"); -const pairing = document.querySelector("#pairing"); -const pairingInstalled = document.querySelector("#pairing-installed"); -const pairingBundled = document.querySelector("#pairing-bundled"); -const pairingStatus = document.querySelector("#pairing-status"); -const pairingUpdate = document.querySelector("#pairing-update"); -const pairingAlign = document.querySelector("#pairing-align"); +const forgetSelection = document.querySelector("#forget-selection"); let nextAction = "check"; let working = false; let channelInitialized = false; -// Set once the App reports that the installed CLI runtime and the bundled -// snapshot disagree. It stays visible while the operator's chosen action runs -// and until services connect, so the decision cannot scroll out of the way. -let pairingOwned = false; -const pairingRevisions = { installed: "未检测到", bundled: "未知" }; document.querySelector("#retry").onclick = () => location.reload(); channel.onchange = () => { channelInitialized = true; render({phase:"idle"}); }; const labels = { idle: "检查当前通道,不会自动安装。", service_error: "运行时已安装,但服务尚未连接。可检查更新、修复或恢复上版;连接仍会自动重试。", - runtime_required: "本机组件与 App 版本尚未对齐。可检查 App 更新,或点击修复安装当前匹配组件。", - runtime_pairing_required: "本机 CLI 运行时与 App 自带运行时不一致,请选择如何对齐。", + runtime_required: "无法准备可用组件。可检查 App 更新,或点击修复后重新连接。", checking: "正在检查更新…", available: "App 与匹配运行时可一起更新。", up_to_date: "当前通道暂无更新。", downloading: "正在下载并校验签名…", installing_app: "正在安装 App,请保持窗口打开。", - installing_runtime: "正在安装匹配的运行时,请稍候…", + installing_runtime: "正在准备所需组件,请稍候…", connecting: "正在连接更新后的服务…", restart_required: "请重启 App,继续完成更新。", ready: "更新完成,正在打开工作区。", @@ -105,6 +94,10 @@ const errors = { runtime_install_failed: "运行时安装失败。请展开诊断信息,提供错误码以便排查。", runtime_install_timeout: "运行时安装超过十分钟,已停止。请检查网络和安装依赖后重试。", runtime_identity_mismatch: "安装已结束,但 App 仍选中了不同运行时。请检查是否设置了 LOOPX_BIN。", + runtime_identity_unavailable: "所选运行时尚不能验证。请用它的安装方式更新 CLI,或清除记住的选择后重新检测。App 保留当前安装。", + runtime_selection_invalid: "记住的运行时选择无法读取。清除选择后,App 会重新检测本机 CLI。", + runtime_selection_unavailable: "所选运行时无法读取。请恢复该安装,或清除选择后重新检测。", + runtime_selection_explicit: "当前 App 使用单独选择的运行时,无法用自带组件修复。请通过原安装方式维护它,或先清除选择。开发启动参数 LOOPX_BIN 仍优先。", runtime_staging_failed: "无法创建安装临时目录。请检查磁盘空间及写入权限。", update_state_unavailable: "无法读写更新状态。请检查 App 数据目录的权限和磁盘空间。", update_state_invalid: "更新状态无法读取。请保留诊断信息并反馈问题。", @@ -115,10 +108,10 @@ const errors = { update_check_timeout: "检查更新超时。请稍后重试。", update_network_failed: "无法连接更新服务器。请检查网络后重试。", update_download_or_signature_failed: "更新包下载或签名校验失败,尚未安装。请重新检查更新。", - app_install_failed: "App 安装未能完成,本次更新未生效;已确认当前版本完好且与运行时匹配,可直接重启继续使用,或重新检查更新后再试。", + app_install_failed: "App 安装未能完成,本次更新未生效;已确认当前版本完好且运行时可用,可直接重启继续使用,或重新检查更新后再试。", app_install_incomplete: "App 安装中断,且无法确认当前版本是否完整,请勿直接重启。请在恢复与更新面板还原上一版本(或重新安装)后再试。", + runtime_pairing_required: "旧版本未能自动选择可用组件。请检查 App 更新,或通过恢复入口重新连接。", backup_failed: "无法备份当前版本,更新已停止。请检查磁盘空间后重试。", - runtime_pairing_required: "本机 CLI 运行时与这个 App 自带的运行时不一致,本地服务需要两者一致才能启动。请选择「升级:更新 App 与运行时」,或「回退 CLI:改用本 App 自带运行时」。Goal 数据不会被删除。", }; function codeText(code, phase) { if (typeof code === "string" && /^runtime_install_exit_(\d+|signal)$/.test(code)) { @@ -132,41 +125,19 @@ function codeText(code, phase) { } return Object.hasOwn(errors, code) ? errors[code] : labels[phase] || ""; } -function shortRevision(value) { - return typeof value === "string" && /^[0-9a-f]{7,40}$/.test(value) ? value.slice(0, 12) : null; -} -// The chooser is state, not decoration: it appears with the decision, stays up -// while the chosen action runs, and disappears once services connect. -function renderPairing(state) { - if (state.phase === DECISION_PHASE) pairingOwned = true; - if (["connecting", "ready"].includes(state.phase)) pairingOwned = false; - const installed = shortRevision(state.details?.installed_revision); - const bundled = shortRevision(state.details?.bundled_revision); - if (installed) pairingRevisions.installed = installed; - if (bundled) pairingRevisions.bundled = bundled; - pairing.hidden = !pairingOwned; - if (!pairingOwned) return; - pairingInstalled.textContent = pairingRevisions.installed; - pairingBundled.textContent = pairingRevisions.bundled; - pairingStatus.textContent = codeText(state.details?.code, state.phase); - pairingUpdate.disabled = working; - pairingAlign.disabled = working; - if (state.phase === DECISION_PHASE) status.textContent = "需要你选择 App 与 CLI 运行时的对齐方式"; - panel.dataset.state = "decision"; - panel.setAttribute("aria-busy", "false"); -} function render(state) { if (!state?.phase) return state; if (state.phase === "available" && state.details?.channel !== channel.value) state = {phase:"idle"}; working = ["checking","downloading","installing_app","installing_runtime","connecting"].includes(state.phase); update.disabled = working; repair.disabled = working || state.phase === "restart_required"; + if (state.details?.bundled_repair_available === false) repair.disabled = true; + forgetSelection.disabled = working || state.phase === "restart_required"; rollback.disabled = working || state.phase === "restart_required"; channel.disabled = working || state.phase === "restart_required"; nextAction = state.phase === "available" ? "apply" : state.phase === "restart_required" ? "restart" : "check"; update.textContent = nextAction === "apply" ? "更新并准备重启 / Install update" : nextAction === "restart" ? "重启完成更新 / Restart" : "检查更新 / Check for updates"; updateStatus.textContent = codeText(state.details?.code, state.phase); - renderPairing(state); return state; } const diagnostics = document.querySelector("#diagnostics"); @@ -214,7 +185,7 @@ async function invokeUpdate(action) { // Match the phase the backend publishes for each action (rollback restores // the previous app; restart keeps the required-restart state) instead of // previewing a download that is not happening. - render({phase: action === "check" ? "checking" : action === "repair" || action === "align_runtime" ? "installing_runtime" : action === "rollback" ? "installing_app" : action === "restart" ? "restart_required" : "downloading"}); + render({phase: action === "check" ? "checking" : action === "repair" || action === "align_runtime" ? "installing_runtime" : action === "forget_runtime_selection" ? "connecting" : action === "rollback" ? "installing_app" : action === "restart" ? "restart_required" : "downloading"}); try { return render(await window.__TAURI__.core.invoke("desktop_update", {action,channel:channel.value})); } catch (error) { return render({phase:"error", details:{code: safeCode(error)}}); } } @@ -222,31 +193,18 @@ async function run(action) { if (working) return; return invokeUpdate(action); } -// "Upgrade" is one operator intent, not two clicks: check the channel this App -// already targets and, when a build exists, continue into the verified install. -// A channel with nothing newer says so and leaves the CLI choice standing. -async function upgradeBoth() { - if (working) return; - const checked = await invokeUpdate("check"); - if (!checked) return; - if (checked.phase === "available") return run("apply"); - if (checked.phase === "restart_required") return run("restart"); - if (checked.phase === "up_to_date") { - pairingStatus.textContent = "当前通道没有更新的 App 构建:可「回退 CLI」对齐,或先安装更新的桌面 App。"; - } -} update.onclick = () => run(nextAction); repair.onclick = () => run("repair"); rollback.onclick = () => run("rollback"); -pairingUpdate.onclick = () => void upgradeBoth(); -pairingAlign.onclick = () => void run("align_runtime"); +forgetSelection.onclick = () => void run("forget_runtime_selection"); // The main status line keeps its loading shape while the supervisor retries. -// A snapshot that stays in a terminal phase for several polls is the only -// front-end-derived error projection: the page pulls it from +// A terminal snapshot is decisive. The front-end error projection the page pulls it from // desktop_update_status itself, so it does not depend on the native eval() // calls racing this script's definition. const TERMINAL_PHASES = ["error", "runtime_required"]; -const ERROR_ESCALATION_ROUNDS = 5; +// A terminal native result is already decisive; never keep its loading clock +// or progress treatment while waiting for more identical polls. +const ERROR_ESCALATION_ROUNDS = 1; let terminalRounds = 0; let escalated = false; async function refresh() { @@ -258,20 +216,17 @@ async function refresh() { channelInitialized = true; } rollback.hidden = !result.rollback_available; + forgetSelection.hidden = result.runtime_selection?.explicit !== true && result.runtime_selection?.remembered !== true && !["runtime_selection_invalid", "runtime_selection_unavailable"].includes(result.state?.details?.code); renderDiagnostics(result); render(result.state); + if (result.runtime_selection?.explicit === true || result.runtime_selection?.bundled_repair_available === false) { + repair.disabled = true; + } renderStartup(result); escalateFromSnapshot(result.state); } catch { renderDiagnostics({state:{phase:"error",details:{code:"desktop_status_unavailable"}}}); } } function escalateFromSnapshot(state) { - // The pairing chooser renders itself and stays interactive; escalation is - // only for silent terminal phases that would leave the loading shape up. - if (state?.phase === DECISION_PHASE) { - terminalRounds = 0; - escalated = false; - return; - } const terminal = TERMINAL_PHASES.includes(state?.phase); terminalRounds = terminal ? terminalRounds + 1 : 0; if (terminal && terminalRounds >= ERROR_ESCALATION_ROUNDS) escalated = true; @@ -280,6 +235,8 @@ function escalateFromSnapshot(state) { panel.dataset.state = "error"; panel.setAttribute("aria-busy", "false"); status.textContent = `${codeText(state?.details?.code, state?.phase)} 详见下方「恢复与更新」面板,可复制诊断信息反馈。`; + bootDetail.textContent = "启动已停止等待。可在下方恢复,不必反复重开 App。"; + document.querySelector(".recovery").open = true; } else { escalated = false; panel.dataset.state = "loading"; diff --git a/apps/desktop/loopx-control-plane/static/index.html b/apps/desktop/loopx-control-plane/static/index.html index fe7c1a0c63..4eebcedc2a 100644 --- a/apps/desktop/loopx-control-plane/static/index.html +++ b/apps/desktop/loopx-control-plane/static/index.html @@ -14,26 +14,13 @@

LoopX

- 正在启动本地控制面 + 正在打开 LoopX

正在读取启动进度…

-

正在检查 App 配套组件和本地服务。

- +

正在准备最新可用版本。

+
恢复与更新 / Recovery & updates @@ -43,6 +30,7 @@

App 与 CLI 运行时不一致,请选择如何对齐检查更新 / Check for updates

+
诊断信息 / Diagnostics diff --git a/examples/desktop-recovery-diagnostics-test.mjs b/examples/desktop-recovery-diagnostics-test.mjs index 76818c2f88..7e640d90e6 100644 --- a/examples/desktop-recovery-diagnostics-test.mjs +++ b/examples/desktop-recovery-diagnostics-test.mjs @@ -42,35 +42,7 @@ test('installer failure is actionable and clipboard denial leaves selectable tex await elements.get('#copy-diagnostics').onclick(); assert.equal(elements.get('#diagnostics').selected, true); }); -test('a different installed runtime asks before anything is replaced', () => { - const {context, elements} = page(); - const decision = { - phase: 'runtime_pairing_required', - details: { - code: 'runtime_pairing_required', - installed_revision: 'a'.repeat(40), - bundled_revision: 'b'.repeat(40), - installed_identity_available: true, - revision_matches: false, - }, - }; - runInNewContext('render(packet)', Object.assign(context, {packet: decision})); - assert.equal(elements.get('#pairing').hidden, false); - assert.equal(elements.get('#pairing-installed').textContent, 'a'.repeat(12)); - assert.equal(elements.get('#pairing-bundled').textContent, 'b'.repeat(12)); - assert.match(elements.get('#pairing-status').textContent, /本地服务需要两者一致/); - // Neither choice is a background install: both stay available and neither - // runs before the operator picks one. - assert.equal(elements.get('#pairing-update').disabled, false); - assert.equal(elements.get('#pairing-align').disabled, false); - // The chooser stays up while the chosen action runs, and retires only when - // services connect. - runInNewContext('render({phase:"installing_runtime",details:{}})', context); - assert.equal(elements.get('#pairing').hidden, false); - runInNewContext('render({phase:"connecting",details:{service:"chat"}})', context); - assert.equal(elements.get('#pairing').hidden, true); -}); -test('the pairing decision reaches diagnostics without private detail', () => { +test('legacy pairing state reaches diagnostics without private detail', () => { const {context, elements} = page(); context.packet = { app_version: '1.0.5', @@ -94,3 +66,28 @@ test('the pairing decision reaches diagnostics without private detail', () => { assert.equal(value.revision_matches, false); assert.ok(!JSON.stringify(value).includes('PRIVATE')); }); + +test('terminal runtime identity failure stops waiting and opens recovery immediately', () => { + const {context, elements} = page(); + const packet = {state:{phase:'runtime_required',details:{code:'runtime_identity_unavailable',bundled_repair_available:false}}}; + runInNewContext('render(packet.state); renderStartup(packet); escalateFromSnapshot(packet.state)', Object.assign(context,{packet})); + assert.equal(elements.get('main').dataset.state,'error'); + assert.equal(elements.get('#boot-elapsed').textContent,'等待恢复'); + assert.equal(elements.get('.recovery').open,true); + assert.equal(elements.get('#repair').disabled,true); + assert.match(elements.get('#status').textContent,/App 保留当前安装/); +}); +test('forgetting a discovery preference reconnects without installing a runtime', async () => { + const {context, elements} = page(); + const calls=[]; + context.window.__TAURI__={core:{invoke:async(command,args)=>{calls.push({command,args});return {phase:'connecting'};}}}; + const packet={phase:'runtime_required',details:{bundled_repair_available:false}}; + runInNewContext('render(packet)',Object.assign(context,{packet})); + assert.equal(elements.get('#repair').disabled,true); + assert.equal(calls.length,0,'rendering never installs or selects a runtime'); + await elements.get('#forget-selection').onclick(); + await Promise.resolve(); + assert.equal(calls.length,1); + assert.equal(calls[0].command,'desktop_update'); + assert.equal(calls[0].args.action,'forget_runtime_selection'); +}); diff --git a/examples/desktop-update-browser-smoke.mjs b/examples/desktop-update-browser-smoke.mjs index c6c775de46..e360f35a92 100644 --- a/examples/desktop-update-browser-smoke.mjs +++ b/examples/desktop-update-browser-smoke.mjs @@ -48,7 +48,7 @@ try { if (failUpdate) throw new Error("private diagnostic must not be displayed"); await new Promise((done) => setTimeout(done, 150)); nativeState = { - phase: args.action === "check" ? "available" : args.action === "align_runtime" ? "connecting" : "restart_required", + phase: args.action === "check" ? "available" : ["align_runtime", "use_installed_runtime", "forget_runtime_selection"].includes(args.action) ? "connecting" : "restart_required", details: { version: "0.5.5", channel: args.channel }, }; return nativeState; @@ -116,7 +116,10 @@ try { await missing.screenshot({ path: resolve(output, "missing-assets.png") }); await missing.unroute("**/assets/*.js"); await missing.getByRole("link", { name: "重新加载 / Reload" }).click(); - await missing.getByRole("button", { name: "更新 LoopX" }).waitFor(); + // This page has no injected native bridge: recovery loads the browser + // workspace, whose updater entry is intentionally unavailable. + await missing.getByRole("button", { name: "更新 LoopX" }).waitFor({ state: "hidden" }); + await missing.locator(".personal-workspace-shell").waitFor(); await page.setViewportSize({ width: 1280, height: 900 }); nativeState = null; @@ -214,9 +217,6 @@ try { environmentTelemetry = { os_version: "26.5", arch: "aarch64", runtime_executable_found: false, python3_found: false, python3_version: null }; nativeState = { phase: "error", details: { code: "runtime_install_exit_2" } }; await page.reload(); - assert.equal(await startupPanel.getAttribute("data-state"), "loading"); - await page.waitForTimeout(2500); - assert.equal(await startupPanel.getAttribute("data-state"), "loading", "escalation waits for several terminal poll rounds"); await page.waitForFunction(() => document.querySelector("main").dataset.state === "error"); assert.equal(await startupPanel.getAttribute("aria-busy"), "false"); const bootHeadline = await page.locator("#status").innerText(); @@ -224,7 +224,8 @@ try { assert.ok(bootHeadline.includes("Python 3.11+"), bootHeadline); assert.ok(bootHeadline.includes("恢复与更新"), bootHeadline); assert.equal(await startupDots.isVisible(), false); - await page.getByText("恢复与更新 / Recovery & updates").click(); + assert.equal(await page.locator("details.recovery").getAttribute("open"), ""); + assert.equal(await page.locator("#boot-elapsed").innerText(), "等待恢复"); assert.ok((await page.locator("#update-status").innerText()).includes("本机多半缺少可用的 Python 3.11+")); const bootDiagnostics = JSON.parse(await page.locator("#diagnostics").inputValue()); assert.equal(bootDiagnostics.schema_version, "desktop_recovery_diagnostics_v2"); @@ -240,99 +241,24 @@ try { nativeState = { phase: "runtime_required", details: { code: "runtime_setup_required" } }; await page.waitForFunction(() => document.querySelector("main").dataset.state === "error" && document.querySelector("#status").innerText.includes("请修复当前版本,成功后重启")); environmentTelemetry = null; - // A different installed CLI runtime is the operator's decision, shown on the - // first screen with both choices: update the App and runtime together, or - // replace the CLI runtime with this App's bundled snapshot. Nothing is - // installed until one of them is chosen. - const pairingState = { phase: "runtime_pairing_required", details: { - code: "runtime_pairing_required", app_version: "1.0.5", - installed_revision: "a".repeat(40), bundled_revision: "b".repeat(40), - installed_identity_available: true, revision_matches: false, - } }; - nativeState = pairingState; - await page.reload(); - const pairingPanel = page.locator("#pairing"); - await pairingPanel.waitFor({ state: "visible" }); - assert.equal(await startupPanel.getAttribute("data-state"), "decision"); - assert.equal(await startupPanel.getAttribute("aria-busy"), "false"); - assert.equal(await startupDots.isVisible(), false); - assert.equal(await page.locator("#status").innerText(), "需要你选择 App 与 CLI 运行时的对齐方式"); - assert.equal(await page.locator("#pairing-installed").innerText(), "aaaaaaaaaaaa"); - assert.equal(await page.locator("#pairing-bundled").innerText(), "bbbbbbbbbbbb"); - assert.ok((await page.locator("#pairing-status").innerText()).includes("本地服务需要两者一致")); - assert.equal(await page.getByRole("button", { name: /升级:更新 App 与运行时/ }).isEnabled(), true); - assert.equal(await page.getByRole("button", { name: /回退 CLI:改用本 App 自带运行时/ }).isEnabled(), true); - // A decision never escalates into the error projection, and the native boot - // failure callback cannot overwrite it. - await page.evaluate(() => window.loopxBootFailed("启动失败,请重试。")); - await page.waitForTimeout(2600); - assert.equal(await startupPanel.getAttribute("data-state"), "decision"); - assert.equal(await pairingPanel.isVisible(), true); - assert.equal(await page.locator("#status").innerText(), "需要你选择 App 与 CLI 运行时的对齐方式"); - await page.screenshot({ path: resolve(output, "startup-pairing-decision.png") }); - await page.emulateMedia({ colorScheme: "dark" }); - await page.screenshot({ path: resolve(output, "startup-pairing-decision-dark.png") }); - await page.emulateMedia({ colorScheme: "light" }); - await page.setViewportSize({ width: 390, height: 844 }); - assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "the decision must fit a phone-width window"); - const upgradeBox = await page.getByRole("button", { name: /升级:更新 App 与运行时/ }).boundingBox(); - const alignBox = await page.getByRole("button", { name: /回退 CLI:改用本 App 自带运行时/ }).boundingBox(); - assert.ok(upgradeBox && alignBox && upgradeBox.y + upgradeBox.height <= alignBox.y + 1, "both choices stay visible and ordered"); - await page.screenshot({ path: resolve(output, "startup-pairing-decision-mobile.png") }); - await page.setViewportSize({ width: 1280, height: 900 }); - - // "Upgrade" is one intent: check this App's channel, then continue into the - // verified install instead of asking for a second click. - calls.length = 0; - await page.getByRole("button", { name: /升级:更新 App 与运行时/ }).click(); - await page.waitForFunction(() => document.querySelector("#pairing-status").textContent.includes("重启")); - assert.deepEqual(calls.map((call) => call.args?.action), ["check", "apply"], "upgrade must check then install"); - assert.deepEqual(calls.at(-1).args.channel, "stable"); - assert.equal(await pairingPanel.isVisible(), true, "the install outcome stays on the decision surface"); - - // A channel with nothing newer says so and leaves the CLI choice standing. - nativeState = pairingState; - await page.reload(); - await pairingPanel.waitFor({ state: "visible" }); - calls.length = 0; - checkUpToDate = true; - await page.getByRole("button", { name: /升级:更新 App 与运行时/ }).click(); - await page.waitForFunction(() => document.querySelector("#pairing-status").textContent.includes("没有更新的 App 构建")); - assert.deepEqual(calls.map((call) => call.args?.action), ["check"], "a channel without a newer build must not install anything"); - assert.equal(await pairingPanel.isVisible(), true); - checkUpToDate = false; - - // "Roll back the CLI" installs this App's snapshot and reconnects the same - // window; the chooser retires as soon as services connect. - nativeState = pairingState; - await page.reload(); - await pairingPanel.waitFor({ state: "visible" }); - calls.length = 0; - await page.getByRole("button", { name: /回退 CLI:改用本 App 自带运行时/ }).click(); - await page.getByText("正在连接本地服务", { exact: true }).waitFor(); - assert.deepEqual(calls.map((call) => call.args?.action), ["align_runtime"]); - assert.equal(await pairingPanel.isVisible(), false); - nativeState = { phase: "connecting", details: { service: "chat" } }; - await page.getByText("正在连接管家对话服务", { exact: true }).waitFor(); - // Production boot surface must expose native installation and service // stages even while recovery is collapsed; reload keeps native elapsed time. nativeState = { phase: "installing_runtime", details: {} }; startupTiming = { elapsed_ms: 35000, phase_elapsed_ms: 32000 }; await page.reload(); - await page.getByText("正在安装 App 配套运行时", { exact: true }).waitFor(); + await page.getByText("正在准备所需组件", { exact: true }).waitFor(); assert.ok((await page.locator("#boot-elapsed").innerText()).includes("35 秒")); assert.equal(await page.locator("details.recovery").getAttribute("open"), null); await page.screenshot({ path: resolve(output, "startup-installing-progress.png") }); await page.reload(); - await page.getByText("正在安装 App 配套运行时", { exact: true }).waitFor(); + await page.getByText("正在准备所需组件", { exact: true }).waitFor(); assert.ok((await page.locator("#boot-elapsed").innerText()).includes("35 秒")); nativeState = { phase: "connecting", details: { service: "chat" } }; - await page.getByText("正在连接管家对话服务", { exact: true }).waitFor(); + await page.getByText("正在打开工作区", { exact: true }).waitFor(); await page.getByText(/启动用时较长/).waitFor(); nativeState = { phase: "service_error", details: { code: "service_start_failed" } }; await page.getByText("本地服务连接失败,正在等待重试", { exact: true }).waitFor(); - console.log("desktop-update-browser-smoke: passed (confirmation, failure redaction, mobile, missing assets + reload, startup motion states, startup recovery, startup error escalation, runtime pairing decision)"); + console.log("desktop-update-browser-smoke: passed (confirmation, failure redaction, mobile, missing assets + reload, startup motion states, startup recovery, immediate terminal recovery, automatic startup presentation)"); } finally { await browser.close(); await new Promise((done) => server.close(done));