diff --git a/.github/workflows/desktop-updater.yml b/.github/workflows/desktop-updater.yml index dc00c07ce5..a6eaa65f18 100644 --- a/.github/workflows/desktop-updater.yml +++ b/.github/workflows/desktop-updater.yml @@ -38,6 +38,31 @@ jobs: - run: python3 -m unittest discover -s tests/desktop -p 'test_*.py' - run: cargo test --locked && cargo clippy --all-targets --locked -- -D warnings working-directory: apps/desktop/loopx-control-plane/src-tauri + windows-desktop-tests: + # The lifecycle and update regressions behind the signed Windows updater + # are Windows-native (service job objects, `ShellExecuteW`, real child + # processes) and cannot execute on the macOS `validate` leg. Compile the + # whole test target natively and run those regressions here. + runs-on: windows-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + # The signed runtime bundle the crate's build script embeds has to exist + # before the test target can compile, exactly as on the macOS leg. + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - uses: actions/setup-node@v6 + with: + node-version: "24" + - run: python scripts/desktop_runtime_bundle.py + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: apps/desktop/loopx-control-plane/src-tauri -> target + key: desktop-update-windows-tests + - run: cargo test --locked --lib -- service_lifecycle + working-directory: apps/desktop/loopx-control-plane/src-tauri identity: if: github.repository == 'loopx-project/loopx' && ((github.event_name == 'release' && !github.event.release.prerelease && startsWith(github.event.release.tag_name, 'v')) || (github.event_name != 'release' && github.ref == 'refs/heads/main')) runs-on: ubuntu-latest @@ -67,16 +92,10 @@ jobs: echo "version=0.0.0-main.${GITHUB_RUN_ID}.${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" echo "tag=desktop-main-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" fi - build: + build-macos: needs: identity if: needs.identity.outputs.channel == 'stable' || needs.identity.outputs.channel == 'main' - strategy: - fail-fast: false - matrix: - include: - - runner: macos-15 - bundle: app - runs-on: ${{ matrix.runner }} + runs-on: macos-15 timeout-minutes: 45 steps: - uses: actions/checkout@v7 @@ -94,33 +113,118 @@ jobs: workspaces: apps/desktop/loopx-control-plane/src-tauri -> target - run: npm ci working-directory: apps/desktop/loopx-control-plane - - name: Build signed App and exact bundled runtime + - name: Build signed macOS App and exact bundled runtime + if: runner.os == 'macOS' working-directory: apps/desktop/loopx-control-plane shell: bash env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "" VERSION: ${{ needs.identity.outputs.version }} - BUNDLE: ${{ matrix.bundle }} + BUNDLE: app run: | set -euo pipefail test -n "$TAURI_SIGNING_PRIVATE_KEY" npm run build -- --bundles "$BUNDLE" --config "{\"version\":\"$VERSION\",\"bundle\":{\"createUpdaterArtifacts\":true,\"macOS\":{\"signingIdentity\":\"-\"}}}" - name: Collect native updater artifacts shell: bash + env: + VERSION: ${{ needs.identity.outputs.version }} run: | mkdir -p dist/updater find apps/desktop/loopx-control-plane/src-tauri/target/release/bundle -type f \( -name '*.app.tar.gz' -o -name '*.app.tar.gz.sig' -o -name '*-setup.exe' -o -name '*-setup.exe.sig' \) -exec cp {} dist/updater/ \; for artifact in dist/updater/*-setup.exe*; do - if [ -f "$artifact" ]; then mv "$artifact" "${artifact/-setup.exe/-updater-setup.exe}"; fi + if [ -f "$artifact" ]; then + case "$artifact" in + *-updater-setup.exe*) continue ;; + esac + mv "$artifact" "${artifact/-setup.exe/-updater-setup.exe}" + fi done + if [ "$RUNNER_OS" = "Windows" ]; then + # The feed advertises this run's version. An installer whose name + # does not embed it would be published under a version it does not + # carry, and clients would never update again. + ls dist/updater/*"$VERSION"*-updater-setup.exe >/dev/null + fi - uses: actions/upload-artifact@v7 with: - name: signed-updater-${{ matrix.runner }} + name: signed-updater-macos-15 + path: dist/updater/* + if-no-files-found: error + build-windows: + needs: identity + # Windows NSIS ships on the main channel only. Keeping this leg out of the + # stable dependency graph means a Windows failure cannot withhold the + # Darwin-only stable feed; main still requires it. + if: needs.identity.outputs.channel == 'main' + runs-on: windows-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.identity.outputs.ref }} + - uses: actions/setup-node@v6 + with: + node-version: "24" + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: apps/desktop/loopx-control-plane/src-tauri -> target + - run: npm ci + working-directory: apps/desktop/loopx-control-plane + - name: Build signed Windows NSIS updater installer + if: runner.os == 'Windows' + working-directory: apps/desktop/loopx-control-plane + shell: bash + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "" + VERSION: ${{ needs.identity.outputs.version }} + BUNDLE: nsis + run: | + set -euo pipefail + test -n "$TAURI_SIGNING_PRIVATE_KEY" + npm run build -- --bundles "$BUNDLE" --config "{\"version\":\"$VERSION\",\"bundle\":{\"createUpdaterArtifacts\":true}}" + - name: Collect native updater artifacts + shell: bash + env: + VERSION: ${{ needs.identity.outputs.version }} + run: | + mkdir -p dist/updater + find apps/desktop/loopx-control-plane/src-tauri/target/release/bundle -type f \( -name '*.app.tar.gz' -o -name '*.app.tar.gz.sig' -o -name '*-setup.exe' -o -name '*-setup.exe.sig' \) -exec cp {} dist/updater/ \; + for artifact in dist/updater/*-setup.exe*; do + if [ -f "$artifact" ]; then + case "$artifact" in + *-updater-setup.exe*) continue ;; + esac + mv "$artifact" "${artifact/-setup.exe/-updater-setup.exe}" + fi + done + if [ "$RUNNER_OS" = "Windows" ]; then + # The feed advertises this run's version. An installer whose name + # does not embed it would be published under a version it does not + # carry, and clients would never update again. + ls dist/updater/*"$VERSION"*-updater-setup.exe >/dev/null + fi + - uses: actions/upload-artifact@v7 + with: + name: signed-updater-windows-latest path: dist/updater/* if-no-files-found: error publish: - needs: [identity, build, validate] + needs: [identity, build-macos, build-windows, validate] + # A skipped Windows build is valid only on stable, where the feed is + # Darwin-only. A main release must have its Windows artifact. + if: >- + always() + && needs.identity.result == 'success' + && needs.build-macos.result == 'success' + && needs.validate.result == 'success' + && (needs.identity.outputs.channel != 'main' || needs.build-windows.result == 'success') runs-on: ubuntu-latest permissions: contents: write @@ -137,7 +241,8 @@ jobs: env: VERSION: ${{ needs.identity.outputs.version }} RELEASE_TAG: ${{ needs.identity.outputs.tag }} - run: python scripts/desktop_update_feed.py --directory dist/updater --version "$VERSION" --tag "$RELEASE_TAG" + CHANNEL: ${{ needs.identity.outputs.channel }} + run: python scripts/desktop_update_feed.py --directory dist/updater --version "$VERSION" --tag "$RELEASE_TAG" --channel "$CHANNEL" - name: Upload immutable artifacts, publish channel pointer last env: GH_TOKEN: ${{ github.token }} @@ -147,10 +252,14 @@ jobs: CHANNEL: ${{ needs.identity.outputs.channel }} run: | set -euo pipefail + # Stable builds intentionally have no Windows artifact. Without + # nullglob, Bash passes the unmatched pattern to `gh`, which exits + # before the feed and channel pointer are published. + shopt -s nullglob if [ "$EVENT_NAME" != release ]; then gh release create "$RELEASE_TAG" --prerelease --target "$SOURCE_REF" --title "Desktop main $SOURCE_REF" --notes "Signed App and matching runtime preview." fi - for artifact in dist/updater/*.gz dist/updater/*.sig; do + for artifact in dist/updater/*.gz dist/updater/*.sig dist/updater/*.exe; do gh release upload "$RELEASE_TAG" "$artifact" done gh release upload "$RELEASE_TAG" dist/updater/desktop-updater.json --clobber diff --git a/README.md b/README.md index 254ea66263..0618e525a4 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,10 @@ native desktop previews from the [current release](https://github.com/loopx-proj they reuse the same loopback services and Goal state. Apple Silicon macOS supports signed App updates that pair the shell with its bundled runtime, plus repair and recovery. Python 3.11+ is required; the App is ad-hoc signed, not notarized. -Windows preview installers currently use manual updates and a separately installed CLI. +Windows NSIS preview installers support the same signed update flow on the main +channel, with a separately installed CLI; the stable channel and MSI installs stay +manual, App rollback and repair are unavailable, and the installer carries no +Authenticode certificate (Windows may show a SmartScreen prompt). [Desktop installation, updates, and source development](apps/desktop/loopx-control-plane/README.md).
diff --git a/apps/desktop/loopx-control-plane/README.md b/apps/desktop/loopx-control-plane/README.md index 0d1298f955..fe51465465 100644 --- a/apps/desktop/loopx-control-plane/README.md +++ b/apps/desktop/loopx-control-plane/README.md @@ -17,6 +17,12 @@ checks its current official channel and downloads a newer App in the background. It verifies the updater signature and prepares the archive locally. The next normal open verifies that archive again and installs it before starting services; ordinary background preparation never replaces or restarts an active workspace. +Windows NSIS installs follow the signed-update journey on the main channel: the +App checks on launch, verifies the minisign signature, runs the installer +passively and restarts automatically. On Windows the stable channel, MSI +installs and App rollback/repair remain manual or unavailable for now, and the +installer carries no Authenticode certificate, so Windows may show a SmartScreen +prompt. 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. diff --git a/apps/desktop/loopx-control-plane/src-tauri/Cargo.lock b/apps/desktop/loopx-control-plane/src-tauri/Cargo.lock index ea749da794..3d73b08562 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/Cargo.lock +++ b/apps/desktop/loopx-control-plane/src-tauri/Cargo.lock @@ -264,6 +264,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -803,6 +812,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -2163,6 +2173,7 @@ name = "loopx-control-plane" version = "0.1.0" dependencies = [ "base64 0.22.1", + "blake2", "command-group", "flate2", "libc", @@ -2170,6 +2181,7 @@ dependencies = [ "objc2", "open", "pep440_rs", + "ring", "rustix", "semver", "serde_json", @@ -2181,6 +2193,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "tempfile", + "zip", ] [[package]] diff --git a/apps/desktop/loopx-control-plane/src-tauri/Cargo.toml b/apps/desktop/loopx-control-plane/src-tauri/Cargo.toml index 94c3250412..7e2de5df47 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/Cargo.toml +++ b/apps/desktop/loopx-control-plane/src-tauri/Cargo.toml @@ -43,3 +43,13 @@ rustix = { version = "1.1.4", features = ["fs"] } [target.'cfg(target_os = "macos")'.dev-dependencies] objc2 = "0.6.4" libc = "0.2" + +# The Windows update regression builds a throwaway minisign key from `ring`, +# a provider-shaped prehashed signature with `blake2`, the NSIS archive with +# `zip`, and drives the pinned updater against a real window through the mock +# runtime. `base64` stays shared because the macOS signature checks use it. +[target.'cfg(target_os = "windows")'.dev-dependencies] +blake2 = "0.10" +ring = "0.17" +tauri = { version = "2.11.1", features = ["test"] } +zip = { version = "4", default-features = false } diff --git a/apps/desktop/loopx-control-plane/src-tauri/build.rs b/apps/desktop/loopx-control-plane/src-tauri/build.rs index 8ede99c294..bbc071df9a 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/build.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/build.rs @@ -2,5 +2,27 @@ fn main() { tauri_build::try_build(tauri_build::Attributes::new().app_manifest( tauri_build::AppManifest::new().commands(&["desktop_update", "desktop_update_status"]), )) - .expect("desktop build") + .expect("desktop build"); + + link_windows_resource_into_tests(); +} + +/// `tauri_build` hands its Windows resource file to `[[bin]]` targets only +/// (`cargo:rustc-link-arg-bins`), so the libtest binary of this crate links +/// without a manifest. Its `comctl32` imports are Common Controls v6 entry +/// points, which the loader then resolves against the System32 5.82 copy, and +/// the test process dies with `STATUS_ENTRYPOINT_NOT_FOUND` before `main`. +/// Attach the same resource to every linked target so `cargo test` can run on +/// Windows. +fn link_windows_resource_into_tests() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + let Ok(out_dir) = std::env::var("OUT_DIR") else { + return; + }; + let resource = std::path::Path::new(&out_dir).join("resource.lib"); + if resource.exists() { + println!("cargo:rustc-link-search=native={out_dir}"); + } } 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 fd882f6689..7e36646c73 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs @@ -6,17 +6,15 @@ mod prepared_update; mod runtime_ancestry; mod runtime_selection; mod service_endpoints; +mod service_lifecycle; mod services; mod update_backup; mod upgrade_request; mod workspace_handoff; mod workspace_location; -use services::ServiceSet; -use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, Mutex, -}; +use service_lifecycle::ServiceLifecycle; +use std::sync::{Arc, Mutex}; use tauri::{ ipc::CapabilityBuilder, webview::PageLoadEvent, AppHandle, Manager, RunEvent, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent, @@ -97,10 +95,19 @@ fn open_web_destination( fn workspace_navigation( url: &Url, - origin: &Url, + workspace_origin: &Url, + boot_origin: &Url, launch: impl FnOnce(&str) -> std::io::Result<()>, ) -> bool { - if url.scheme() == "tauri" || url.origin() == origin.origin() { + // macOS serves the App's bundled boot surface over `tauri://localhost`, and + // the scheme check below covers it. Windows serves the same bundled page + // over the WebView2 transport alias `http://tauri.localhost`, so that boot + // origin stays inside the fence too. Opaque origins never compare equal, + // which keeps `file://` and other custom protocols outside the fence. + if url.scheme() == "tauri" + || url.origin() == workspace_origin.origin() + || url.origin() == boot_origin.origin() + { return true; } // WKWebView evaluates navigation policy before its new-window delegate. @@ -119,14 +126,13 @@ pub fn run() { let web_origin = "http://127.0.0.1:5173".to_string(); #[cfg(not(dev))] let web_origin = endpoints.workspace_origin(); - let services = Arc::new(Mutex::new(None::)); - let services_for_setup = Arc::clone(&services); let navigation_origin: Url = web_origin.parse().expect("valid desktop origin"); - let shutting_down = Arc::new(AtomicBool::new(false)); - let shutting_down_for_setup = Arc::clone(&shutting_down); + let lifecycle = ServiceLifecycle::new(); + let lifecycle_for_setup = lifecycle.clone(); let builder = tauri::Builder::default() .manage(maintenance::Maintenance::default()) + .manage(lifecycle.clone()) .plugin(tauri_plugin_updater::Builder::new().build()) .invoke_handler(tauri::generate_handler![ maintenance::desktop_update, @@ -162,8 +168,9 @@ pub fn run() { } else { NavigationRetry::SingleAttempt }; + let boot_origin = boot_url(app.handle()); let handoff = Arc::new(Mutex::new(WorkspaceHandoff::new( - boot_url(app.handle()), + boot_origin.clone(), workspace_location::launch_destination(app.handle(), &origin), retry, ))); @@ -185,7 +192,7 @@ pub fn run() { .page_reached(payload.url()); }) .on_navigation(move |url| { - workspace_navigation(url, &navigation_origin, |destination| { + workspace_navigation(url, &navigation_origin, &boot_origin, |destination| { open::that_detached(destination) }) }) @@ -196,12 +203,20 @@ pub fn run() { let handle = app.handle().clone(); std::thread::spawn(move || { + let lifecycle = lifecycle_for_setup; // Runtime preparation belongs to the same live supervisor as // service startup. A failed install must not strand this window // after a later repair, reload, or external runtime correction. - while !shutting_down_for_setup.load(Ordering::Acquire) { + while !lifecycle.should_stop() { + // The App-update handoff owns the runtime until it either + // replaces this process or returns; while it is active the + // supervisor neither starts nor recovers services. + if lifecycle.handoff_active() { + std::thread::sleep(std::time::Duration::from_millis(200)); + continue; + } { - let mut current = services_for_setup.lock().expect("service state lock"); + let mut current = lifecycle.services(); if current.is_some() { if maintenance::reconnect_requested(&handle) { // Runtime repair reuses this supervisor even @@ -222,18 +237,39 @@ pub fn run() { } } } - match maintenance::start_services(&handle, &endpoints) { + let restoring = lifecycle.restore_pending(); + let started = if restoring { + // A released handoff (the installer never launched) + // already stopped this window's own services. Bring the + // same runtime back without re-announcing phases: the + // failure snapshot on screen is the state the operator + // is retrying from, and it must not be overwritten. + maintenance::restore_services_after_update_failure(&handle, &endpoints) + } else { + maintenance::start_services(&handle, &endpoints) + }; + match started { Ok(None) => { std::thread::sleep(std::time::Duration::from_millis(200)); continue; } - Ok(Some(mut started)) => { - if shutting_down_for_setup.load(Ordering::Acquire) { - started.stop(); - return; - } + Ok(Some(started)) => { let healed = started.healed; - *services_for_setup.lock().expect("service state lock") = Some(started); + if let Err(mut returned) = lifecycle.adopt(started) { + // The window closed or a new handoff claimed + // the runtime while this start was in flight: + // stop the group this thread started instead + // of stranding it, and keep owing whatever the + // owner recorded. + returned.stop(); + continue; + } + if restoring { + // The failure snapshot owns this transition; + // the workspace handoff already settled and + // must not replay an old selection. + continue; + } handoff .lock() .expect("workspace handoff lock") @@ -250,6 +286,22 @@ pub fn run() { } } Err(error) => { + if restoring { + // The failure diagnostics stay on screen; + // retry the reclaimed runtime at the + // supervisor's pace without re-announcing a + // phase. + eprintln!( + "LoopX service restore after failed App update deferred: {error}" + ); + for _ in 0..10 { + if lifecycle.should_stop() || lifecycle.handoff_active() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(200)); + } + continue; + } let message = boot_failure_message(&error); eprintln!("LoopX service error: {error}"); if let Some(window) = handle.get_webview_window("main") { @@ -273,7 +325,7 @@ pub fn run() { 10 }; for _ in 0..rounds { - if shutting_down_for_setup.load(Ordering::Acquire) { + if lifecycle.should_stop() { return; } if maintenance::reconnect_requested(&handle) { @@ -299,14 +351,9 @@ pub fn run() { .expect("failed to build LoopX desktop shell"); app.run(move |app, event| { if matches!(event, RunEvent::Exit | RunEvent::ExitRequested { .. }) { - workspace_location::remember_current(app); - shutting_down.store(true, Ordering::Release); - if let Ok(mut guard) = services.lock() { - if let Some(current) = guard.as_mut() { - current.stop(); - } - *guard = None; - } + // Save the route and reclaim this App's own services through the + // same owner the App-update handoff uses. + lifecycle.shutdown(app); } }); } @@ -355,26 +402,58 @@ mod tests { #[test] fn navigation_keeps_the_workspace_fence_and_hands_external_web_links_off_once() { - let origin: Url = "http://127.0.0.1:8767/chat/".parse().unwrap(); - for destination in [ - "tauri://localhost/", - "http://127.0.0.1:8767/chat/?goalId=example", + let workspace: Url = "http://127.0.0.1:8767/chat/".parse().unwrap(); + // macOS reaches the bundled boot surface over the custom protocol; + // Windows serves the same page over the WebView2 transport alias. + for (boot, boot_page) in [ + ("tauri://localhost/", "tauri://localhost/index.html"), + ( + "http://tauri.localhost/", + "http://tauri.localhost/index.html", + ), ] { - assert!(workspace_navigation( - &destination.parse().unwrap(), - &origin, - |_| { panic!("workspace navigation must not leave the App") } - )); + let boot: Url = boot.parse().unwrap(); + for destination in [boot_page, "http://127.0.0.1:8767/chat/?goalId=example"] { + assert!(workspace_navigation( + &destination.parse().unwrap(), + &workspace, + &boot, + |_| { panic!("workspace navigation must not leave the App") } + )); + } } + let boot: Url = "http://tauri.localhost/".parse().unwrap(); let external: Url = "https://example.org/form?lang=en&view=all".parse().unwrap(); let mut opened = Vec::new(); - let allowed = workspace_navigation(&external, &origin, |value| { + let allowed = workspace_navigation(&external, &workspace, &boot, |value| { opened.push(value.to_string()); Ok(()) }); // macOS never reaches the new-window delegate after this rejection. assert!(!allowed); assert_eq!(opened, [external.as_str()]); + // The transport alias is accepted only as an exact origin: a lookalike + // host, port, or scheme stays an external web destination and is + // handed off once more. + for destination in [ + "http://tauri.localhost.evil.example/index.html", + "http://elsewhere.localhost/index.html", + "http://tauri.localhost:8080/index.html", + "https://tauri.localhost/index.html", + ] { + let destination: Url = destination.parse().unwrap(); + let mut opened = Vec::new(); + assert!(!workspace_navigation( + &destination, + &workspace, + &boot, + |value| { + opened.push(value.to_string()); + Ok(()) + } + )); + assert_eq!(opened, [destination.as_str()]); + } for destination in [ "file:///tmp/example.txt", "javascript:alert(1)", @@ -382,11 +461,12 @@ mod tests { ] { assert!(!workspace_navigation( &destination.parse().unwrap(), - &origin, + &workspace, + &boot, |_| { panic!("non-web navigation must not reach a system handler") } )); } - assert!(!workspace_navigation(&external, &origin, |_| { + assert!(!workspace_navigation(&external, &workspace, &boot, |_| { Err(std::io::Error::other("browser unavailable")) })); } @@ -438,6 +518,12 @@ mod tests { // snapshot itself and name the known fresh-Mac installer failure. assert!(script.contains("runtime_install_exit_2")); assert!(script.contains("desktop_recovery_diagnostics_v2")); + // The Windows release ships no bundled-runtime repair and no rollback, + // so fallback copy for states without a dedicated code must switch to + // wording that avoids those controls when repair is unavailable. + assert!( + script.contains("!bundledRepairAvailable && Object.hasOwn(unrepairedFallback, phase)") + ); } #[test] 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 d7fbbcd6a6..238eb49283 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs @@ -366,6 +366,28 @@ impl Maintenance { } Some(recover()) } + + // The App-update handoff reclaimed this window's services before the + // pinned Windows installer could be launched, but the launch failed and + // this process survived. The failure snapshot the operator is retrying + // from is the state this restore has to preserve, so the transaction + // fences that guard ordinary reconciliation do not apply; an admitted + // explicit action still owns the next attempt and defers this one. + fn restore_services( + &self, + start: impl FnOnce() -> Result, + ) -> Result, String> { + if self.busy.load(Ordering::Acquire) { + return Ok(None); + } + let Ok(_guard) = self.supervision.try_lock() else { + return Ok(None); + }; + if self.busy.load(Ordering::Acquire) { + return Ok(None); + } + Ok(Some(start()?)) + } } struct BusyGuard<'a>(&'a AtomicBool); impl Drop for BusyGuard<'_> { @@ -485,26 +507,59 @@ pub fn desktop_update_status(app: AppHandle, state: State<'_, Maintenance>) -> V let runtime_selection = json!({ "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() + // The App-owned bundled runtime repair is declared unavailable on + // Windows in this release by product decision, not because the + // platform lacks the mechanism; the Windows desktop keeps using the + // separately installed CLI. + "bundled_repair_available": cfg!(target_os = "macos") + && selection.is_some() && bundled_runtime::repair_resources_available(&app) && !state.separately_managed_runtime.load(Ordering::Acquire), }); let rollback_available = crate::update_backup::available(&app); let app_version = app.package_info().version.to_string(); + // The panel keeps its channel copy truthful from this projection: Windows + // NSIS updates ship on the main preview channel, while the stable channel + // and MSI installs keep the manual installer path. + let platform = std::env::consts::OS; let startup = state.startup_timing(); // Read mutable transaction state after the potentially blocking // diagnostics so this response cannot revive a superseded phase. let snapshot = state.status_snapshot(); let last_failure = state.last_failure.lock().unwrap().clone(); - json!({"state": snapshot, "startup": startup, "last_failure": last_failure, "app_version": app_version, "runtime": runtime, "runtime_selection": runtime_selection, "rollback_available": rollback_available, "environment": environment}) + json!({"state": snapshot, "startup": startup, "last_failure": last_failure, "app_version": app_version, "platform": platform, "runtime": runtime, "runtime_selection": runtime_selection, "rollback_available": rollback_available, "environment": environment}) } +// The signed App-update journey ships on macOS and Windows; other platforms +// keep the explicit unsupported error. +fn app_update_platform_supported() -> bool { + cfg!(target_os = "macos") || cfg!(target_os = "windows") +} + +// Action availability on a supported platform: Windows ships the signed +// App-update journey (check/install/restart and its recovery), while the +// macOS bundled-runtime repair and verified-backup rollback stay macOS-only. +// Windows rollback and repair report an explicit unavailable state instead +// of a half-built install path. The rule is a pure platform/action table so +// every column stays testable from any host OS. +fn app_update_action_supported_on(platform: &str, action: &str) -> bool { + platform != "windows" + || matches!( + action, + "check" | "apply" | "forget_runtime_selection" | "restart" + ) +} + +fn app_update_action_supported(action: &str) -> bool { + app_update_action_supported_on(std::env::consts::OS, action) +} + #[tauri::command] pub async fn desktop_update( app: AppHandle, action: String, channel: String, ) -> Result { - if cfg!(dev) || !cfg!(target_os = "macos") { + if cfg!(dev) || !app_update_platform_supported() { return Err("platform_update_not_supported".into()); } let url = endpoint(&channel)?; @@ -520,6 +575,9 @@ pub async fn desktop_update( ) { return Err("invalid_update_action".into()); } + if !app_update_action_supported(&action) { + return Err("update_action_platform_unavailable".into()); + } let state = app.state::(); let _guard = state.acquire()?; state.cancel_background_update(); @@ -737,6 +795,12 @@ async fn find_update( let configure_archive = archive_client.clone(); let update = app .updater_builder() + // The pinned Windows installer launches the replacement right after + // the pre-exit hook and exits the process when that launch succeeds, + // so this is the only chance to reclaim this App's own services; its + // owner documents why Tauri's `cleanup_before_exit` deliberately + // stays out of the hook. + .on_before_exit(crate::service_lifecycle::app_update_pre_exit_hook(app)) .version_comparator(move |current, remote| { crate::prepared_update::is_newer( ¤t.to_string(), @@ -787,16 +851,26 @@ async fn install_verified_app( .map_err(|_| "app_install_failed".to_string()) .and_then(|result| result); if installed.is_err() { - // The pinned macOS installer renames the old App away before moving - // the new one in, so a failed install does not by itself prove the - // previously installed App is still in place. Only a verified - // 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. + // A failed install does not by itself prove the previously installed + // App is still in place: the App-owned macOS installer swaps bundles + // atomically, but its administrator-owned fallback follows the plugin + // installer, which renames the old App away before moving the new one + // in. Only a verified 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. + // Windows verifies the running executable instead -- see + // failed_install_left_previous_app_usable. + // The pinned plugin's Windows hook already reclaimed this window's own + // services before it tried to launch the installer; an error here + // means the launch failed and this process continues, so the handoff + // is released. A reclaimed runtime is then owed back to the live + // window by the supervisor, and the failure below stays on screen. + crate::service_lifecycle::release_app_update_handoff(app); return Err(finalize_install_failure( &state, failed_install_left_previous_app_usable(app), + platform_uses_install_journal(), || bundled_runtime::discard_journal(app), ) .into()); @@ -809,6 +883,7 @@ async fn install_verified_app( *state.pending.lock().unwrap() = None; Ok(state.complete_app_replacement(json!({"version":target}))) } +#[cfg(not(target_os = "windows"))] // A safe-restart promise after a failed app replacement requires the running // App's bundle to still be present -- the pinned macOS updater's install_inner // performs rename(old App -> temporary) before rename(new App -> original), so @@ -839,11 +914,22 @@ fn failed_install_left_previous_app_usable(app: &AppHandle) -> bool { ) } +#[cfg(target_os = "windows")] +// The Windows installer only runs after the plugin's ShellExecuteW launch +// succeeds, and the plugin exits the process at that point; an install error +// therefore means the installer never launched and the previously installed +// App was never touched. The running executable still being present is the +// layout-level guard for the safe-restart promise. +fn failed_install_left_previous_app_usable(_app: &AppHandle) -> bool { + std::env::current_exe().is_ok_and(|executable| executable.is_file()) +} + // 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 have a qualified identity. Unverified keeps the // journal and the `app_install_incomplete` recovery state. +#[cfg(not(target_os = "windows"))] fn previous_installation_is_usable( executable: &std::path::Path, bundled: Option<&Value>, @@ -873,10 +959,11 @@ fn install_failure_recovery(previous_app_usable: bool) -> (&'static str, bool) { // recovery state applies instead. fn install_failure_state( previous_app_usable: bool, + journal_applies: bool, journal_discarded: Result, ) -> &'static str { let (code, may_discard_journal) = install_failure_recovery(previous_app_usable); - if !may_discard_journal { + if !may_discard_journal || !journal_applies { return code; } match journal_discarded { @@ -885,12 +972,22 @@ fn install_failure_state( } } +// The continuation journal exists only where a staged App replacement can +// leave one behind (macOS). The Windows installer never starts unless the +// pinned plugin's launch succeeded, so a journal filesystem error there +// cannot describe the installed App and must not reclassify a verified safe +// restart. +fn platform_uses_install_journal() -> bool { + cfg!(target_os = "macos") +} + // Own the complete install-failure decision at one testable boundary. The // public diagnostic reports whether this call removed a file; safe restart is // a separate invariant and also accepts an already-absent journal. fn finalize_install_failure( state: &Maintenance, previous_app_usable: bool, + journal_applies: bool, discard_journal: impl FnOnce() -> Result, ) -> &'static str { let (code, may_discard_journal) = install_failure_recovery(previous_app_usable); @@ -900,12 +997,18 @@ fn finalize_install_failure( .store(true, Ordering::Release); return code; } - let journal_result = discard_journal(); + // A platform without the staged-replacement journal must not attempt the + // effect at all; the classification below already ignores it. + let journal_result = if journal_applies { + discard_journal() + } else { + Ok(false) + }; let journal_removed = journal_result.as_ref().is_ok_and(|removed| *removed); state .install_journal_discarded .store(journal_removed, Ordering::Release); - let code = install_failure_state(previous_app_usable, journal_result); + let code = install_failure_state(previous_app_usable, journal_applies, journal_result); state .incomplete_app_installation .store(code == "app_install_incomplete", Ordering::Release); @@ -1059,7 +1162,7 @@ fn resume_runtime(app: &AppHandle) -> Result(); if cfg!(dev) - || !cfg!(target_os = "macos") + || !app_update_platform_supported() || app.config().identifier != "io.loopx.control-plane" { return; @@ -1106,6 +1209,33 @@ fn automatic_app_update(app: &AppHandle) { eprintln!("LoopX prepared App update deferred: {error}"); } } + // Windows has no staged-replacement archive to consume: the pinned + // plugin installs only from a checked feed, so an ordinary launch + // performs the bounded check and applies the verified installer + // before any App-owned service exists. A failed check or launch + // leaves the installed App usable and keeps its diagnostics. + #[cfg(target_os = "windows")] + { + let result: Result, String> = + tauri::async_runtime::block_on(state.run_automatic_update( + channel, + async { + let url = endpoint(channel)?; + let checked = + perform(app, "check", channel, url.clone(), Duration::from_secs(3)) + .await?; + Ok((checked["phase"] == "available").then_some(url)) + }, + |url| async move { + perform(app, "apply", channel, url, Duration::from_secs(3)).await?; + Ok(()) + }, + || app.restart(), + )); + if let Err(error) = result { + eprintln!("LoopX automatic App update deferred: {error}"); + } + } return; } if state.automatic_update_checked.swap(true, Ordering::AcqRel) { @@ -1260,6 +1390,24 @@ pub fn start_services( result } +// Restore the runtime a released App-update handoff owes back. The pinned +// Windows installer returns an error only when its launch never happened, so +// the same runtime is started again in place: no phase is re-announced, and +// the failure snapshot keeps its place on screen. +pub fn restore_services_after_update_failure( + app: &AppHandle, + endpoints: &crate::service_endpoints::ServiceEndpoints, +) -> Result, String> { + let state = app.state::(); + state.restore_services(|| { + // Progress would replace the failure snapshot with "connecting"; this + // start exists only to hand the live window its runtime back. + let runtime = resume_runtime(app)?; + crate::services::ServiceSet::start(&runtime, endpoints, |_pending| {}) + .map_err(|error| error.to_string()) + }) +} + pub fn reconnect_requested(app: &AppHandle) -> bool { app.state::().snapshot.lock().unwrap()["phase"] == "connecting" } @@ -1648,13 +1796,14 @@ mod tests { state .prepared_update_checked .store(prepared, Ordering::Release); - let code = finalize_install_failure(&state, usable, || { - if discard_ok { - Ok(true) - } else { - Err("update_state_unavailable".into()) - } - }); + let code = + finalize_install_failure(&state, usable, platform_uses_install_journal(), || { + if discard_ok { + Ok(true) + } else { + Err("update_state_unavailable".into()) + } + }); let result = tauri::async_runtime::block_on(state.run_automatic_update( "stable", async { Err::, String>(code.into()) }, @@ -1663,11 +1812,11 @@ mod tests { )); assert_eq!(result.unwrap_err(), code); assert_eq!(state.last_failure.lock().unwrap()["details"]["code"], code); - if usable && discard_ok && prepared { + if usable && prepared && (discard_ok || !platform_uses_install_journal()) { assert_eq!(state.status_snapshot()["phase"], "connecting"); assert_eq!( state.last_failure.lock().unwrap()["details"]["journal_discarded"], - true + platform_uses_install_journal() ); assert_eq!(state.reconcile_services(|| Ok(())).unwrap(), Some(())); } else { @@ -1933,6 +2082,29 @@ mod tests { } assert!(allow_action("restart_required", "restart").is_ok()); } + + #[test] + fn windows_keeps_the_signed_app_update_journey_without_macos_recovery_actions() { + for platform in ["macos", "windows"] { + for action in ["check", "apply", "forget_runtime_selection", "restart"] { + assert!( + app_update_action_supported_on(platform, action), + "{platform} {action}" + ); + } + } + for action in ["repair", "align_runtime", "rollback"] { + assert!( + app_update_action_supported_on("macos", action), + "macos keeps {action}" + ); + assert!( + !app_update_action_supported_on("windows", action), + "windows reports {action} as explicitly unavailable" + ); + } + assert!(app_update_action_supported("check")); + } #[test] fn endpoints_are_closed_and_https() { assert_eq!(endpoint("main").unwrap().scheme(), "https"); @@ -2141,7 +2313,9 @@ mod tests { #[test] fn completed_restore_retires_incomplete_installation_and_exposes_restart() { let state = Maintenance::default(); - finalize_install_failure(&state, false, || panic!("unverified journal retained")); + finalize_install_failure(&state, false, platform_uses_install_journal(), || { + panic!("unverified journal retained") + }); assert_eq!( state.status_snapshot()["details"]["code"], "app_install_incomplete" @@ -2233,6 +2407,47 @@ mod tests { ); } + #[test] + fn a_released_handoff_restores_the_runtime_without_overwriting_the_failure() { + // The pinned Windows installer only exits this process after a + // successful launch, so an install error returns in-process with the + // handoff already released. The reclaimed runtime comes back while the + // failure the operator is acting on stays the visible state. + let state = Maintenance::default(); + state.publish_failure("app_install_failed", "main"); + let started = std::cell::Cell::new(false); + let restored = state.restore_services(|| { + started.set(true); + Ok("owned services") + }); + assert_eq!(restored.unwrap(), Some("owned services")); + assert!(started.get()); + assert_eq!( + state.status_snapshot()["phase"], + "error", + "the restore must not replace the failure snapshot" + ); + assert_eq!( + state.status_snapshot()["details"]["code"], + "app_install_failed" + ); + } + + #[test] + fn a_restore_yields_to_an_admitted_explicit_action() { + let state = Maintenance::default(); + let guard = state.acquire().unwrap(); + let started = std::cell::Cell::new(false); + let deferred = state.restore_services(|| { + started.set(true); + Ok(()) + }); + assert_eq!(deferred.unwrap(), None); + assert!(!started.get(), "a running transaction owns the runtime"); + drop(guard); + assert_eq!(state.restore_services(|| Ok(())).unwrap(), Some(())); + } + #[test] fn partially_removed_app_keeps_the_journal_as_incomplete() { // Review round 3 counterexample, through the same boundary the @@ -2331,7 +2546,41 @@ mod tests { #[cfg(test)] mod install_failure_state_tests { - use super::{finalize_install_failure, Maintenance}; + use super::{ + finalize_install_failure, install_failure_state, platform_uses_install_journal, Maintenance, + }; + + #[test] + fn a_journal_error_only_reclassifies_where_the_journal_exists() { + assert_eq!( + install_failure_state(true, true, Err("journal_unavailable".into())), + "app_install_incomplete" + ); + assert_eq!( + install_failure_state(true, false, Err("journal_unavailable".into())), + "app_install_failed" + ); + assert_eq!( + install_failure_state(true, true, Ok(true)), + "app_install_failed" + ); + assert_eq!( + install_failure_state(false, true, Ok(true)), + "app_install_incomplete" + ); + } + + #[test] + fn a_journal_that_does_not_apply_is_neither_touched_nor_reclassified() { + let state = Maintenance::default(); + let code = finalize_install_failure(&state, true, false, || { + panic!("a platform without the staged-replacement journal must not touch it") + }); + assert_eq!(code, "app_install_failed"); + assert!(!state + .incomplete_app_installation + .load(std::sync::atomic::Ordering::Acquire)); + } #[test] fn existing_journal_is_removed_before_safe_restart_is_reported() { @@ -2340,7 +2589,7 @@ mod install_failure_state_tests { let journal = dir.path().join("desktop-update.json"); std::fs::write(&journal, "{\"version\":\"1.2.3\"}").unwrap(); - let code = finalize_install_failure(&state, true, || { + let code = finalize_install_failure(&state, true, true, || { crate::bundled_runtime::discard_journal_at(&journal) }); @@ -2356,7 +2605,7 @@ mod install_failure_state_tests { let dir = tempfile::tempdir().unwrap(); let journal = dir.path().join("desktop-update.json"); - let code = finalize_install_failure(&state, true, || { + let code = finalize_install_failure(&state, true, true, || { crate::bundled_runtime::discard_journal_at(&journal) }); @@ -2373,7 +2622,7 @@ mod install_failure_state_tests { let journal = dir.path().join("desktop-update.json"); std::fs::create_dir(&journal).unwrap(); - let code = finalize_install_failure(&state, true, || { + let code = finalize_install_failure(&state, true, true, || { crate::bundled_runtime::discard_journal_at(&journal) }); @@ -2399,7 +2648,7 @@ mod install_failure_state_tests { #[test] fn unusable_app_keeps_recovery_state_without_touching_the_journal() { let state = Maintenance::default(); - let code = finalize_install_failure(&state, false, || { + let code = finalize_install_failure(&state, false, platform_uses_install_journal(), || { panic!("an unusable App must not discard recovery state") }); assert_eq!(code, "app_install_incomplete"); diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/service_lifecycle.rs b/apps/desktop/loopx-control-plane/src-tauri/src/service_lifecycle.rs new file mode 100644 index 0000000000..fc4e986c7e --- /dev/null +++ b/apps/desktop/loopx-control-plane/src-tauri/src/service_lifecycle.rs @@ -0,0 +1,344 @@ +//! One owner for this window's App-owned service runtime. The live supervisor +//! adopts, recovers and surrenders it; the window exit and the App-update +//! handoff reclaim it before the process can be replaced. No task execution, +//! and no second place that decides which service processes this App owns. + +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, MutexGuard, +}; + +use tauri::{AppHandle, Manager, Runtime}; + +use crate::services::ServiceSet; + +#[derive(Clone)] +pub(crate) struct ServiceLifecycle(Arc); + +struct Inner { + shutting_down: AtomicBool, + handoff: AtomicBool, + reclaimed: AtomicBool, + restore_pending: AtomicBool, + services: Mutex>, +} + +impl ServiceLifecycle { + pub(crate) fn new() -> Self { + Self(Arc::new(Inner { + shutting_down: AtomicBool::new(false), + handoff: AtomicBool::new(false), + reclaimed: AtomicBool::new(false), + restore_pending: AtomicBool::new(false), + services: Mutex::new(None), + })) + } + + /// The window is closing: the supervisor stops and never starts again. + pub(crate) fn should_stop(&self) -> bool { + self.0.shutting_down.load(Ordering::Acquire) + } + + /// An App-update handoff owns the runtime. The supervisor neither starts + /// nor recovers services while it is parked, and a start that was already + /// in flight surrenders the set it built instead of adopting it. + pub(crate) fn handoff_active(&self) -> bool { + self.0.handoff.load(Ordering::Acquire) + } + + /// A released handoff that reclaimed this window's services still owes + /// them back: the pinned Windows installer only exits the process after a + /// successful launch, so a failure returns here with the runtime already + /// stopped. Cleared when a live set is adopted again. + pub(crate) fn restore_pending(&self) -> bool { + self.0.restore_pending.load(Ordering::Acquire) + } + + /// The live services of this window. The lock covers ownership changes + /// only: callers never hold it across an eager service wait. + pub(crate) fn services(&self) -> MutexGuard<'_, Option> { + self.0.services.lock().expect("service state lock") + } + + /// Adopt a freshly started set. A start that raced the closing window or + /// the App-update handoff is handed back so its caller stops the processes + /// it started instead of leaving a group this App no longer owns. A live + /// set also settles the restore a released handoff still owed. + pub(crate) fn adopt(&self, services: ServiceSet) -> Result<(), ServiceSet> { + let mut current = self.services(); + if self.should_stop() || self.handoff_active() { + return Err(services); + } + self.0.reclaimed.store(false, Ordering::Release); + self.0.restore_pending.store(false, Ordering::Release); + *current = Some(services); + Ok(()) + } + + /// Reclaim this App's own services before the process can be replaced: + /// save the workspace route, park the supervisor, and stop the owned set. + /// A set stops through its own job objects, so its descendants leave with + /// it, while a service this App never started (a CLI-owned listener on + /// another port) is not in the set and keeps running. + pub(crate) fn begin_update_handoff(&self, app: &AppHandle) -> bool { + self.begin_update_handoff_with(|| crate::workspace_location::remember_current(app)) + } + + /// Release a handoff that returned in-process, which for the pinned + /// Windows installer means the installer never launched. Returns whether + /// this window's runtime was reclaimed, and records that the supervisor + /// owes the same runtime back while the failure stays on screen. + pub(crate) fn release_update_handoff(&self) -> bool { + self.0.handoff.store(false, Ordering::Release); + let reclaimed = self.0.reclaimed.swap(false, Ordering::AcqRel); + if reclaimed { + self.0.restore_pending.store(true, Ordering::Release); + } + reclaimed + } + + /// The window is exiting: save the route and reclaim the owned services + /// before the process ends. + pub(crate) fn shutdown(&self, app: &AppHandle) { + self.shutdown_with(|| crate::workspace_location::remember_current(app)); + } + + /// The route-save effect is injected so the ownership rule stays + /// exercisable without a live window, exactly as `begin_update_handoff` + /// runs it for the real one. + pub(crate) fn begin_update_handoff_with(&self, remember_route: impl FnOnce()) -> bool { + self.reclaim(true, remember_route) + } + + fn shutdown_with(&self, remember_route: impl FnOnce()) { + self.reclaim(false, remember_route); + } + + fn reclaim(&self, update_handoff: bool, remember_route: impl FnOnce()) -> bool { + // Park the supervisor before touching ownership: a start that is + // already in flight re-checks this park under the same lock and stops + // its own set instead of adopting it. + if update_handoff { + self.0.handoff.store(true, Ordering::Release); + } else { + self.0.shutting_down.store(true, Ordering::Release); + } + remember_route(); + let reclaimed = self.services().take(); + match reclaimed { + Some(mut services) => { + services.stop(); + if update_handoff { + self.0.reclaimed.store(true, Ordering::Release); + } + true + } + None => false, + } + } +} + +/// The pinned updater's pre-exit hook. On Windows it runs after the verified +/// archive was unpacked and immediately before the installer is launched, and +/// the process exits as soon as that launch succeeds -- so this is the only +/// place that can reclaim this App's services before an exit that never +/// reaches `RunEvent::Exit`. Tauri's `cleanup_before_exit` is deliberately not +/// part of the hook: it hides the window and clears resource tables that a +/// failed launch cannot restore. +pub(crate) fn begin_app_update_handoff(app: &AppHandle) -> bool { + app.state::().begin_update_handoff(app) +} + +/// The exact pre-exit hook the desktop window installs. Production and the +/// Windows update regression both build the hook here, so the hook the +/// regression exercises cannot drift from the one the window runs. +pub(crate) fn app_update_pre_exit_hook( + app: &AppHandle, +) -> impl Fn() + Send + Sync + 'static { + let app = app.clone(); + move || { + begin_app_update_handoff(&app); + } +} + +/// Release a hook that returned without a replacement. Nothing needs to run +/// on success: the process exits through the plugin right after the launch. +pub(crate) fn release_app_update_handoff(app: &AppHandle) -> bool { + app.state::().release_update_handoff() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::owned_set_for_test; + + #[test] + fn an_update_handoff_reclaims_the_runtime_and_owes_exactly_one_restore() { + let lifecycle = ServiceLifecycle::new(); + assert!(lifecycle.adopt(owned_set_for_test(Vec::new())).is_ok()); + let saved = std::cell::Cell::new(0u32); + + assert!(lifecycle.begin_update_handoff_with(|| saved.set(saved.get() + 1))); + assert_eq!(saved.get(), 1, "the handoff saves the route once"); + assert!(lifecycle.handoff_active()); + assert!(!lifecycle.should_stop()); + assert!(!lifecycle.restore_pending(), "no release happened yet"); + assert!(lifecycle.services().is_none()); + + // Until the handoff returns, no start may claim the runtime. + assert!(lifecycle.adopt(owned_set_for_test(Vec::new())).is_err()); + assert!(lifecycle.release_update_handoff()); + assert!(!lifecycle.handoff_active()); + assert!( + lifecycle.restore_pending(), + "a released handoff owes the reclaimed runtime back" + ); + + // The supervisor settles the debt by adopting a live set again, and a + // second release has nothing left to owe. + assert!(!lifecycle.release_update_handoff()); + assert!(lifecycle.adopt(owned_set_for_test(Vec::new())).is_ok()); + assert!(!lifecycle.restore_pending()); + assert!(!lifecycle.release_update_handoff(), "one restore only"); + } + + #[test] + fn a_handoff_without_a_live_runtime_owes_no_restore() { + let lifecycle = ServiceLifecycle::new(); + assert!(!lifecycle.begin_update_handoff_with(|| {})); + assert!(lifecycle.handoff_active()); + assert!(!lifecycle.release_update_handoff()); + assert!(!lifecycle.restore_pending()); + assert!(lifecycle.services().is_none()); + } + + #[test] + fn a_closing_window_stops_adopting_and_never_owes_a_restore() { + let lifecycle = ServiceLifecycle::new(); + assert!(lifecycle.adopt(owned_set_for_test(Vec::new())).is_ok()); + lifecycle.shutdown_with(|| {}); + assert!(lifecycle.should_stop()); + assert!(lifecycle.services().is_none()); + assert!(lifecycle.adopt(owned_set_for_test(Vec::new())).is_err()); + assert!(!lifecycle.release_update_handoff()); + assert!(!lifecycle.restore_pending()); + } + + #[test] + fn repeated_handoffs_keep_service_ownership_single_valued() { + let lifecycle = ServiceLifecycle::new(); + for _ in 0..3 { + assert!(lifecycle.adopt(owned_set_for_test(Vec::new())).is_ok()); + assert!(lifecycle.begin_update_handoff_with(|| {})); + assert!(lifecycle.release_update_handoff()); + assert!( + lifecycle.services().is_none(), + "the caller restores the runtime, the owner never starts one" + ); + assert!(lifecycle.restore_pending()); + } + } + + #[cfg(windows)] + pub(crate) mod windows_reclaim { + use super::*; + use command_group::CommandGroup; + use std::process::{Command, Stdio}; + use std::time::Duration; + + // A real child process that stays alive after its `cmd /c` parent + // exits, so the reclaim has to reach the job's descendants too. + pub(crate) fn hold_process_owned() -> command_group::GroupChild { + let mut command = Command::new("cmd"); + command + .args(["/c", "ping", "-n", "60", "127.0.0.1"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + command.group_spawn().expect("spawn owned service fixture") + } + + pub(crate) fn hold_process_independent() -> std::process::Child { + let mut command = Command::new("cmd"); + command + .args(["/c", "ping", "-n", "60", "127.0.0.1"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + command.spawn().expect("spawn independent fixture") + } + + pub(crate) fn process_is_alive(pid: u32) -> bool { + let output = Command::new("tasklist") + .args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"]) + .output() + .expect("tasklist runs"); + String::from_utf8_lossy(&output.stdout).contains(&pid.to_string()) + } + + pub(crate) fn wait_until(condition: impl Fn() -> bool, what: &str) { + for _ in 0..200 { + if condition() { + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + panic!("timed out waiting for {what}"); + } + + #[test] + fn an_update_handoff_reclaims_the_owned_group_and_its_descendants() { + let lifecycle = ServiceLifecycle::new(); + let owned = hold_process_owned(); + let owned_pid = owned.id(); + let mut independent = hold_process_independent(); + let independent_pid = independent.id(); + + assert!(lifecycle.adopt(owned_set_for_test(vec![owned])).is_ok()); + assert!(process_is_alive(owned_pid)); + assert!(lifecycle.begin_update_handoff_with(|| {})); + wait_until( + || !process_is_alive(owned_pid), + "the owned service group to exit", + ); + + // A process this App never started is not in the owned set: a + // CLI-owned listener on its own port keeps running. + assert!( + process_is_alive(independent_pid), + "an independent process must survive the handoff" + ); + + // The replacement never launched: the released handoff owes the + // runtime back, and the replacement group is the only new owner. + assert!(lifecycle.release_update_handoff()); + assert!(lifecycle.restore_pending()); + let replacement = hold_process_owned(); + let replacement_pid = replacement.id(); + assert!(lifecycle + .adopt(owned_set_for_test(vec![replacement])) + .is_ok()); + assert!(!lifecycle.restore_pending()); + + // A repeated update reclaims exactly the replacement group and + // still spares the independent process. + assert!(lifecycle.begin_update_handoff_with(|| {})); + wait_until( + || !process_is_alive(replacement_pid), + "the replacement service group to exit", + ); + assert!(process_is_alive(independent_pid)); + + let _ = independent.kill(); + let _ = independent.wait(); + } + } +} + +// The verified-artifact launch failure is a full-journey regression: it drives +// the pinned updater's own HTTP, signature verification and Windows launch +// against a real window plus the ownership rules above. It lives in its own +// file to keep those rules readable. +#[cfg(all(test, windows))] +#[path = "service_lifecycle_windows_update_tests.rs"] +mod windows_update_tests; diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/service_lifecycle_windows_update_tests.rs b/apps/desktop/loopx-control-plane/src-tauri/src/service_lifecycle_windows_update_tests.rs new file mode 100644 index 0000000000..e32222644e --- /dev/null +++ b/apps/desktop/loopx-control-plane/src-tauri/src/service_lifecycle_windows_update_tests.rs @@ -0,0 +1,479 @@ +//! Windows regression for the verified-but-unlaunchable App update. +//! +//! The pinned updater verifies a real minisign signature, unpacks the real +//! NSIS archive and only then reaches `ShellExecuteW`. When that launch fails +//! the process keeps running with the pre-exit hook already executed, so this +//! window must stay usable and the services the hook reclaimed must return to +//! the live supervisor. +//! +//! Nothing here substitutes for the plugin's own machinery: the feed is real +//! HTTP on loopback, the signature is checked by the plugin's own +//! `minisign-verify` call over the downloaded bytes, and the archive is +//! unpacked by the plugin's own ZIP handling. Only the signing key is +//! throwaway, and it is generated per run so no key material can reach the +//! repository. + +// `tauri_build` attaches the application resource to `[[bin]]` targets only, so +// this libtest binary would link without an application manifest. The +// `comctl32` entry points reachable from the mocked App are Common Controls v6 +// APIs; without the manifest the loader binds the System32 5.82 copy and the +// process dies with `STATUS_ENTRYPOINT_NOT_FOUND` before `main`. Linking the +// same resource here keeps the fix inside the test binary: the build script +// only publishes the search path, and `rustc-link-arg` was not an option +// because it would duplicate the resource for `[[bin]]` targets. +#[link(name = "resource", kind = "static")] +extern "C" {} + +use super::tests::windows_reclaim::{ + hold_process_independent, hold_process_owned, process_is_alive, wait_until, +}; +use super::{app_update_pre_exit_hook, release_app_update_handoff, ServiceLifecycle}; +use crate::services::owned_set_for_test; +use base64::{engine::general_purpose::STANDARD, Engine}; +use blake2::{Blake2b512, Digest}; +use ring::signature::{Ed25519KeyPair, KeyPair}; +use serde_json::json; +use std::collections::HashMap; +use std::io::{Cursor, Read, Write}; +use std::net::TcpListener; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use tauri::test::{mock_builder, mock_context, noop_assets, MockRuntime}; +use tauri::{AppHandle, Manager, Resource, WebviewUrl, WebviewWindowBuilder}; +use tauri_plugin_updater::{Update, Updater, UpdaterExt}; + +/// The announced main-channel version. The production comparator accepts it +/// for a window whose own version is not a `main.` prerelease. +const UPDATE_VERSION: &str = "0.0.2-main.1"; +/// A test binary is not a bundled App, so `tauri::utils::platform::bundle_type` +/// cannot report `nsis`; the real NSIS window resolves this key first. +const UPDATE_TARGET: &str = "windows-x86_64-nsis"; +const INSTALLER: &str = "loopx-control-plane_0.0.2-main.1_x64-setup.exe"; + +/// A resource whose survival proves `cleanup_before_exit` did not run: the +/// pinned default hook clears exactly this table. +struct WindowSentinel; +impl Resource for WindowSentinel {} + +/// A throwaway minisign-format Ed25519 key. The key id is derived from the +/// public key instead of being random so a run cannot reuse a shipped id. +struct TestSigner { + key_id: [u8; 8], + key: Ed25519KeyPair, +} + +impl TestSigner { + fn generate() -> Self { + let rng = ring::rand::SystemRandom::new(); + let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).expect("test signing key"); + let key = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).expect("test signing key"); + let digest = Blake2b512::digest(key.public_key().as_ref()); + let mut key_id = [0u8; 8]; + key_id.copy_from_slice(&digest[..8]); + Self { key_id, key } + } + + /// The plugin expects the base64 of a two-line `minisign.pub` file. + fn public_key(&self) -> String { + let mut key = Vec::with_capacity(42); + key.extend_from_slice(b"Ed"); + key.extend_from_slice(&self.key_id); + key.extend_from_slice(self.key.public_key().as_ref()); + STANDARD.encode(format!( + "untrusted comment: minisign public key\n{}\n", + STANDARD.encode(key) + )) + } + + /// The base64 signature the release feed announces, in the prehashed + /// ("ED") mode `tauri signer sign` produces. + fn sign(&self, data: &[u8]) -> String { + let prehash = Blake2b512::digest(data); + let signature = self.key.sign(&prehash[..]); + let mut blob = Vec::with_capacity(74); + blob.extend_from_slice(b"ED"); + blob.extend_from_slice(&self.key_id); + blob.extend_from_slice(signature.as_ref()); + let trusted_comment = format!("timestamp:0\tfile:{INSTALLER}\tprehashed"); + let mut global = Vec::with_capacity(64 + trusted_comment.len()); + global.extend_from_slice(signature.as_ref()); + global.extend_from_slice(trusted_comment.as_bytes()); + let global_signature = self.key.sign(&global); + STANDARD.encode(format!( + "untrusted comment: signature from minisign secret key\n{}\ntrusted comment: {}\n{}\n", + STANDARD.encode(&blob), + trusted_comment, + STANDARD.encode(global_signature.as_ref()), + )) + } +} + +/// The ZIP an NSIS App update ships: the plugin extracts its `-setup.exe` and +/// launches that file. The payload is deliberately not a portable executable, +/// so Windows refuses the launch instead of starting a replacement process -- +/// the failure this regression owns. +fn nsis_artifact() -> Vec { + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + writer + .start_file( + INSTALLER, + zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored), + ) + .expect("artifact entry"); + writer + .write_all(b"this payload is not a portable executable") + .expect("artifact payload"); + writer.finish().expect("artifact archive").into_inner() +} + +fn feed_body(artifact_url: &str, signature: &str) -> Vec { + let platforms = HashMap::from([( + UPDATE_TARGET.to_string(), + json!({"url": artifact_url, "signature": signature}), + )]); + json!({ + "version": UPDATE_VERSION, + "notes": "regression artifact", + "pub_date": null, + "platforms": platforms, + }) + .to_string() + .into_bytes() +} + +/// A loopback feed the pinned updater fetches over real HTTP: the signature +/// verification this regression crosses must run on bytes that travelled +/// through the download path, not on an in-memory substitute. +struct FeedServer { + origin: String, + stop: Arc, + thread: Option>, +} + +impl FeedServer { + fn start(routes: impl FnOnce(&str) -> Vec<(&'static str, &'static str, Vec)>) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind the loopback feed"); + let origin = format!( + "http://127.0.0.1:{}", + listener.local_addr().expect("feed address").port() + ); + listener + .set_nonblocking(true) + .expect("non-blocking feed listener"); + let routes: HashMap)> = routes(&origin) + .into_iter() + .map(|(path, content_type, body)| (path.to_string(), (content_type, body))) + .collect(); + let stop = Arc::new(AtomicBool::new(false)); + let thread = std::thread::spawn({ + let stop = Arc::clone(&stop); + move || { + while !stop.load(Ordering::Acquire) { + let Ok((mut stream, _)) = listener.accept() else { + std::thread::sleep(Duration::from_millis(10)); + continue; + }; + let _ = stream.set_read_timeout(Some(Duration::from_secs(5))); + let mut request = Vec::new(); + let mut buffer = [0u8; 512]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + match stream.read(&mut buffer) { + Ok(0) | Err(_) => break, + Ok(read) => request.extend_from_slice(&buffer[..read]), + } + } + let path = String::from_utf8_lossy(&request) + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .map(str::to_string); + let response = match path.as_deref().and_then(|path| routes.get(path)) { + Some((content_type, body)) => { + let mut response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .into_bytes(); + response.extend_from_slice(body); + response + } + None => { + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + .to_vec() + } + }; + let _ = stream.write_all(&response); + let _ = stream.flush(); + } + } + }); + Self { + origin, + stop, + thread: Some(thread), + } + } +} + +impl Drop for FeedServer { + fn drop(&mut self) { + self.stop.store(true, Ordering::Release); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +/// The updater the production window builds, with the pre-exit hook injected +/// as an argument so the plugin's default hook can be contrasted with ours. +fn updater_with( + app: &AppHandle, + feed: &str, + on_before_exit: F, +) -> Updater { + app.updater_builder() + .on_before_exit(on_before_exit) + .version_comparator(|current, remote| { + // The production main-channel rule: a window that is not itself a + // `main.` prerelease moves to the announced main build. + if !current.pre.as_str().starts_with("main.") { + remote.version.pre.as_str().starts_with("main.") + } else { + remote.version > current + } + }) + // A test binary is not a bundled App, so the plugin cannot derive + // the NSIS target; the real window requests this exact key. + .target(UPDATE_TARGET) + .endpoints(vec![feed.parse().expect("loopback feed url")]) + .expect("the loopback feed is an allowed endpoint") + .no_proxy() + .timeout(Duration::from_secs(30)) + .build() + .expect("updater for the loopback feed") +} + +fn announce(updater: &Updater) -> Update { + let update = tauri::async_runtime::block_on(updater.check()) + .expect("the loopback feed answers") + .expect("the feed announces a main-channel update"); + assert_eq!(update.version, UPDATE_VERSION); + update +} + +fn download(update: &mut Update) -> Vec { + update.timeout = Some(Duration::from_secs(30)); + tauri::async_runtime::block_on(update.download(|_, _| {}, || {})) + .expect("the announced artifact crosses the plugin's signature verification") +} + +/// The pinned updater keeps its extraction directory; a regression run must +/// not leave it behind. +fn plugin_temp_dirs() -> Vec { + let prefix = format!("test-{UPDATE_VERSION}-updater-"); + std::fs::read_dir(std::env::temp_dir()) + .map(|entries| { + entries + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(&prefix)) + }) + .collect() + }) + .unwrap_or_default() +} + +fn kill_with_descendants(child: &mut std::process::Child) { + let _ = std::process::Command::new("taskkill") + .args(["/T", "/F", "/PID", &child.id().to_string()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + let _ = child.wait(); +} + +#[test] +fn a_verified_artifact_whose_launch_fails_keeps_the_window_usable_and_returns_the_runtime() { + let artifact = nsis_artifact(); + let signer = TestSigner::generate(); + let signature = signer.sign(&artifact); + let mut tampered = artifact.clone(); + tampered[64] ^= 0x01; + let server = FeedServer::start(|origin| { + vec![ + ( + "/feed.json", + "application/json", + feed_body(&format!("{origin}/artifact.zip"), &signature), + ), + ( + "/tampered-feed.json", + "application/json", + feed_body(&format!("{origin}/tampered.zip"), &signature), + ), + ( + "/artifact.zip", + "application/octet-stream", + artifact.clone(), + ), + ( + "/tampered.zip", + "application/octet-stream", + tampered.clone(), + ), + ] + }); + + let lifecycle = ServiceLifecycle::new(); + let mut context = mock_context(noop_assets()); + // The pinned plugin reads its configuration from the App config; the + // signing key is the throwaway test key, and the plain-HTTP loopback feed + // is the explicit development allowance the plugin documents. + context.config_mut().plugins.0.insert( + "updater".to_string(), + json!({ + "pubkey": signer.public_key(), + "dangerousInsecureTransportProtocol": true, + }), + ); + let app = mock_builder() + .plugin( + tauri_plugin_updater::Builder::new() + .pubkey(signer.public_key()) + .build(), + ) + .manage(lifecycle.clone()) + .build(context) + .expect("desktop shell for the update regression"); + let window = WebviewWindowBuilder::new(&app, "main", WebviewUrl::default()) + .visible(true) + .build() + .expect("this window"); + let handle = app.handle().clone(); + + let owned = hold_process_owned(); + let owned_pid = owned.id(); + let mut independent = hold_process_independent(); + let independent_pid = independent.id(); + assert!(lifecycle.adopt(owned_set_for_test(vec![owned])).is_ok()); + assert!(process_is_alive(owned_pid)); + let app_resource = handle.resources_table().add(WindowSentinel); + let window_resource = window.resources_table().add(WindowSentinel); + let temp_before = plugin_temp_dirs(); + + let feed = format!("{}/feed.json", server.origin); + // The production hook, verbatim: the same constructor the desktop window + // installs, so the regression cannot drift from the shipped hook. + let handoff = || app_update_pre_exit_hook(&handle); + + // A tampered artifact announced with the same signature must not cross + // verification; without it the "verified" claim below would be vacuous. + let tampered_updater = updater_with( + &handle, + &format!("{}/tampered-feed.json", server.origin), + handoff(), + ); + let tampered_update = announce(&tampered_updater); + assert!( + tauri::async_runtime::block_on(tampered_update.download(|_, _| {}, || {})).is_err(), + "a tampered artifact must not cross the plugin's signature verification" + ); + + let updater = updater_with(&handle, &feed, handoff()); + let mut update = announce(&updater); + let bytes = download(&mut update); + let launched = update.install(bytes); + assert!( + launched.is_err(), + "an unlaunchable installer returns an error instead of replacing this process" + ); + + // The plugin ran this window's own hook, and that hook reclaimed exactly + // one service group -- while a service this App never started survives. + assert!( + lifecycle.handoff_active(), + "the pinned plugin must run the hook the production window installs" + ); + wait_until( + || !process_is_alive(owned_pid), + "the reclaimed service group to exit", + ); + assert!( + process_is_alive(independent_pid), + "a CLI-owned service on its own port is not this App's to stop" + ); + + // The failure keeps the window usable: Tauri's own `cleanup_before_exit` + // would have cleared exactly these tables and hidden this window. + assert!( + handle + .resources_table() + .get::(app_resource) + .is_ok(), + "the App resources must survive a failed installer launch" + ); + assert!( + window + .resources_table() + .get::(window_resource) + .is_ok(), + "the window resources must survive a failed installer launch" + ); + assert!( + handle.get_webview_window("main").is_some(), + "the window the operator retries from must still be addressable" + ); + + // The released handoff owes the reclaimed runtime back to the supervisor. + assert!( + release_app_update_handoff(&handle), + "the hook reclaimed a runtime, so the release owes it back" + ); + assert!(lifecycle.restore_pending()); + let replacement = hold_process_owned(); + let replacement_pid = replacement.id(); + assert!(lifecycle + .adopt(owned_set_for_test(vec![replacement])) + .is_ok()); + assert!( + !lifecycle.restore_pending(), + "a live set settles the restore the released handoff owed" + ); + + // Contrast run: the pinned plugin's *default* hook is `cleanup_before_exit`, + // exactly what this window replaces. Running it proves the assertions + // above detect the pre-fix hazard instead of passing vacuously. + let default_resource = handle.resources_table().add(WindowSentinel); + let default_updater = updater_with(&handle, &feed, { + let app = handle.clone(); + move || app.cleanup_before_exit() + }); + let mut default_update = announce(&default_updater); + let default_bytes = download(&mut default_update); + assert!(default_update.install(default_bytes).is_err()); + assert!( + handle + .resources_table() + .get::(default_resource) + .is_err(), + "the default hook clears the resource table, so the survival assertions above detect that difference" + ); + + lifecycle.shutdown(&handle); + assert!( + !process_is_alive(replacement_pid), + "closing the window reclaims the replacement group" + ); + kill_with_descendants(&mut independent); + for path in plugin_temp_dirs() { + if !temp_before.contains(&path) { + let _ = std::fs::remove_dir_all(path); + } + } +} 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 4095ff5b54..d5152f09a3 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/services.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/services.rs @@ -255,6 +255,28 @@ impl ServiceSet { } } +// Ownership transfer (adopt, update handoff, stop) is exercisable without a +// real runtime: the set owns exactly the groups a test hands it, so a reclaim +// is observable on the same job objects production uses. +#[cfg(test)] +pub(crate) fn owned_set_for_test(children: Vec) -> ServiceSet { + ServiceSet { + owned: children + .into_iter() + .map(|child| OwnedService { + child, + kind: ServiceKind::Status, + }) + .collect(), + runtime: SelectedRuntime { + executable: String::new(), + identity: None, + }, + last_recovery_attempt: None, + healed: false, + } +} + /// One service's connection attempt. The child this App spawned travels with /// the outcome even when the attempt failed, so `ServiceSet` can stop it /// instead of leaking a process that no longer has an owner. diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/workspace_location.rs b/apps/desktop/loopx-control-plane/src-tauri/src/workspace_location.rs index 775b4ab890..30ec5a3345 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/workspace_location.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/workspace_location.rs @@ -7,7 +7,7 @@ use std::{ io::{Read, Write}, path::Path, }; -use tauri::{AppHandle, Manager, Url}; +use tauri::{AppHandle, Manager, Runtime, Url}; #[derive(Debug, Default, PartialEq)] struct LocalWorkspace { @@ -167,7 +167,7 @@ pub(crate) fn launch_destination(app: &AppHandle, origin: &Url) -> Url { origin.clone() } -pub(crate) fn remember_current(app: &AppHandle) { +pub(crate) fn remember_current(app: &AppHandle) { let Some(handoff) = app .try_state::>>( ) diff --git a/apps/desktop/loopx-control-plane/static/boot.js b/apps/desktop/loopx-control-plane/static/boot.js index 4abade4a6e..261c8dc426 100644 --- a/apps/desktop/loopx-control-plane/static/boot.js +++ b/apps/desktop/loopx-control-plane/static/boot.js @@ -111,6 +111,7 @@ const errors = { update_feed_unavailable: "此通道的更新源尚未就绪或暂时不可用。可稍后重新检查。", update_feed_invalid: "更新源格式异常。请稍后重新检查。", update_platform_unavailable: "此通道尚无适用于本机的更新包。", + update_action_platform_unavailable: "当前平台暂不支持该恢复操作:Windows 版 App 仅提供更新检查与安装,修复与回滚暂不可用。", update_check_timeout: "检查更新超时。请稍后重试。", update_network_failed: "更新网络请求失败,尚未安装。请检查连接后重试。", update_download_timeout: "更新包下载超时,尚未安装。请检查网络后重试。", @@ -124,6 +125,22 @@ const errors = { backup_failed: "App 备份无法保存或未通过完整性校验,替换已停止。请检查磁盘空间与权限,或重新安装完整 App。", rollback_failed: "恢复 App 未完成,请保持窗口打开。可重试恢复;若仍失败,请重新安装完整 App。", }; +// Fallback wording for states without a dedicated code must name the controls +// this panel actually offers: where the bundled-runtime repair is unavailable +// (the Windows release), it must not point at the disabled repair action or +// the hidden rollback action. +const unrepairedFallback = { + service_error: "运行时已安装,但服务尚未连接。可检查更新;连接仍会自动重试。", + runtime_required: "无法准备可用组件。可检查 App 更新;当前安装仍会保留。", + available: "有新版本可用,可更新 App。", + error: "更新未完成。请重试检查更新。Goal 数据不会被删除。", +}; +function fallbackText(phase) { + if (!bundledRepairAvailable && Object.hasOwn(unrepairedFallback, phase)) { + return unrepairedFallback[phase]; + } + return labels[phase] || ""; +} function codeText(code, phase) { if (runtimeExplicit && ["runtime_identity_unavailable", "runtime_selection_explicit"].includes(code)) { return "启动参数 LOOPX_BIN 固定了当前运行时。请通过该安装方式修复它,或移除、修正 LOOPX_BIN 后重新打开 App;清除记住的选择不会改变此参数。"; @@ -140,7 +157,7 @@ function codeText(code, phase) { } return `安装程序退出(${code.slice("runtime_install_exit_".length)})。请复制诊断信息反馈;修复没有完成。`; } - return Object.hasOwn(errors, code) ? errors[code] : labels[phase] || ""; + return Object.hasOwn(errors, code) ? errors[code] : fallbackText(phase); } function render(state) { if (!state?.phase) return state; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/desktop-update.tsx b/apps/presentation/dashboard/src/features/personal-workspace/desktop-update.tsx index 726a9bf076..d4ad70327a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/desktop-update.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/desktop-update.tsx @@ -5,7 +5,7 @@ import { useWorkspaceI18n } from "./i18n"; type Phase = "idle" | "runtime_required" | "runtime_pairing_required" | "connecting" | "service_error" | "checking" | "available" | "up_to_date" | "downloading" | "installing_app" | "installing_runtime" | "restart_required" | "ready" | "error"; type UpdateState = { phase: Phase; details?: { version?: string; channel?: string; received?: number; total?: number; code?: string; background?: boolean; prepared?: boolean } }; type RuntimeSelection = { explicit?: boolean; remembered?: boolean; bundled_repair_available?: boolean }; -type UpdateStatus = { state: UpdateState | null; app_version: string; rollback_available?: boolean; runtime_selection?: RuntimeSelection }; +type UpdateStatus = { state: UpdateState | null; app_version: string; platform?: string; rollback_available?: boolean; runtime_selection?: RuntimeSelection }; type DesktopWindow = Window & { __TAURI__?: { core: { invoke: (command: string, args?: Record) => Promise } } }; const workingPhases: Phase[] = ["checking", "connecting", "downloading", "installing_app", "installing_runtime"]; @@ -21,6 +21,7 @@ const updateErrors: Record = { update_feed_unavailable: ["此通道的更新源尚未就绪或暂时不可用。可稍后重新检查,当前版本仍可继续使用。", "This channel's update feed is not ready or temporarily unavailable. Check again later; you can keep using this version."], update_feed_invalid: ["更新源格式异常。请稍后重新检查。", "The update feed is invalid. Check again later."], update_platform_unavailable: ["此通道尚无适用于本机的更新包。", "This channel has no update package for this platform."], + update_action_platform_unavailable: ["当前平台暂不支持该恢复操作:Windows 版 App 仅提供更新检查与安装。", "This recovery action is unavailable on this platform; the Windows App supports update checks and installation only."], update_check_timeout: ["检查更新超时。请稍后重试。", "The update check timed out. Try again later."], update_network_failed: ["更新网络请求失败,尚未安装。请检查连接后重试。", "The update request failed; nothing was installed. Check your connection and retry."], update_download_timeout: ["更新包下载超时,尚未安装。请检查网络后重试。", "The update download timed out; nothing was installed. Check your connection and retry."], @@ -38,6 +39,7 @@ export function DesktopUpdate() { const [open, setOpen] = useState(false); const panel = useRef(null); const [channel, setChannel] = useState("stable"); + const [platform, setPlatform] = useState(""); const [state, setState] = useState({ phase: "idle" }); const [version, setVersion] = useState(""); const [rollbackAvailable, setRollbackAvailable] = useState(false); @@ -53,6 +55,7 @@ export function DesktopUpdate() { function readStatus(value: UpdateStatus, includeState = true) { setVersion(value.app_version); + setPlatform(value.platform ?? ""); setRollbackAvailable(value.rollback_available === true); setRuntimeSelection(value.runtime_selection); if (includeState && value.state?.phase) setState(value.state); @@ -140,14 +143,33 @@ export function DesktopUpdate() { } } } + // Where this App cannot reinstall its bundled runtime (the Windows release, + // or a separately managed runtime), the copy must not name the disabled + // repair action or promise a combined App-and-runtime update. + const phaseMessage = (repaired: string, unrepaired: string) => (repairAvailable ? repaired : unrepaired); + // The feed publishes Windows NSIS updates on the main preview channel only + // (stable and MSI installs keep the manual installer path), so the idle copy + // must scope the launch-time promise to that channel instead of the host OS + // alone. Read the host OS from the native status projection. + const idleMessage = () => { + if (platform === "windows") { + return channel === "main" + ? (zh ? "main 预览版会在启动时检查并安装已签名的 Windows NSIS 更新;也可在此手动检查或安装。" : "The Windows main preview checks for and installs signed NSIS updates at launch. You can also check or install here.") + : (zh ? "Windows 稳定版与 MSI 安装需手动运行新的签名安装包;App 内自动更新目前只在 main 预览通道提供(NSIS)。" : "Windows stable and MSI installs update by running a newer signed installer; in-App automatic updates currently ship on the main preview channel (NSIS)."); + } + if (platform === "macos") { + return zh ? "先打开本地版本,更新在后台准备,下次打开时生效。也可在此手动更新。" : "Open the local version first. Updates prepare in the background and apply on the next open. You can also update here."; + } + return zh ? "更新会跟随所选通道;请检查更新以确认本机是否有可用安装包。" : "Updates follow the selected channel; check for updates to see whether this machine has an installer."; + }; const message: Record = { - service_error: zh ? "运行时已安装,但服务尚未连接。可重试更新、修复或恢复上版。" : "Runtime installed, but services are unavailable. Retry updates, repair, or restore the previous version.", + service_error: phaseMessage(zh ? "运行时已安装,但服务尚未连接。可重试更新、修复或恢复上版。" : "Runtime installed, but services are unavailable. Retry updates, repair, or restore the previous version.", zh ? "运行时已安装,但服务尚未连接。可检查更新;连接仍会自动重试。" : "Runtime installed, but services are unavailable. Check for updates; the connection keeps retrying."), runtime_pairing_required: zh ? "旧版启动器未能自动选择可用组件。请检查 App 更新,或通过恢复入口重新连接。" : "The older launcher could not select a usable runtime. Check for an App update or reconnect through recovery.", - idle: zh ? "先打开本地版本,更新在后台准备,下次打开时生效。也可在此手动更新。" : "Open the local version first. Updates prepare in the background and apply on the next open. You can also update here.", - runtime_required: zh ? "请完成匹配组件安装,或检查 App 更新。" : "Install matching components or check for an App update.", + idle: idleMessage(), + runtime_required: phaseMessage(zh ? "请完成匹配组件安装,或检查 App 更新。" : "Install matching components or check for an App update.", zh ? "无法准备可用组件。可检查 App 更新;当前安装仍会保留。" : "Components are unavailable. Check for an App update; the current installation is kept."), connecting: zh ? "正在连接本地服务…" : "Connecting to local services…", checking: state.details?.background ? (zh ? "正在后台检查更新,当前版本可继续使用。" : "Checking in the background. Keep using this version.") : (zh ? "正在检查更新…" : "Checking for updates…"), - available: state.details?.prepared ? (zh ? "更新已下载并通过签名校验,下次打开时自动安装。" : "Update downloaded and signature verified. It installs on the next open.") : (zh ? "新版本已就绪,一次更新 App 与匹配的运行时。" : "Update the App and its matching runtime together."), + available: state.details?.prepared ? (zh ? "更新已下载并通过签名校验,下次打开时自动安装。" : "Update downloaded and signature verified. It installs on the next open.") : phaseMessage(zh ? "新版本已就绪,一次更新 App 与匹配的运行时。" : "Update the App and its matching runtime together.", zh ? "有新版本可用,可更新 App。" : "A new version is available; the App can be updated."), up_to_date: zh ? "当前通道暂无更新。" : "No newer update on this channel.", downloading: state.details?.background ? (zh ? "正在后台下载并校验签名,当前版本可继续使用。" : "Downloading and verifying in the background. Keep using this version.") : (zh ? "正在下载并校验签名…" : "Downloading and verifying signature…"), installing_app: zh ? "正在安装 App,请保持窗口打开。" : "Installing the App. Keep this window open.", @@ -192,7 +214,7 @@ export function DesktopUpdate() { -

{zh ? "App 与匹配的 CLI 一起更新,服务可能短暂断开。不删除 Goal 数据。" : "Updates the App and matching CLI. Services may briefly disconnect. Goal data is not deleted."}

+

{repairAvailable ? (zh ? "App 与匹配的 CLI 一起更新,服务可能短暂断开。不删除 Goal 数据。" : "Updates the App and matching CLI. Services may briefly disconnect. Goal data is not deleted.") : (zh ? "仅更新 App;服务可能短暂断开。不删除 Goal 数据。" : "Updates the App only. Services may briefly disconnect. Goal data is not deleted.")}

{invoke ? <>

{runtimeSelection?.explicit === true ? updateErrors.runtime_selection_explicit[zh ? 0 : 1] : repairAvailable ? (zh ? "启动失败时,可重装当前 App 随附的运行时。" : "If startup fails, reinstall this App's bundled runtime.") : (zh ? "当前 App 无法重装运行时。请恢复上个版本或重新安装完整 App;单独管理的运行时请通过原安装方式维护。" : "This App cannot reinstall the runtime. Restore the previous version or reinstall the complete App; maintain separately managed runtimes through their installer.")}

diff --git a/examples/desktop-update-browser-smoke.mjs b/examples/desktop-update-browser-smoke.mjs index 11651fc4ba..cc1ff0bcc1 100644 --- a/examples/desktop-update-browser-smoke.mjs +++ b/examples/desktop-update-browser-smoke.mjs @@ -16,7 +16,7 @@ const server = createServer(async (req, res) => { const relative = url.pathname === "/chat/" ? "/chat/index.html" : url.pathname; const base = url.pathname.startsWith("/boot/") ? resolve(root, "apps/desktop/loopx-control-plane/static") : resolve(root, "loopx/web"); const path = resolve(base, `.${url.pathname.startsWith("/boot/") ? url.pathname.slice(5) : relative}`); - if (!path.startsWith(`${base}/`)) { res.writeHead(403).end(); return; } + if (!path.startsWith(`${base}/`) && !path.startsWith(`${base}\\`)) { res.writeHead(403).end(); return; } try { res.setHeader("Content-Type", types[extname(path)] ?? "application/octet-stream"); res.end(await readFile(path)); } catch { res.writeHead(404).end(); } }); @@ -47,6 +47,7 @@ try { let checkUpToDate = false; let statusFailure = false; let environmentTelemetry = null; + let platform = "macos"; let delayedStatus = null; function delayNextStatus(phase) { delayedStatus = { @@ -65,6 +66,7 @@ try { runtime_selection: structuredClone(nativeRuntimeSelection), startup: structuredClone(startupTiming), app_version: "0.5.4", + platform, rollback_available: rollbackAvailable, environment: structuredClone(environmentTelemetry), }; @@ -365,6 +367,29 @@ try { await page.getByText("高级选项", { exact: true }).click(); assert.equal(await page.getByRole("button", { name: "修复当前版本", exact: true }).isEnabled(), true, "legacy native status retains its available repair action"); + // The feed publishes Windows NSIS updates on main only. Stable must name the + // manual installer path; main may promise the launch-time installation. + platform = "windows"; + nativeState = { phase: "idle", details: {} }; + await page.reload(); + await page.getByRole("button", { name: "更新 LoopX", exact: true }).click(); + const stableIdle = page.locator(".personal-update-panel").getByRole("status"); + await page.getByText(/Windows 稳定版与 MSI 安装需手动运行新的签名安装包/).waitFor(); + assert.doesNotMatch(await stableIdle.innerText(), /启动时检查并安装/, "stable must not promise launch-time installation"); + await page.screenshot({ path: resolve(output, "update-idle-windows-stable.png") }); + nativeState = { phase: "idle", details: { channel: "main" } }; + await page.reload(); + await page.getByRole("button", { name: "更新 LoopX", exact: true }).click(); + await page.getByText(/main 预览版会在启动时检查并安装已签名的 Windows NSIS 更新/).waitFor(); + await page.getByText("0.5.4 · main 预览版", { exact: true }).waitFor(); + nativeState = { phase: "idle", details: {} }; + await page.evaluate(() => localStorage.setItem("loopx-pw-locale", "en")); + await page.reload(); + await page.getByRole("button", { name: "Update LoopX", exact: true }).click(); + await page.getByText(/in-App automatic updates currently ship on the main preview channel/).waitFor(); + await page.evaluate(() => localStorage.setItem("loopx-pw-locale", "zh-CN")); + platform = "macos"; + const missing = await browser.newPage(); await missing.route("**/assets/*.js", (route) => route.abort()); await missing.goto(`${origin}/chat/`); @@ -480,6 +505,11 @@ try { } await page.screenshot({ path: resolve(output, "startup-recovery.png") }); + nativeRuntimeSelection = { explicit: false, remembered: true, bundled_repair_available: false }; + await page.getByText("运行时已安装,但服务尚未连接。可检查更新;连接仍会自动重试。").waitFor(); + assert.equal(await page.locator("#repair").isEnabled(), false, "boot fallback must match the disabled repair control"); + nativeRuntimeSelection = undefined; + // A snapshot stuck in a terminal phase must turn the main status line into // its error projection (fresh Mac without Python 3.11+: installer exit 2), // not the pre-fix permanent loading shape. The projection is derived by the diff --git a/scripts/desktop_update_feed.py b/scripts/desktop_update_feed.py index 61397ff7e6..e86044d358 100644 --- a/scripts/desktop_update_feed.py +++ b/scripts/desktop_update_feed.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """Publish a complete native updater feed only after all signed artifacts exist.""" import argparse +import base64 +import binascii import json import re from pathlib import Path @@ -10,12 +12,62 @@ STABLE_VERSION = re.compile(r"(\d+)\.(\d+)\.(\d+)") MAIN_VERSION = re.compile(r"0\.0\.0-main\.(\d+)\.(\d+)") +# Publish only platforms with a qualified signed update artifact for the +# channel. Windows NSIS updates ship on the main channel first; the stable +# channel keeps manual Windows installs until that rollout is approved. +# Keys follow the pinned updater's `{os}-{arch}-{installer}` lookup in +# tauri-plugin-updater 2.11.0, whose installer component comes from the +# bundle-type marker the NSIS install carries. Do not add the bare +# `windows-x86_64` fallback key until MSI installs are qualified: the updater +# consults that key for every Windows installer kind, so it would offer the +# NSIS installer to MSI installs as well. +QUALIFIED_PLATFORMS = { + "main": ( + ("darwin-aarch64", ".app.tar.gz"), + ("windows-x86_64-nsis", "-updater-setup.exe"), + ), + "stable": (("darwin-aarch64", ".app.tar.gz"),), +} + def release_channel(tag: str, prerelease: bool) -> str: """Only core stable releases may advance the desktop stable pointer.""" return "stable" if not prerelease and re.fullmatch(r"v\d+\.\d+\.\d+", tag) else "skip" +def _minisign_signature(path: Path) -> str: + """Publish only a signature the pinned updater can parse. + + The client base64-decodes the .sig file and hands the bytes to minisign, + which reads four lines: the untrusted comment, the 74-byte signature, the + trusted comment and the 64-byte global signature. A truncated or + mismatched file must fail here, not after the client has downloaded the + artifact. + """ + encoded = path.read_text(encoding="utf-8").strip() + if not encoded: + raise ValueError(f"missing signature: {path.name}") + try: + decoded = base64.b64decode(encoded, validate=True) + except (binascii.Error, ValueError) as error: + raise ValueError(f"invalid signature encoding: {path.name}") from error + lines = decoded.split(b"\n") + if lines and lines[-1] == b"": + lines.pop() + if len(lines) != 4 or not lines[0].startswith(b"untrusted comment: "): + raise ValueError(f"invalid signature contents: {path.name}") + if not lines[2].startswith(b"trusted comment: "): + raise ValueError(f"invalid signature contents: {path.name}") + try: + signature = base64.b64decode(lines[1], validate=True) + global_signature = base64.b64decode(lines[3], validate=True) + except (binascii.Error, ValueError) as error: + raise ValueError(f"invalid signature contents: {path.name}") from error + if len(signature) != 74 or len(global_signature) != 64: + raise ValueError(f"invalid signature contents: {path.name}") + return encoded + + def _channel_version_key(channel: str, value: object) -> tuple[int, ...]: if not isinstance(value, dict) or not isinstance(value.get("version"), str): raise ValueError("desktop update feed must contain a string version") @@ -38,21 +90,20 @@ def channel_pointer_decision(channel: str, current: object, candidate: object) - return "advance" if candidate_key >= current_key else "retain" -def build(directory: Path, version: str, tag: str) -> dict: +def build(directory: Path, version: str, tag: str, channel: str) -> dict: if not re.fullmatch(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?", version): raise ValueError("invalid desktop version") if not re.fullmatch(r"[0-9A-Za-z._-]+", tag): raise ValueError("invalid release tag") + if channel not in QUALIFIED_PLATFORMS: + raise ValueError("unsupported desktop update channel") platforms = {} - # Publish only platforms with a qualified bundled-runtime installer. - for platform, suffix in (("darwin-aarch64", ".app.tar.gz"),): + for platform, suffix in QUALIFIED_PLATFORMS[channel]: matches = list(directory.glob(f"*{suffix}")) if len(matches) != 1 or matches[0].stat().st_size == 0: raise ValueError(f"missing or ambiguous artifact: {platform}") artifact = matches[0] - signature = artifact.with_name(artifact.name + ".sig").read_text().strip() - if not signature: - raise ValueError(f"missing signature: {platform}") + signature = _minisign_signature(artifact.with_name(artifact.name + ".sig")) platforms[platform] = { "url": f"https://github.com/loopx-project/loopx/releases/download/{quote(tag)}/{quote(artifact.name)}", "signature": signature, @@ -67,6 +118,7 @@ def build(directory: Path, version: str, tag: str) -> dict: parser.add_argument("--tag") parser.add_argument("--classify-release") parser.add_argument("--prerelease", choices=("true", "false"), default="false") + parser.add_argument("--channel", choices=("main", "stable")) parser.add_argument("--channel-pointer-decision", choices=("stable", "main")) parser.add_argument("--current-feed", type=Path) parser.add_argument("--candidate-feed", type=Path) @@ -87,7 +139,9 @@ def build(directory: Path, version: str, tag: str) -> dict: raise SystemExit(0) if args.directory is None or args.version is None or args.tag is None: parser.error("--directory, --version and --tag are required to build a feed") - output = build(args.directory, args.version, args.tag) + if args.channel is None: + parser.error("--channel is required to build a feed") + output = build(args.directory, args.version, args.tag, args.channel) (args.directory / "desktop-updater.json").write_text( json.dumps(output, indent=2) + "\n", encoding="utf-8" ) diff --git a/tests/desktop/test_update_feed.py b/tests/desktop/test_update_feed.py index 578445e8db..dcaa7bd67b 100644 --- a/tests/desktop/test_update_feed.py +++ b/tests/desktop/test_update_feed.py @@ -1,6 +1,11 @@ """Fail closed before advertising incomplete updates.""" +import base64 import importlib.util +import json +import os from pathlib import Path +import shutil +import subprocess import tempfile import unittest @@ -15,6 +20,15 @@ / "workflows" / "desktop-updater.yml" ) +# What Tauri signs with: base64 of a minisign signature whose lines are the +# untrusted comment, a 74-byte signature, a trusted comment and a 64-byte +# global signature. Matches the shape of published channel signatures. +TEST_SIGNATURE = base64.b64encode( + b"untrusted comment: signature from tauri secret key\n" + + base64.b64encode(b"\x00" * 74) + + b"\ntrusted comment: timestamp:0\n" + + base64.b64encode(b"\x00" * 64) +).decode("ascii") class FeedTests(unittest.TestCase): @@ -92,6 +106,280 @@ def test_workflow_compares_the_existing_feed_before_replacing_the_pointer(self): self.assertEqual(positions, sorted(positions)) self.assertIn("already points to an equal or newer build", publish) + def test_workflow_builds_and_uploads_the_signed_windows_installer(self): + workflow = WORKFLOW.read_text(encoding="utf-8") + self.assertIn("runs-on: windows-latest", workflow) + self.assertIn("BUNDLE: nsis", workflow) + self.assertIn("dist/updater/*.exe", workflow) + feed_step = workflow.split("- name: Generate complete feed", 1)[1].split( + "- name: Upload immutable artifacts", 1 + )[0] + self.assertIn('--channel "$CHANNEL"', feed_step) + self.assertIn("CHANNEL: ${{ needs.identity.outputs.channel }}", feed_step) + + def test_workflow_gates_the_signed_windows_build_and_checks_its_version(self): + workflow = WORKFLOW.read_text(encoding="utf-8") + macos_step = workflow.split( + "- name: Build signed macOS App and exact bundled runtime", 1 + )[1].split("- name: Build signed Windows NSIS updater installer", 1)[0] + windows_step = workflow.split( + "- name: Build signed Windows NSIS updater installer", 1 + )[1].split("- name: Collect native updater artifacts", 1)[0] + self.assertIn("if: runner.os == 'macOS'", macos_step) + self.assertIn('\\"createUpdaterArtifacts\\":true', macos_step) + self.assertIn('\\"signingIdentity\\":\\"-\\"', macos_step) + self.assertIn("if: runner.os == 'Windows'", windows_step) + self.assertIn('\\"createUpdaterArtifacts\\":true', windows_step) + self.assertNotIn("signingIdentity", windows_step) + collect_step = workflow.split( + "- name: Collect native updater artifacts", 1 + )[1].split("- uses: actions/upload-artifact", 1)[0] + self.assertIn("-updater-setup.exe", collect_step) + self.assertIn("*-updater-setup.exe*) continue", collect_step) + self.assertIn('ls dist/updater/*"$VERSION"*-updater-setup.exe', collect_step) + self.assertIn("VERSION: ${{ needs.identity.outputs.version }}", collect_step) + + def test_stable_publish_does_not_require_the_windows_build(self): + workflow = WORKFLOW.read_text(encoding="utf-8") + build_macos = workflow.split("\n build-macos:\n", 1)[1].split( + "\n build-windows:\n", 1 + )[0] + build_windows = workflow.split("\n build-windows:\n", 1)[1].split( + "\n publish:\n", 1 + )[0] + self.assertIn( + "if: needs.identity.outputs.channel == 'stable' || needs.identity.outputs.channel == 'main'", + build_macos, + ) + self.assertIn("if: needs.identity.outputs.channel == 'main'", build_windows) + self.assertIn("name: signed-updater-windows-latest", build_windows) + + publish = workflow.split("\n publish:\n", 1)[1] + self.assertIn( + "needs: [identity, build-macos, build-windows, validate]", publish + ) + condition = publish.split("if: >-", 1)[1].split("runs-on:", 1)[0] + expression = " ".join(line.strip() for line in condition.splitlines()) + python_expression = ( + expression.replace("always()", "True") + .replace("&&", " and ") + .replace("||", " or ") + .replace("needs.identity.result", "identity_result") + .replace("needs.build-macos.result", "darwin_result") + .replace("needs.build-windows.result", "windows_result") + .replace("needs.validate.result", "validate_result") + .replace("needs.identity.outputs.channel", "channel") + ) + + def publish_after( + channel, + identity_result="success", + darwin_result="success", + windows_result="skipped", + validate_result="success", + ): + return eval( # noqa: S307 - repository-owned expression, no builtins. + python_expression, + {"__builtins__": {}}, + { + "channel": channel, + "identity_result": identity_result, + "darwin_result": darwin_result, + "windows_result": windows_result, + "validate_result": validate_result, + }, + ) + + self.assertTrue( + publish_after("stable", windows_result="failure"), + "a Windows failure must not withhold the Darwin-only stable feed", + ) + self.assertFalse( + publish_after("main", windows_result="failure"), + "a main release must reject a failed Windows build", + ) + self.assertFalse( + publish_after("main", windows_result="skipped"), + "a main release must reject a missing Windows build", + ) + self.assertTrue(publish_after("main", windows_result="success")) + + def _upload_step_script(self): + lines = WORKFLOW.read_text(encoding="utf-8").splitlines() + step = next( + index + for index, line in enumerate(lines) + if line.strip() + == "- name: Upload immutable artifacts, publish channel pointer last" + ) + run = next( + index + for index in range(step + 1, len(lines)) + if lines[index].strip() == "run: |" + ) + script_indent = len(lines[run + 1]) - len(lines[run + 1].lstrip()) + body = [] + for line in lines[run + 1 :]: + if line.strip() and len(line) - len(line.lstrip()) < script_indent: + break + body.append(line[script_indent:]) + return "\n".join(body).rstrip() + + def _working_bash(self): + candidates = [os.environ.get("BASH"), shutil.which("bash")] + git = shutil.which("git") + if git: + candidates.append(str(Path(git).resolve().parents[1] / "bin" / "bash.exe")) + if os.name == "nt": + candidates.extend( + [ + r"C:\Program Files\Git\bin\bash.exe", + r"C:\Program Files\Git\usr\bin\bash.exe", + ] + ) + for candidate in candidates: + if not candidate: + continue + try: + probe = subprocess.run( + [candidate, "-c", "exit 0"], + capture_output=True, + timeout=10, + ) + except OSError: + continue + if probe.returncode == 0: + return candidate + self.skipTest("a working bash is required for the upload-step regression") + + def _run_upload_step(self, channel, version, tag, include_windows): + bash = self._working_bash() + script = self._upload_step_script() + case_dir = self.root / f"upload-{channel}-{int(include_windows)}" + updater = case_dir / "dist" / "updater" + updater.mkdir(parents=True) + for name in ("LoopX.app.tar.gz", "LoopX.app.tar.gz.sig"): + shutil.copy2(self.root / name, updater / name) + if include_windows: + for name in ( + "LoopX_0.0.0-main.123.1_x64-updater-setup.exe", + "LoopX_0.0.0-main.123.1_x64-updater-setup.exe.sig", + ): + shutil.copy2(self.root / name, updater / name) + payload = feed.build(updater, version, tag, channel) + (updater / "desktop-updater.json").write_text( + json.dumps(payload), encoding="utf-8" + ) + + bin_dir = case_dir / "bin" + bin_dir.mkdir() + gh_log = case_dir / "gh.log" + gh = bin_dir / "gh" + gh.write_text( + "#!/bin/sh\n" + 'printf \'%s\\n\' "$*" >> "$GH_LOG"\n' + "exit 0\n", + encoding="utf-8", + ) + gh.chmod(0o755) + env = { + **os.environ, + "PATH": f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}", + "GH_LOG": str(gh_log), + "RELEASE_TAG": tag, + "SOURCE_REF": "source-ref", + "EVENT_NAME": "release", + "CHANNEL": channel, + } + result = subprocess.run( + [bash, "-c", script], + cwd=case_dir, + env=env, + capture_output=True, + text=True, + timeout=30, + ) + self.assertEqual(result.returncode, 0, result.stderr) + return gh_log.read_text(encoding="utf-8").splitlines() + + def test_upload_step_skips_absent_windows_artifact_on_stable(self): + stable = self._run_upload_step("stable", "1.2.4", "v1.2.4", False) + self.assertIn("release upload v1.2.4 dist/updater/LoopX.app.tar.gz", stable) + self.assertIn( + "release upload v1.2.4 dist/updater/LoopX.app.tar.gz.sig", stable + ) + self.assertIn( + "release upload v1.2.4 dist/updater/desktop-updater.json --clobber", + stable, + ) + self.assertIn( + "release upload desktop-stable dist/updater/desktop-updater.json --clobber", + stable, + ) + self.assertFalse(any(".exe" in line for line in stable)) + self.assertFalse(any("*" in line for line in stable)) + + main = self._run_upload_step( + "main", "0.0.0-main.123.1", "desktop-main-123-1", True + ) + self.assertIn( + "release upload desktop-main-123-1 " + "dist/updater/LoopX_0.0.0-main.123.1_x64-updater-setup.exe", + main, + ) + self.assertIn( + "release upload desktop-main dist/updater/desktop-updater.json --clobber", + main, + ) + feed_upload = next( + index + for index, line in enumerate(main) + if "dist/updater/desktop-updater.json --clobber" in line + and "desktop-main " not in line + ) + pointer_upload = next( + index + for index, line in enumerate(main) + if line.startswith("release upload desktop-main ") + ) + self.assertLess(feed_upload, pointer_upload) + + def test_upload_step_rejects_main_without_windows_artifact(self): + case_dir = self.root / "upload-main-missing" + updater = case_dir / "dist" / "updater" + updater.mkdir(parents=True) + for name in ("LoopX.app.tar.gz", "LoopX.app.tar.gz.sig"): + shutil.copy2(self.root / name, updater / name) + with self.assertRaises(ValueError): + feed.build(updater, "0.0.0-main.123.1", "desktop-main-123-1", "main") + + def test_workflow_pointer_upload_stays_inside_the_advance_branch(self): + publish = WORKFLOW.read_text(encoding="utf-8").split( + "- name: Upload immutable artifacts, publish channel pointer last", 1 + )[1] + lines = publish.splitlines() + branch = next( + index + for index, line in enumerate(lines) + if 'if [ "$decision" = advance ]; then' in line + ) + upload = next( + index + for index, line in enumerate(lines) + if 'gh release upload "$pointer"' in line + ) + closing = next( + index + for index, line in enumerate(lines[upload:], start=upload) + if line.strip() == "fi" + ) + + def indentation(line): + return len(line) - len(line.lstrip()) + + self.assertGreater(indentation(lines[upload]), indentation(lines[branch])) + self.assertEqual(indentation(lines[closing]), indentation(lines[branch])) + def setUp(self): self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) @@ -99,16 +387,53 @@ def setUp(self): self.artifact = self.root / "LoopX.app.tar.gz" self.artifact.write_bytes(b"test archive") self.signature = self.root / "LoopX.app.tar.gz.sig" - self.signature.write_text("test signature") + self.signature.write_text(TEST_SIGNATURE) + self.windows = self.root / "LoopX_0.0.0-main.123.1_x64-updater-setup.exe" + self.windows.write_bytes(b"test installer") + self.windows_signature = ( + self.root / "LoopX_0.0.0-main.123.1_x64-updater-setup.exe.sig" + ) + self.windows_signature.write_text(TEST_SIGNATURE) def build(self): - return feed.build(self.root, "0.0.0-main.123.1", "desktop-main-123-1") + return feed.build(self.root, "0.0.0-main.123.1", "desktop-main-123-1", "main") def test_immutable_source_and_qualified_platform(self): result = self.build() - self.assertEqual(set(result["platforms"]), {"darwin-aarch64"}) + self.assertEqual( + set(result["platforms"]), {"darwin-aarch64", "windows-x86_64-nsis"} + ) self.assertEqual(result["platforms"]["darwin-aarch64"]["url"], "https://github.com/loopx-project/loopx/releases/download/desktop-main-123-1/LoopX.app.tar.gz") + self.assertEqual( + result["platforms"]["windows-x86_64-nsis"]["url"], + "https://github.com/loopx-project/loopx/releases/download/desktop-main-123-1/LoopX_0.0.0-main.123.1_x64-updater-setup.exe", + ) + + def test_stable_channel_excludes_windows_until_that_rollout_is_approved(self): + # The signed Windows installer is attached to stable releases for + # manual installs, but the stable feed must not advertise it yet. + result = feed.build(self.root, "1.2.4", "v1.2.4", "stable") + self.assertEqual(set(result["platforms"]), {"darwin-aarch64"}) + + def test_windows_artifact_fails_closed_on_the_main_channel(self): + self.windows.unlink() + with self.assertRaises(ValueError): + self.build() + self.windows.write_bytes(b"") + with self.assertRaises(ValueError): + self.build() + self.windows.write_bytes(b"installer") + self.windows_signature.unlink() + with self.assertRaises(FileNotFoundError): + self.build() + self.windows_signature.write_text(" \n") + with self.assertRaises(ValueError): + self.build() + + def test_unknown_channel_is_rejected(self): + with self.assertRaises(ValueError): + feed.build(self.root, "0.0.0-main.123.1", "desktop-main-123-1", "nightly") def test_missing_empty_and_duplicate_artifact(self): self.artifact.unlink() @@ -130,8 +455,49 @@ def test_missing_or_empty_signature(self): with self.assertRaises(ValueError): self.build() + def test_malformed_signature_fails_closed(self): + # The client needs two decodable payloads of 74 and 64 bytes; a + # partially written file that only carries the comment line, a wrong + # payload length, or bytes a lenient decoder would silently drop must + # not be published. + short_signature = base64.b64encode(b"\x00" * 73) + global_signature = base64.b64encode(b"\x00" * 64) + for case, content in [ + ("not base64", "signature from tauri secret key"), + ("wrong decoded contents", base64.b64encode(b"hello world").decode("ascii")), + ("broken padding", TEST_SIGNATURE[:-1]), + ( + "comment only", + base64.b64encode( + b"untrusted comment: signature from tauri secret key\n" + ).decode("ascii"), + ), + ( + "wrong payload length", + base64.b64encode( + b"untrusted comment: c\n" + + short_signature + + b"\ntrusted comment: t\n" + + global_signature + ).decode("ascii"), + ), + ("embedded newline", TEST_SIGNATURE[:40] + "\n" + TEST_SIGNATURE[40:]), + ]: + with self.subTest(case=case): + self.signature.write_text(content) + with self.assertRaises(ValueError): + self.build() + # Keep the qualified Darwin signature valid so the second + # failure is caused by the Windows signature alone. + self.signature.write_text(TEST_SIGNATURE) + self.windows_signature.write_text(content) + with self.assertRaises(ValueError): + self.build() + self.windows_signature.write_text(TEST_SIGNATURE) + self.signature.write_text(TEST_SIGNATURE) + def test_untrusted_version_or_tag(self): for version, tag in [("v1.0.0", "valid"), ("1.0.0", "../main"), ("1.0.0;command", "main")]: with self.subTest(version=version, tag=tag): with self.assertRaises(ValueError): - feed.build(self.root, version, tag) + feed.build(self.root, version, tag, "main")