diff --git a/apps/desktop/loopx-control-plane/README.md b/apps/desktop/loopx-control-plane/README.md index 9e6bef1537..0f873f1220 100644 --- a/apps/desktop/loopx-control-plane/README.md +++ b/apps/desktop/loopx-control-plane/README.md @@ -55,15 +55,41 @@ failed journal retirement or unknown installation failure still requires recover ## Updates And Recovery From a terminal, `loopx upgrade` coordinates the owning CLI installer and this -App's signed updater, restarts the App and waits for native service readiness. +App's signed updater. On writable macOS installations that advertise native +preparation support, the CLI first runs the installed App's windowless updater. +The existing workspace stays open while the native owner checks, downloads and +verifies the signed archive. A matching verified background cache is reused. +Preparation failure leaves the current window running. Only after preparation +succeeds does the CLI quit/reopen the App and wait for qualified services. + +The handoff pins that prepared version in a request-scoped disposable cache. +Restart verifies its bytes again and binds the version to the sealed App bundle; +it never downloads or chases a moving channel feed. Missing, mismatched or corrupt +bytes fail explicitly. The replacement process acknowledges the frozen target, +then reports both service identities. A failed check cannot become success just +because the workspace is healthy. Older Apps and administrator-protected install +locations retain the existing interactive native update/authorization path until +an App with preparation support is installed or the installation is writable. + Archive users can select Main with `loopx upgrade --ref main`; `--dry-run` is -read-only. The terminal passes only a bounded request id and an official channel -to the App. A private request receipt contains versions, source revisions, -update phase and the qualified service runtime identity; it contains no Goal -content or credentials. An update check failure remains a failed terminal -upgrade even when the workspace can still open. Normal launches without that -request perform their network check after local services are ready; an explicit -terminal upgrade retains its thirty-second interactive check budget. +read-only. The terminal passes a bounded request id, official channel and prepared +version, never an arbitrary download URL or installation path. Receipts contain +versions, source revisions, phase and qualified runtime identity, without Goal +content or credentials. Normal launch/background behavior is unchanged. + +macOS 可写安装目录下,`loopx upgrade` 先通过已安装 App 的无窗口原生更新器检查、 +下载并验签,保持原工作区可用;已有匹配的验签缓存会复用。准备成功后才退出、重开 +App,并核验服务身份。准备失败不关闭窗口。切换阶段固定本次版本、重新验签并核对 +密封包版本,不在启动时追逐更新源;缓存丢失、损坏或版本不符明确失败。旧 App 及 +需要管理员授权的目录保留原交互式升级路径。普通启动与后台更新行为不变。 + +This separates usable-workspace time from transfer time, following the same +preparation-before-restart principle visible in +[VS Code's Downloaded/Ready and quit-and-install transitions](https://github.com/microsoft/vscode/blob/4ebcba98631cbacfbf469338653b5d98f9c392b1/src/vs/platform/update/electron-main/updateService.darwin.ts#L177) +and [Zed's asynchronous download/install and restart-path preparation](https://github.com/zed-industries/zed/blob/48741fd17d0dec28be6ad664b0a06dc7202086be/crates/auto_update/src/auto_update.rs#L785). +Their packaging differs from LoopX's independently owned CLI runtime; this does +not combine package owners, remove integrity checks, or claim faster networks. + The existing update panel shows the same native maintenance phases and recovery actions. Main is the latest complete signed build, which can lag Git HEAD. diff --git a/apps/desktop/loopx-control-plane/src-tauri/Info.plist b/apps/desktop/loopx-control-plane/src-tauri/Info.plist new file mode 100644 index 0000000000..8c39f94712 --- /dev/null +++ b/apps/desktop/loopx-control-plane/src-tauri/Info.plist @@ -0,0 +1,5 @@ + + + + LoopXUpgradePreparation1 + 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 ab887464ca..447c8dbf2e 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs @@ -130,6 +130,13 @@ fn workspace_navigation( pub fn run() { #[cfg(target_os = "macos")] service_lifetime::run_if_requested(); + let context = tauri::generate_context!(); + + #[cfg(target_os = "macos")] + if std::env::args().any(|arg| arg == "--loopx-upgrade-prepare") { + maintenance::prepare_terminal_upgrade(context); + return; + } // Release builds load the versioned LoopX Chat workspace that ships inside // the installed `loopx` release, so `loopx update` refreshes the frontend @@ -365,7 +372,7 @@ pub fn run() { let builder = builder.on_web_content_process_terminate(workspace_handoff::content_lost); let app = builder - .build(tauri::generate_context!()) + .build(context) .expect("failed to build LoopX desktop shell"); app.run(move |app, event| { if matches!(event, RunEvent::Exit | RunEvent::ExitRequested { .. }) { 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 d46e35e3e3..b53e821201 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs @@ -1442,6 +1442,38 @@ fn automatic_app_update(app: &AppHandle) { if state.automatic_update_checked.swap(true, Ordering::AcqRel) { return; } + #[cfg(target_os = "macos")] + if let Some(request) = request.filter(|request| request.target.is_some()) { + let result = tauri::async_runtime::block_on(state.run_automatic_update( + channel, + async { + // Both no-update and replacement processes use this frozen + // candidate. A cache miss never falls back to network at boot. + let prepared = request.prepared_target( + &app.package_info().version.to_string(), + updater_public_key(app)?, + )?; + if prepared.is_none() { + state.publish("up_to_date", json!({"channel":channel})); + } + Ok(prepared) + }, + |prepared| async move { + let target = prepared.version; + let version = target.clone(); + install_verified_app(app, target, move || { + crate::macos_update::install_prepared(&version, &prepared.bytes) + }) + .await?; + Ok(()) + }, + || app.restart(), + )); + if let Err(error) = result { + eprintln!("LoopX prepared terminal upgrade failed: {error}"); + } + return; + } // A CLI-requested upgrade is an explicit operation, not an ordinary open. // Preserve its requested channel and exact service-ready return contract. let result: Result, String> = @@ -1473,6 +1505,85 @@ fn automatic_app_update(app: &AppHandle) { // of the installed App. Diagnostics retain the failed check. } +#[cfg(target_os = "macos")] +async fn prepare_terminal_candidate( + update: Option, + current: &str, + channel: &str, + key: &str, + automatic_root: &std::path::Path, + request_root: &std::path::Path, +) -> Result { + let Some(update) = update else { + return Ok(current.into()); + }; + let cached = crate::prepared_update::load(automatic_root, current, channel, key) + .ok() + .flatten() + .filter(|cached| cached.version == update.version && cached.signature == update.signature); + let bytes = if let Some(cached) = cached { + cached.bytes + } else { + download_update(&update, |_, _| {}).await? + }; + crate::prepared_update::save( + request_root, + channel, + &update.version, + &update.signature, + &bytes, + )?; + Ok(update.version) +} + +// The installed App remains the signature/channel owner. This bounded helper +// has no window, single-instance callback or service lifecycle: the CLI waits +// for preparation before asking the existing window to quit. +#[cfg(target_os = "macos")] +pub fn prepare_terminal_upgrade(context: tauri::Context) { + tauri::Builder::default() + .plugin(tauri_plugin_updater::Builder::new().build()) + .setup(|app| { + app.set_activation_policy(tauri::ActivationPolicy::Prohibited); + let outcome = tauri::async_runtime::block_on(async { + let handle = app.handle(); + let request = crate::upgrade_request::UpgradeRequest::from_args( + &std::env::args().collect::>(), + handle + .path() + .app_local_data_dir() + .map_err(|_| "update_unavailable")?, + handle.package_info().version.to_string(), + Value::Null, + ) + .ok_or("invalid_update_action")?; + let channel = &request.channel; + let update = + find_update(handle, channel, endpoint(channel)?, Duration::from_secs(30)) + .await?; + let target = prepare_terminal_candidate( + update, + &handle.package_info().version.to_string(), + channel, + updater_public_key(handle)?, + &prepared_directory(handle)?, + &request.preparation_directory(), + ) + .await?; + Ok::<_, String>(json!({"ok":true,"channel":channel,"target":target})) + }); + let code = if outcome.is_ok() { 0 } else { 1 }; + println!( + "{}", + outcome.unwrap_or_else(|code| json!({"ok":false,"code":code})) + ); + app.handle().exit(code); + Ok(()) + }) + .run(context) + .expect("native upgrade preparation"); +} + pub fn start_background_update(app: &AppHandle) { let state = app.state::(); if cfg!(dev) diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance_transfer_tests.rs b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance_transfer_tests.rs index 76774ab101..c70b543165 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance_transfer_tests.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance_transfer_tests.rs @@ -479,3 +479,135 @@ fn released_archive_survives_slow_native_transport_and_cache_reopen() { ); eprintln!("released signed archive: {} bytes, {:.3}s including verified cache reopen; no installation", bytes.len(), start.elapsed().as_secs_f64()); } + +#[cfg(target_os = "macos")] +#[test] +fn terminal_preparation_reuses_verified_bytes_and_freezes_an_offline_handoff() { + let app = app(KEY); + let server = Server::new(); + let root = tempfile::tempdir().unwrap(); + let automatic = root.path().join("automatic"); + let request = crate::upgrade_request::UpgradeRequest::from_args( + &[ + format!("--loopx-upgrade-request={}", "a".repeat(32)), + "--loopx-upgrade-channel=stable".into(), + "--loopx-upgrade-target=1.1.0".into(), + ], + root.path().into(), + "0.1.0".into(), + Value::Null, + ) + .unwrap(); + tauri::async_runtime::block_on(async { + let update = find_update( + app.handle(), + "stable", + server.feed("fast"), + Duration::from_secs(3), + ) + .await + .unwrap(); + assert_eq!( + prepare_terminal_candidate( + update.clone(), + "0.1.0", + "stable", + KEY, + &automatic, + &request.preparation_directory() + ) + .await + .unwrap(), + "1.1.0" + ); + assert_eq!(server.requests.load(Ordering::Acquire), 2); + assert_eq!( + request + .prepared_target("0.1.0", KEY) + .unwrap() + .unwrap() + .bytes, + BODY + ); + crate::prepared_update::save(&automatic, "stable", "1.1.0", SIGNATURE, BODY).unwrap(); + prepare_terminal_candidate( + update, + "0.1.0", + "stable", + KEY, + &automatic, + &request.preparation_directory(), + ) + .await + .unwrap(); + assert_eq!( + server.requests.load(Ordering::Acquire), + 2, + "verified automatic cache avoids a second archive transfer" + ); + }); + drop(server); // No network is available at the replacement boundary. + assert_eq!( + request + .prepared_target("0.1.0", KEY) + .unwrap() + .unwrap() + .bytes, + BODY + ); + // Another window's background cache cannot replace this request's bytes. + crate::prepared_update::clear(&automatic).unwrap(); + assert!(request.prepared_target("0.1.0", "invalid").is_err()); + crate::prepared_update::save( + &request.preparation_directory(), + "stable", + "1.2.0", + SIGNATURE, + BODY, + ) + .unwrap(); + assert!(request.prepared_target("0.1.0", KEY).is_err()); + crate::prepared_update::clear(&request.preparation_directory()).unwrap(); + assert!(request.prepared_target("0.1.0", KEY).is_err()); + assert!(request.prepared_target("1.1.0", KEY).unwrap().is_none()); +} + +#[cfg(target_os = "macos")] +#[test] +fn terminal_preparation_rejects_bad_download_before_any_handoff() { + let app = app(KEY); + let server = Server::new(); + let root = tempfile::tempdir().unwrap(); + tauri::async_runtime::block_on(async { + let update = find_update( + app.handle(), + "stable", + server.feed("tampered"), + Duration::from_secs(3), + ) + .await + .unwrap(); + let request = root.path().join("request"); + assert_eq!( + prepare_terminal_candidate( + update, + "0.1.0", + "stable", + KEY, + &root.path().join("automatic"), + &request + ) + .await + .unwrap_err(), + "update_signature_invalid" + ); + assert!(!request.exists()); + assert_eq!( + prepare_terminal_candidate(None, "1.1.0", "stable", KEY, root.path(), &request) + .await + .unwrap(), + "1.1.0" + ); + assert!(!request.exists()); + }); +} diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/upgrade_request.rs b/apps/desktop/loopx-control-plane/src-tauri/src/upgrade_request.rs index 07f7321304..418310abcb 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/upgrade_request.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/upgrade_request.rs @@ -11,6 +11,8 @@ use std::{ pub(crate) struct UpgradeRequest { pub channel: String, + #[cfg(target_os = "macos")] + pub target: Option, id: String, path: PathBuf, version: String, @@ -39,8 +41,16 @@ impl UpgradeRequest { if !matches!(channel, "main" | "stable") { return None; } + let target = args + .iter() + .find_map(|s| s.strip_prefix("--loopx-upgrade-target=")); + if target.is_some_and(|value| value.len() > 128 || semver::Version::parse(value).is_err()) { + return None; + } Some(Self { channel: channel.to_string(), + #[cfg(target_os = "macos")] + target: target.map(str::to_owned), id: id.to_string(), path: directory.join("upgrades").join(format!("{id}.json")), version, @@ -51,6 +61,31 @@ impl UpgradeRequest { }) } + #[cfg(target_os = "macos")] + pub fn preparation_directory(&self) -> PathBuf { + self.path.with_extension("prepared") + } + + #[cfg(target_os = "macos")] + pub fn prepared_target( + &self, + current: &str, + key: &str, + ) -> Result, String> { + let target = self + .target + .as_deref() + .ok_or("update_download_or_signature_failed")?; + if target == current { + let _ = crate::prepared_update::clear(&self.preparation_directory()); + return Ok(None); + } + crate::prepared_update::load(&self.preparation_directory(), current, &self.channel, key)? + .filter(|prepared| prepared.version == target) + .map(Some) + .ok_or_else(|| "update_download_or_signature_failed".into()) + } + pub fn services_ready(&self, identity: Option<&Value>) { *self.runtime.lock().unwrap() = identity.cloned(); self.publish("ready", &json!({})); diff --git a/apps/desktop/loopx-control-plane/src-tauri/tauri.conf.json b/apps/desktop/loopx-control-plane/src-tauri/tauri.conf.json index dd5963130e..d2eee415be 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/tauri.conf.json +++ b/apps/desktop/loopx-control-plane/src-tauri/tauri.conf.json @@ -19,6 +19,7 @@ "createUpdaterArtifacts": false, "resources": {"../runtime/runtime-source.tar.gz": "runtime/runtime-source.tar.gz", "../runtime/identity.json": "runtime/identity.json"}, "active": true, + "macOS": {"infoPlist": "Info.plist"}, "targets": ["app", "dmg", "msi", "nsis", "deb", "appimage"], "category": "DeveloperTool", "shortDescription": "Local LoopX control plane", diff --git a/loopx/cli_commands/support_control_upgrade.py b/loopx/cli_commands/support_control_upgrade.py index 7c130956a1..273f0119b3 100644 --- a/loopx/cli_commands/support_control_upgrade.py +++ b/loopx/cli_commands/support_control_upgrade.py @@ -72,6 +72,11 @@ def desktop_upgrade_plan(ref: str | None) -> dict[str, Any]: "changes_applied": False, "application": str(app), "channel": channel, + # A protected installation retains the existing interactive native + # authorization path. Preparation must not remove that capability. + "preparation_supported": ( + info.get("LoopXUpgradePreparation") == 1 and os.access(app.parent, os.W_OK) + ), **evidence, "reason": "The native App verifies its signed channel update, restarts and qualifies both services.", } @@ -157,7 +162,8 @@ def _running_receipt(receipt: dict[str, Any]) -> bool: def _restart_app( - app: Path, request_id: str, channel: str, timeout_seconds: int + app: Path, request_id: str, channel: str, timeout_seconds: int, + *, target: str | None = None, ) -> None: # AppleScript receives the path as an argv value, never interpolated code. # Quit is asynchronous. Keep the bounded wait in the same normal App @@ -187,6 +193,7 @@ def _restart_app( "--args", f"--loopx-upgrade-request={request_id}", f"--loopx-upgrade-channel={channel}", + *([f"--loopx-upgrade-target={target}"] if target else []), ], check=True, capture_output=True, @@ -199,9 +206,37 @@ def _upgrade_desktop(plan: dict[str, Any], timeout_seconds: int) -> dict[str, An request_id = uuid.uuid4().hex receipt = _receipt_directory() / f"{request_id}.json" started = time.monotonic() - _restart_app(app, request_id, plan["channel"], timeout_seconds) last = None try: + target = None + if plan.get("preparation_supported"): + info = plistlib.loads((app / "Contents/Info.plist").read_bytes()) + executable = info.get("CFBundleExecutable", "") + if (not isinstance(executable, str) or not executable or executable in {".", ".."} + or Path(executable).name != executable): + raise ValueError("invalid native upgrade executable") + prepared = subprocess.run( + [str(app / "Contents/MacOS" / executable), + "--loopx-upgrade-prepare", f"--loopx-upgrade-request={request_id}", + f"--loopx-upgrade-channel={plan['channel']}"], + capture_output=True, text=True, timeout=timeout_seconds, + ) + result = json.loads(prepared.stdout) + target = result.get("target") if isinstance(result, dict) else None + if (not isinstance(result, dict) or prepared.returncode != 0 or result.get("ok") is not True + or result.get("channel") != plan["channel"] + or not isinstance(target, str) or not target or len(target) > 128 + or any(c not in "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-+" for c in target)): + return {**plan, "ok": False, "status": "preparation_failed", + "changes_applied": False, + "reason": "Native App preparation failed; the running App was not restarted."} + remaining = timeout_seconds - (time.monotonic() - started) + if remaining <= 0: + raise subprocess.TimeoutExpired("native upgrade preparation", timeout_seconds) + if target is None: + _restart_app(app, request_id, plan["channel"], remaining) + else: + _restart_app(app, request_id, plan["channel"], remaining, target=target) while time.monotonic() - started < timeout_seconds: try: if receipt.stat().st_size <= 16 * 1024: @@ -231,6 +266,7 @@ def _upgrade_desktop(plan: dict[str, Any], timeout_seconds: int) -> dict[str, An } if ( last.get("phase") == "ready" + and (target is None or last.get("app_version") == target) and last.get("update_checked") is True and _running_receipt(last) and isinstance(last.get("runtime_identity"), dict) @@ -262,6 +298,11 @@ def _upgrade_desktop(plan: dict[str, Any], timeout_seconds: int) -> dict[str, An } finally: receipt.unlink(missing_ok=True) + # Only this request's disposable, signed archive; never another + # window's automatic cache or an installation/recovery journal. + if plan.get("preparation_supported"): + import shutil + shutil.rmtree(receipt.with_suffix(".prepared"), ignore_errors=True) def finish_upgrade( @@ -304,7 +345,7 @@ def finish_upgrade( return payload try: progress( - "Updating and restarting App; waiting for native update and service readback" + "Preparing App update before restart; then verifying native service readiness" ) desktop = _upgrade_desktop(desktop, timeout_seconds) except (OSError, ValueError, subprocess.SubprocessError) as error: diff --git a/tests/test_self_upgrade.py b/tests/test_self_upgrade.py index 5293af83c6..32964bd92b 100644 --- a/tests/test_self_upgrade.py +++ b/tests/test_self_upgrade.py @@ -127,6 +127,84 @@ def run(argv, **kwargs): pass +@pytest.mark.parametrize("failure", [None, "signature", "timeout", "invalid"]) +def test_native_preparation_keeps_existing_app_until_verified(monkeypatch, tmp_path, failure): + import subprocess + import threading + + app = app_fixture(tmp_path) + info = plistlib.loads((app / "Contents/Info.plist").read_bytes()) + info.update(LoopXUpgradePreparation=1, CFBundleExecutable="loopx-control-plane") + (app / "Contents/Info.plist").write_bytes(plistlib.dumps(info)) + module = "loopx.cli_commands.support_control_upgrade." + directory = tmp_path / "receipts" + directory.mkdir() + monkeypatch.setattr(module + "_applications", lambda: (app,)) + monkeypatch.setattr(module + "_receipt_directory", lambda: directory) + downloading, finish = threading.Event(), threading.Event() + restarted, outcomes = [], [] + + def prepare(argv, **_kwargs): + assert argv[0] == str(app / "Contents/MacOS/loopx-control-plane") + assert "--loopx-upgrade-prepare" in argv + downloading.set() + assert finish.wait(3) + if failure == "timeout": + raise subprocess.TimeoutExpired(argv, 1) + body = {"ok": True, "channel": "main", "target": "0.0.0-main.2.1"} + if failure == "signature": + body = {"ok": False, "code": "update_signature_invalid"} + if failure == "invalid": + body = [] + return subprocess.CompletedProcess(argv, int(failure == "signature"), json.dumps(body)) + + def restart(_app, request_id, channel, _timeout, *, target): + restarted.append(target) + (directory / f"{request_id}.json").write_text(json.dumps({ + "request_id": request_id, "channel": channel, "app_version": target, + "bundled_source_revision": "a" * 40, "phase": "ready", + "update_checked": True, "pid": os.getpid(), + "runtime_identity": {"source_revision": "b" * 40}})) + + def execute(): + try: + outcomes.append(_upgrade_desktop(desktop_upgrade_plan(None), 5)) + except subprocess.TimeoutExpired: + outcomes.append("timeout") + + monkeypatch.setattr(module + "subprocess.run", prepare) + monkeypatch.setattr(module + "_restart_app", restart) + worker = threading.Thread(target=execute) + worker.start() + try: + assert downloading.wait(3) + assert not restarted, "network preparation must not close the usable App" + finally: + finish.set() + worker.join(5) + assert not worker.is_alive() + if failure: + assert not restarted + if failure == "timeout": + assert outcomes[0] == "timeout" + else: + assert outcomes[0]["ok"] is False + else: + assert restarted == ["0.0.0-main.2.1"] + assert outcomes[0]["ok"] is True + assert not list(directory.iterdir()) + + +def test_protected_app_retains_native_authorization_path(monkeypatch, tmp_path): + app = app_fixture(tmp_path) + info = plistlib.loads((app / "Contents/Info.plist").read_bytes()) + info["LoopXUpgradePreparation"] = 1 + (app / "Contents/Info.plist").write_bytes(plistlib.dumps(info)) + monkeypatch.setattr("loopx.cli_commands.support_control_upgrade._applications", lambda: (app,)) + monkeypatch.setattr("loopx.cli_commands.support_control_upgrade.os.access", lambda *_: False) + assert desktop_upgrade_plan("main")["preparation_supported"] is False + + def test_upgrade_never_restarts_the_app_while_service_activation_is_deferred(): payload = {"ok": False, "changes_applied": True, "execution": {"install_returncode": 0, "doctor_returncode": 0, "activation_pending": True},