From b35fc165eec174a30ad057c9d2d7642cb59fa935 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 16 Jul 2026 10:55:19 -0700 Subject: [PATCH] feat(linux): extend companion self-update to macOS/Windows test builds (#109244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform-aware install kinds: Linux AppImage and macOS self-install in place; Windows defers — the update downloads and verifies in the background, then the NSIS installer runs only from the user-confirmed restart (Tauri's installer launch exits the process, so it must never fire behind a silent auto-check). Linux system packages stay notify-only. The release workflow gains dispatch-gated unsigned macOS/Windows test bundles signed with the same minisign key, and a separate desktop-test update channel: latest.json stays Linux-only on every release, while an opt-in fixed 'desktop-test' prerelease hosts latest-desktop-test.json (monotonic version guard) so Linux-only releases can never strand distributed test builds. Build jobs check out the SHA the validation job resolved, closing the tag-move race. --- .github/workflows/linux-app-release.yml | 332 ++++++++++++++++++++++-- apps/linux/README.md | 2 +- apps/linux/src-tauri/Cargo.toml | 2 +- apps/linux/src-tauri/src/updater.rs | 155 ++++++++--- 4 files changed, 432 insertions(+), 59 deletions(-) diff --git a/.github/workflows/linux-app-release.yml b/.github/workflows/linux-app-release.yml index 8da03491a221..cd6440485b38 100644 --- a/.github/workflows/linux-app-release.yml +++ b/.github/workflows/linux-app-release.yml @@ -7,6 +7,10 @@ on: description: Existing OpenClaw release tag to receive Linux companion bundles, for example v2026.7.1 required: true type: string + desktop-test-bundles: + description: Also build unsigned macOS/Windows test bundles + default: false + type: boolean permissions: contents: write @@ -19,12 +23,14 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" jobs: - build_and_attach: - name: Build and attach Linux companion bundles - # Oldest supported build base: bundles link against this glibc, so newer - # runners would silently drop Ubuntu 22.04/Debian 12 users. + validate_release: + name: Validate release tag runs-on: ubuntu-22.04 - timeout-minutes: 45 + timeout-minutes: 10 + # Build jobs check out this exact SHA so a tag force-moved mid-run cannot + # swap in code the ancestry guard never validated. + outputs: + tag_sha: ${{ steps.ancestry.outputs.tag_sha }} steps: - name: Validate tag input format env: @@ -47,6 +53,7 @@ jobs: persist-credentials: false - name: Ensure tag commit is reachable from main + id: ancestry env: RELEASE_TAG: ${{ inputs.tag }} run: | @@ -57,6 +64,7 @@ jobs: echo "Tag ${RELEASE_TAG} (${tag_sha}) is not reachable from main; Linux bundles ship for main-based releases only." exit 1 fi + echo "tag_sha=${tag_sha}" >> "$GITHUB_OUTPUT" - name: Ensure matching GitHub release exists env: @@ -64,6 +72,20 @@ jobs: RELEASE_TAG: ${{ inputs.tag }} run: gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --json tagName --jq .tagName + build_linux: + name: Build Linux companion bundles + needs: validate_release + # Oldest supported build base: bundles link against this glibc, so newer + # runners would silently drop Ubuntu 22.04/Debian 12 users. + runs-on: ubuntu-22.04 + timeout-minutes: 45 + steps: + - name: Checkout selected tag + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ needs.validate_release.outputs.tag_sha }} + persist-credentials: false + - name: Install Tauri system dependencies run: | sudo apt-get update @@ -104,51 +126,266 @@ jobs: pnpm dlx @tauri-apps/cli@2.11.4 build --bundles deb,appimage \ --config "{\"version\":\"${version}\"}" - - name: Verify and rename bundles for the release tag + - name: Verify and rename Linux bundles env: RELEASE_TAG: ${{ inputs.tag }} run: | set -euo pipefail + shopt -s nullglob version="${RELEASE_TAG#v}" - deb=$(ls apps/linux/src-tauri/target/release/bundle/deb/*.deb) - deb_version=$(dpkg-deb -f "${deb}" Version) + debs=(apps/linux/src-tauri/target/release/bundle/deb/*.deb) + appimages=(apps/linux/src-tauri/target/release/bundle/appimage/*.AppImage) + if [[ ${#debs[@]} -ne 1 || ${#appimages[@]} -ne 1 || ! -f "${appimages[0]}.sig" ]]; then + echo "Expected one deb, one AppImage, and its updater signature" + exit 1 + fi + deb_version=$(dpkg-deb -f "${debs[0]}" Version) if [[ "${deb_version}" != "${version}"* ]]; then echo "Debian package version '${deb_version}' does not match release version '${version}'" exit 1 fi - mkdir -p dist/linux-app - cp "${deb}" "dist/linux-app/OpenClaw-${version}-amd64.deb" - cp apps/linux/src-tauri/target/release/bundle/appimage/*.AppImage \ - "dist/linux-app/OpenClaw-${version}-amd64.AppImage" - (cd dist/linux-app && sha256sum ./* > SHA256SUMS.linux-app.txt) - cat dist/linux-app/SHA256SUMS.linux-app.txt + mkdir -p dist/linux-app/release dist/linux-app/signatures + cp "${debs[0]}" "dist/linux-app/release/OpenClaw-${version}-amd64.deb" + cp "${appimages[0]}" "dist/linux-app/release/OpenClaw-${version}-amd64.AppImage" + cp "${appimages[0]}.sig" \ + "dist/linux-app/signatures/OpenClaw-${version}-amd64.AppImage.sig" - - name: Generate updater manifest (latest.json) + - name: Upload Linux bundles + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: linux-app-release + path: dist/linux-app + if-no-files-found: error + + # TEST-ONLY bundles: no Apple codesigning/notarization or Authenticode. + # Users must bypass Gatekeeper or SmartScreen before running them. + build_macos: + name: Build unsigned macOS test bundles + if: ${{ inputs['desktop-test-bundles'] }} + needs: validate_release + runs-on: macos-14 + timeout-minutes: 45 + steps: + - name: Checkout selected tag + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ needs.validate_release.outputs.tag_sha }} + persist-credentials: false + + - name: Install Rust + run: rustup toolchain install stable --profile minimal + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + install-deps: "false" + + - name: Build macOS test bundles + working-directory: apps/linux/src-tauri env: + RELEASE_TAG: ${{ inputs.tag }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + set -euo pipefail + version="${RELEASE_TAG#v}" + pnpm dlx @tauri-apps/cli@2.11.4 build --bundles app,dmg \ + --config "{\"version\":\"${version}\"}" + + - name: Verify and rename macOS bundles + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + shopt -s nullglob + version="${RELEASE_TAG#v}" + apps=(apps/linux/src-tauri/target/release/bundle/macos/*.app) + archives=(apps/linux/src-tauri/target/release/bundle/macos/*.app.tar.gz) + dmgs=(apps/linux/src-tauri/target/release/bundle/dmg/*.dmg) + if [[ ${#apps[@]} -ne 1 || ${#archives[@]} -ne 1 || ${#dmgs[@]} -ne 1 || ! -f "${archives[0]}.sig" ]]; then + echo "Expected one app, updater archive, updater signature, and dmg" + exit 1 + fi + bundle_version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \ + "${apps[0]}/Contents/Info.plist") + if [[ "${bundle_version}" != "${version}" ]]; then + echo "macOS bundle version '${bundle_version}' does not match release version '${version}'" + exit 1 + fi + mkdir -p dist/macos-app/release dist/macos-app/signatures + cp "${dmgs[0]}" "dist/macos-app/release/OpenClaw-${version}-darwin-aarch64.dmg" + cp "${archives[0]}" \ + "dist/macos-app/release/OpenClaw-${version}-darwin-aarch64.app.tar.gz" + cp "${archives[0]}.sig" \ + "dist/macos-app/signatures/OpenClaw-${version}-darwin-aarch64.app.tar.gz.sig" + + - name: Upload macOS test bundles + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: macos-app-release + path: dist/macos-app + if-no-files-found: error + + build_windows: + name: Build unsigned Windows test bundle + if: ${{ inputs['desktop-test-bundles'] }} + needs: validate_release + runs-on: windows-2022 + timeout-minutes: 45 + steps: + - name: Checkout selected tag + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ needs.validate_release.outputs.tag_sha }} + persist-credentials: false + + - name: Install Rust + run: rustup toolchain install stable --profile minimal + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + install-deps: "false" + + - name: Build Windows test bundle + working-directory: apps/linux/src-tauri + shell: bash + env: + RELEASE_TAG: ${{ inputs.tag }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + set -euo pipefail + version="${RELEASE_TAG#v}" + pnpm dlx @tauri-apps/cli@2.11.4 build --bundles nsis \ + --config "{\"version\":\"${version}\"}" + + - name: Verify and rename Windows bundle + shell: pwsh + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + $ErrorActionPreference = "Stop" + $version = $env:RELEASE_TAG.Substring(1) + $installers = @(Get-ChildItem "apps/linux/src-tauri/target/release/bundle/nsis/*.exe") + if ($installers.Count -ne 1) { + throw "Expected one NSIS installer; found $($installers.Count)" + } + $installer = $installers[0] + $signature = "$($installer.FullName).sig" + if (-not (Test-Path -LiteralPath $signature)) { + throw "Missing NSIS updater signature: $signature" + } + if (-not $installer.VersionInfo.ProductVersion.StartsWith($version)) { + throw "Windows bundle version '$($installer.VersionInfo.ProductVersion)' does not match release version '$version'" + } + New-Item -ItemType Directory -Force -Path "dist/windows-app/release", "dist/windows-app/signatures" | Out-Null + Copy-Item -LiteralPath $installer.FullName -Destination "dist/windows-app/release/OpenClaw-$version-windows-x86_64.exe" + Copy-Item -LiteralPath $signature -Destination "dist/windows-app/signatures/OpenClaw-$version-windows-x86_64.exe.sig" + + - name: Upload Windows test bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: windows-app-release + path: dist/windows-app + if-no-files-found: error + + publish: + name: Publish companion bundles and updater manifest + if: >- + ${{ + always() && + needs.build_linux.result == 'success' && + (!inputs['desktop-test-bundles'] || + (needs.build_macos.result == 'success' && needs.build_windows.result == 'success')) + }} + needs: + - validate_release + - build_linux + - build_macos + - build_windows + # One shared desktop-test channel asset must not race across release tags. + concurrency: + group: linux-app-release-publish + cancel-in-progress: false + runs-on: ubuntu-22.04 + timeout-minutes: 10 + steps: + - name: Download Linux bundles + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: linux-app-release + path: dist/input/linux + + - name: Download macOS test bundles + if: ${{ inputs['desktop-test-bundles'] }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: macos-app-release + path: dist/input/macos + + - name: Download Windows test bundle + if: ${{ inputs['desktop-test-bundles'] }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: windows-app-release + path: dist/input/windows + + - name: Assemble release assets and updater manifest + env: + DESKTOP_TEST_BUNDLES: ${{ inputs['desktop-test-bundles'] }} GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.tag }} run: | set -euo pipefail version="${RELEASE_TAG#v}" - # The signature is over the AppImage bytes, so renaming the file does - # not invalidate it. The committed pubkey verifies it in the app. - sig_file=$(ls apps/linux/src-tauri/target/release/bundle/appimage/*.AppImage.sig) - signature=$(cat "${sig_file}") + mkdir -p dist/release + cp dist/input/linux/release/* dist/release/ + if [[ "${DESKTOP_TEST_BUNDLES}" == "true" ]]; then + cp dist/input/macos/release/* dist/release/ + cp dist/input/windows/release/* dist/release/ + fi + + # Generate this before latest.json so it covers only downloadable bundles. + (cd dist/release && sha256sum ./* > SHA256SUMS.linux-app.txt) + cat dist/release/SHA256SUMS.linux-app.txt + + linux_signature=$(cat "dist/input/linux/signatures/OpenClaw-${version}-amd64.AppImage.sig") + url_base="https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}" pub_date=$(date -u +%Y-%m-%dT%H:%M:%SZ) # Capture the full body (no early-closing pipe under pipefail), then # truncate to 2000 Unicode chars inside jq so we never split a # multibyte character or SIGPIPE the release-view command. notes=$(gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --json body --jq '.body // ""') - url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/OpenClaw-${version}-amd64.AppImage" + # latest.json is always the stable Linux channel. Desktop test builds + # use a separate manifest that Linux-only releases leave untouched. jq -n \ --arg version "${version}" \ --arg notes "${notes}" \ --arg pub_date "${pub_date}" \ - --arg signature "${signature}" \ - --arg url "${url}" \ - '{version: $version, notes: ($notes | .[0:2000]), pub_date: $pub_date, platforms: {"linux-x86_64": {signature: $signature, url: $url}}}' \ - > dist/linux-app/latest.json - cat dist/linux-app/latest.json + --arg linux_signature "${linux_signature}" \ + --arg linux_url "${url_base}/OpenClaw-${version}-amd64.AppImage" \ + '{version: $version, notes: ($notes | .[0:2000]), pub_date: $pub_date, platforms: {"linux-x86_64": {signature: $linux_signature, url: $linux_url}}}' \ + > dist/release/latest.json + cat dist/release/latest.json + + if [[ "${DESKTOP_TEST_BUNDLES}" == "true" ]]; then + macos_signature=$(cat "dist/input/macos/signatures/OpenClaw-${version}-darwin-aarch64.app.tar.gz.sig") + windows_signature=$(cat "dist/input/windows/signatures/OpenClaw-${version}-windows-x86_64.exe.sig") + jq -n \ + --arg version "${version}" \ + --arg notes "${notes}" \ + --arg pub_date "${pub_date}" \ + --arg macos_signature "${macos_signature}" \ + --arg macos_url "${url_base}/OpenClaw-${version}-darwin-aarch64.app.tar.gz" \ + --arg windows_signature "${windows_signature}" \ + --arg windows_url "${url_base}/OpenClaw-${version}-windows-x86_64.exe" \ + '{version: $version, notes: ($notes | .[0:2000]), pub_date: $pub_date, platforms: {"darwin-aarch64": {signature: $macos_signature, url: $macos_url}, "windows-x86_64": {signature: $windows_signature, url: $windows_url}}}' \ + > dist/release/latest-desktop-test.json + cat dist/release/latest-desktop-test.json + fi - name: Attach bundles to the release env: @@ -159,4 +396,47 @@ jobs: gh release upload "${RELEASE_TAG}" \ --repo "${GITHUB_REPOSITORY}" \ --clobber \ - dist/linux-app/* + dist/release/* + + - name: Publish desktop test update channel + if: ${{ inputs['desktop-test-bundles'] }} + env: + DESKTOP_TEST_CHANNEL_TAG: desktop-test + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + TAG_SHA: ${{ needs.validate_release.outputs.tag_sha }} + run: | + set -euo pipefail + channel_dir="${RUNNER_TEMP}/desktop-test-channel" + candidate_version="${RELEASE_TAG#v}" + mkdir -p "${channel_dir}" + + if gh release view "${DESKTOP_TEST_CHANNEL_TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + has_manifest=$(gh release view "${DESKTOP_TEST_CHANNEL_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json assets \ + --jq '[.assets[].name] | index("latest-desktop-test.json") != null') + if [[ "${has_manifest}" == "true" ]]; then + gh release download "${DESKTOP_TEST_CHANNEL_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern latest-desktop-test.json \ + --dir "${channel_dir}" + current_version=$(jq -er '.version | strings' "${channel_dir}/latest-desktop-test.json") + newest_version=$(printf '%s\n' "${current_version}" "${candidate_version}" | LC_ALL=C sort -V | tail -n 1) + if [[ "${newest_version}" != "${candidate_version}" ]]; then + echo "Desktop test channel is already newer (${current_version}); leaving it unchanged." + exit 0 + fi + fi + else + gh release create "${DESKTOP_TEST_CHANNEL_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --target "${TAG_SHA}" \ + --prerelease \ + --title "OpenClaw desktop test update channel" \ + --notes "Opt-in updater manifest for unsigned macOS and Windows Tauri test builds." + fi + gh release upload "${DESKTOP_TEST_CHANNEL_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --clobber \ + dist/release/latest-desktop-test.json diff --git a/apps/linux/README.md b/apps/linux/README.md index fb2829e7357f..7745ea4f5912 100644 --- a/apps/linux/README.md +++ b/apps/linux/README.md @@ -30,7 +30,7 @@ On first run, release builds automatically install the stable CLI channel, while ## Updates -The companion checks the latest GitHub release shortly after launch and from **Check for Updates** in the tray menu. AppImage installs download and verify the signed update in place, then wait for **Restart to update**. Package-managed installs such as `.deb` stay owned by the system package manager and link to the release download page instead of replacing installed files. +The companion checks the latest GitHub release shortly after launch and from **Check for Updates** in the tray menu. AppImage installs download and verify the signed update in place, then wait for **Restart to update**. Package-managed installs such as `.deb` stay owned by the system package manager and link to the release download page instead of replacing installed files. The macOS and Windows test builds use a separate opt-in desktop-test update channel; macOS self-updates like the AppImage build, while Windows downloads the update first and runs its installer only after **Restart to update**. ## Canvas bridge diff --git a/apps/linux/src-tauri/Cargo.toml b/apps/linux/src-tauri/Cargo.toml index 1b3a5087410b..03042c6aa7c5 100644 --- a/apps/linux/src-tauri/Cargo.toml +++ b/apps/linux/src-tauri/Cargo.toml @@ -10,7 +10,7 @@ name = "openclaw-desktop" path = "src/main.rs" [build-dependencies] -tauri-build = "2.6.3" +tauri-build = { version = "2.6.3", features = [] } [dependencies] base64 = "0.22.1" diff --git a/apps/linux/src-tauri/src/updater.rs b/apps/linux/src-tauri/src/updater.rs index a0d6e12aad2c..649d403284c9 100644 --- a/apps/linux/src-tauri/src/updater.rs +++ b/apps/linux/src-tauri/src/updater.rs @@ -1,11 +1,11 @@ use serde::Serialize; use std::ffi::OsString; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::Duration; use tauri::{AppHandle, Emitter, Manager, WebviewWindow}; use tauri_plugin_opener::OpenerExt; -use tauri_plugin_updater::UpdaterExt; +use tauri_plugin_updater::{Update, UpdaterExt}; pub(crate) const NOT_AVAILABLE_EVENT: &str = "updater://not-available"; pub(crate) const AVAILABLE_EVENT: &str = "updater://available"; @@ -15,18 +15,33 @@ pub(crate) const READY_EVENT: &str = "updater://ready"; pub(crate) const ERROR_EVENT: &str = "updater://error"; const RELEASE_URL: &str = "https://github.com/openclaw/openclaw/releases/latest"; +#[cfg(any(target_os = "macos", target_os = "windows"))] +// Test desktop builds need a channel that Linux-only releases never replace. +const DESKTOP_TEST_UPDATE_ENDPOINT: &str = + "https://github.com/openclaw/openclaw/releases/download/desktop-test/latest-desktop-test.json"; const AUTO_CHECK_DELAY: Duration = Duration::from_secs(3); #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum InstallKind { - AppImage, - SystemPackage, + SelfInstall, + DeferredInstall, + NotifyOnly, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +// Keep every discriminator available so one host can test all platform rules. +#[allow(dead_code)] +enum Platform { + Linux, + Macos, + Windows, } #[derive(Default)] pub struct UpdaterState { auto_check_started: AtomicBool, check_in_progress: Arc, + deferred_update: Mutex>, // Set when a manual (tray/command) check is requested. The one in-flight // check reads this at emit time so a manual click that lands while the // silent startup auto-check is running still surfaces a result instead of @@ -34,6 +49,11 @@ pub struct UpdaterState { manual_pending: Arc, } +struct DeferredUpdate { + update: Update, + bytes: Vec, +} + struct CheckGuard { in_progress: Arc, manual_pending: Arc, @@ -103,7 +123,28 @@ pub fn updater_ready(app: AppHandle) { #[tauri::command] pub fn relaunch(app: AppHandle) { - app.restart(); + let state = app.state::(); + let deferred = state + .deferred_update + .lock() + .expect("deferred updater state lock poisoned") + .take(); + let Some(deferred) = deferred else { + app.restart(); + }; + + let result = deferred.update.install(&deferred.bytes); + match result { + Ok(()) => app.restart(), + Err(error) => { + state + .deferred_update + .lock() + .expect("deferred updater state lock poisoned") + .replace(deferred); + emit_error(&app, error); + } + } } #[tauri::command] @@ -129,7 +170,16 @@ async fn run_check(app: AppHandle, manual: bool) { return; }; let should_notify = || manual_pending.load(Ordering::Acquire); - let updater = match app.updater() { + #[cfg(target_os = "linux")] + let updater = app.updater(); + #[cfg(any(target_os = "macos", target_os = "windows"))] + let updater = app + .updater_builder() + .endpoints(vec![DESKTOP_TEST_UPDATE_ENDPOINT + .parse() + .expect("desktop test updater endpoint is valid")]) + .and_then(|builder| builder.build()); + let updater = match updater { Ok(updater) => updater, Err(error) => { if should_notify() { @@ -158,7 +208,8 @@ async fn run_check(app: AppHandle, manual: bool) { notes: update.body.clone(), }; - if install_kind() == InstallKind::SystemPackage { + let install_kind = install_kind(); + if install_kind == InstallKind::NotifyOnly { emit( &app, AVAILABLE_MANUAL_EVENT, @@ -175,19 +226,26 @@ async fn run_check(app: AppHandle, manual: bool) { let Some(window) = main_window(&app) else { return; }; - let progress_window = window.clone(); - let mut downloaded = 0_u64; - let result = update - .download_and_install( - move |chunk_size, total| { - downloaded = downloaded.saturating_add(chunk_size as u64); - let _ = progress_window.emit(PROGRESS_EVENT, Progress { downloaded, total }); - }, - || {}, - ) - .await; + let result = match install_kind { + InstallKind::SelfInstall => update + .download_and_install(progress_callback(window.clone()), || {}) + .await + .map(|()| None), + InstallKind::DeferredInstall => update + .download(progress_callback(window.clone()), || {}) + .await + .map(Some), + InstallKind::NotifyOnly => unreachable!("notify-only updates return before downloading"), + }; match result { - Ok(()) => { + Ok(deferred_bytes) => { + if let Some(bytes) = deferred_bytes { + app.state::() + .deferred_update + .lock() + .expect("deferred updater state lock poisoned") + .replace(DeferredUpdate { update, bytes }); + } let _ = window.emit(READY_EVENT, info); } Err(error) => emit_error(&app, error), @@ -208,15 +266,31 @@ fn begin_check(app: &AppHandle) -> Option { } fn install_kind() -> InstallKind { - install_kind_from_appimage_env(std::env::var_os("APPIMAGE")) + #[cfg(target_os = "linux")] + let platform = Platform::Linux; + #[cfg(target_os = "macos")] + let platform = Platform::Macos; + #[cfg(target_os = "windows")] + let platform = Platform::Windows; + + install_kind_from_appimage_env(std::env::var_os("APPIMAGE"), platform) } -fn install_kind_from_appimage_env(appimage: Option) -> InstallKind { - if appimage.is_some() { - InstallKind::AppImage - } else { - // Package managers own deb/rpm installs; replacing their files would corrupt that contract. - InstallKind::SystemPackage +fn install_kind_from_appimage_env(appimage: Option, platform: Platform) -> InstallKind { + match platform { + Platform::Linux if appimage.is_some() => InstallKind::SelfInstall, + Platform::Linux => { + // Package managers own deb/rpm files, so replacing them would corrupt their contract. + InstallKind::NotifyOnly + } + Platform::Macos => { + // Tauri owns .app replacement and returns after installing, like the AppImage path. + InstallKind::SelfInstall + } + Platform::Windows => { + // Tauri's NSIS install exits the process, so wait for user-confirmed relaunch. + InstallKind::DeferredInstall + } } } @@ -224,6 +298,14 @@ fn main_window(app: &AppHandle) -> Option { app.get_webview_window("main") } +fn progress_callback(window: WebviewWindow) -> impl FnMut(usize, Option) { + let mut downloaded = 0_u64; + move |chunk_size, total| { + downloaded = downloaded.saturating_add(chunk_size as u64); + let _ = window.emit(PROGRESS_EVENT, Progress { downloaded, total }); + } +} + fn emit(app: &AppHandle, event: &str, payload: S) { if let Some(window) = main_window(app) { let _ = window.emit(event, payload); @@ -245,14 +327,25 @@ mod tests { use super::*; #[test] - fn install_kind_follows_appimage_env_presence() { + fn install_kind_covers_every_platform_path() { assert_eq!( - install_kind_from_appimage_env(None), - InstallKind::SystemPackage + install_kind_from_appimage_env(None, Platform::Linux), + InstallKind::NotifyOnly ); assert_eq!( - install_kind_from_appimage_env(Some(OsString::from("/tmp/OpenClaw.AppImage"))), - InstallKind::AppImage + install_kind_from_appimage_env( + Some(OsString::from("/tmp/OpenClaw.AppImage")), + Platform::Linux, + ), + InstallKind::SelfInstall + ); + assert_eq!( + install_kind_from_appimage_env(None, Platform::Macos), + InstallKind::SelfInstall + ); + assert_eq!( + install_kind_from_appimage_env(None, Platform::Windows), + InstallKind::DeferredInstall ); }