From 390bc4e562e9fee6d5081f6017b8264d4e59bcd4 Mon Sep 17 00:00:00 2001 From: Peter Jausovec Date: Mon, 24 Aug 2026 19:27:24 +0200 Subject: [PATCH] implement upgrade-safe installers Signed-off-by: Peter Jausovec --- .github/workflows/release.yml | 121 ++++++++++++++---- crates/agentdesktop/macos/scripts/postinstall | 39 +++++- crates/agentdesktop/macos/scripts/preinstall | 120 +++++++++++++++++ crates/agentdesktop/windows/installer.wxs | 14 +- frontend/desktop/MACOS_INSTALLER.md | 21 +++ frontend/desktop/WINDOWS_INSTALLER.md | 23 ++++ .../desktop/scripts/package-macos.test.mjs | 20 ++- .../desktop/scripts/package-windows.test.mjs | 41 ++++++ 8 files changed, 369 insertions(+), 30 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8acc2f40..ad9fa32e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -391,21 +391,48 @@ jobs: run: | (cd release && shasum -a 256 "${OUTPUT_NAME}" > "${OUTPUT_NAME}.sha256") - - name: Smoke test PKG + - name: Smoke test PKG install and replacement shell: bash env: OUTPUT_NAME: agentdesktop-macos-${{ matrix.arch }}.pkg run: | + wait_for_daemon() { + deadline=$((SECONDS + 30)) + until sudo "/Applications/agentdesktop.app/Contents/MacOS/agentdesktop" status; do + if (( SECONDS >= deadline )); then + echo "Installed CLI could not reach the Agent Desktop daemon" >&2 + return 1 + fi + sleep 1 + done + } + + daemon_pid() { + sudo launchctl print system/dev.agentdesktop.daemon | + awk '$1 == "pid" && $2 == "=" { print $3; exit }' + } + sudo installer -pkg "release/${OUTPUT_NAME}" -target / sudo launchctl print system/dev.agentdesktop.daemon > /dev/null - deadline=$((SECONDS + 30)) - until sudo "/Applications/agentdesktop.app/Contents/MacOS/agentdesktop" status; do - if (( SECONDS >= deadline )); then - echo "Installed CLI could not reach the Agent Desktop daemon" >&2 - exit 1 - fi - sleep 1 - done + wait_for_daemon + + first_pid="$(daemon_pid)" + test -n "${first_pid}" + config_hash="$(sudo shasum -a 256 /etc/agentdesktop/config.yaml | awk '{ print $1 }')" + sudo touch /var/lib/agentdesktop/upgrade-smoke-marker + + sudo installer -pkg "release/${OUTPUT_NAME}" -target / + sudo launchctl print system/dev.agentdesktop.daemon > /dev/null + wait_for_daemon + + second_pid="$(daemon_pid)" + test -n "${second_pid}" + if [ "${first_pid}" = "${second_pid}" ]; then + echo "Agent Desktop daemon was not restarted during package replacement" >&2 + exit 1 + fi + test "${config_hash}" = "$(sudo shasum -a 256 /etc/agentdesktop/config.yaml | awk '{ print $1 }')" + sudo test -f /var/lib/agentdesktop/upgrade-smoke-marker - name: Remove smoke-test installation if: always() @@ -415,6 +442,7 @@ jobs: sudo rm -f /Library/LaunchDaemons/dev.agentdesktop.daemon.plist sudo rm -rf "/Applications/agentdesktop.app" sudo rm -rf /etc/agentdesktop /var/lib/agentdesktop /var/run/agentdesktop + sudo rm -f /var/run/agentdesktop-relaunch sudo pkgutil --forget dev.agentdesktop.installer || true - name: Upload unsigned PKG @@ -503,33 +531,72 @@ jobs: $hash = (Get-FileHash "release/$env:OUTPUT_NAME" -Algorithm SHA256).Hash.ToLowerInvariant() "$hash $env:OUTPUT_NAME" | Set-Content "release/$env:OUTPUT_NAME.sha256" -Encoding ascii - - name: Smoke test MSI + - name: Smoke test MSI install and replacement shell: pwsh env: OUTPUT_NAME: agentdesktop-windows-${{ matrix.arch }}.msi run: | + function Assert-MsiSuccess($process, $operation) { + if ($process.ExitCode -notin @(0, 3010)) { + throw "MSI $operation failed with exit code $($process.ExitCode)" + } + } + + function Wait-ForAgentDesktopService { + $service = Get-Service AgentDesktop + $service.WaitForStatus("Running", [TimeSpan]::FromSeconds(30)) + $deadline = (Get-Date).AddSeconds(30) + do { + & "$env:ProgramFiles\Agent Desktop\agentdesktop.exe" status + if ($LASTEXITCODE -eq 0) { + return + } + Start-Sleep -Seconds 1 + } while ((Get-Date) -lt $deadline) + throw "Installed CLI could not reach the Agent Desktop service" + } + $installer = (Resolve-Path "release/$env:OUTPUT_NAME").Path $install = Start-Process msiexec.exe -ArgumentList @("/i", "`"$installer`"", "/qn", "/norestart") -Wait -PassThru - if ($install.ExitCode -ne 0) { - throw "MSI installation failed with exit code $($install.ExitCode)" + Assert-MsiSuccess $install "installation" + Wait-ForAgentDesktopService + + $configPath = "$env:ProgramData\AgentDesktop\config.yaml" + $stateMarker = "$env:ProgramData\AgentDesktop\state\upgrade-smoke-marker" + $configHash = (Get-FileHash $configPath -Algorithm SHA256).Hash + New-Item -ItemType Directory -Force (Split-Path $stateMarker) | Out-Null + New-Item -ItemType File -Force $stateMarker | Out-Null + + $tray = Start-Process "$env:ProgramFiles\Agent Desktop\agentdesktop.exe" -PassThru + Start-Sleep -Seconds 3 + if ($tray.HasExited) { + throw "Agent Desktop tray process exited before replacement" } - $service = Get-Service AgentDesktop - $service.WaitForStatus("Running", [TimeSpan]::FromSeconds(30)) - $deadline = (Get-Date).AddSeconds(30) - do { - & "$env:ProgramFiles\Agent Desktop\agentdesktop.exe" status - if ($LASTEXITCODE -eq 0) { - break - } - Start-Sleep -Seconds 1 - } while ((Get-Date) -lt $deadline) - if ($LASTEXITCODE -ne 0) { - throw "Installed CLI could not reach the Agent Desktop service" + + $replace = Start-Process msiexec.exe -ArgumentList @( + "/i", + "`"$installer`"", + "/qn", + "/norestart", + "REINSTALL=ALL", + "REINSTALLMODE=vomus" + ) -Wait -PassThru + Assert-MsiSuccess $replace "replacement" + $tray.Refresh() + if (-not $tray.HasExited) { + throw "Agent Desktop tray process remains after replacement" } - $uninstall = Start-Process msiexec.exe -ArgumentList @("/x", "`"$installer`"", "/qn", "/norestart") -Wait -PassThru - if ($uninstall.ExitCode -ne 0) { - throw "MSI uninstall failed with exit code $($uninstall.ExitCode)" + + Wait-ForAgentDesktopService + if ((Get-FileHash $configPath -Algorithm SHA256).Hash -ne $configHash) { + throw "Agent Desktop configuration changed during replacement" } + if (-not (Test-Path $stateMarker)) { + throw "Agent Desktop state was removed during replacement" + } + + $uninstall = Start-Process msiexec.exe -ArgumentList @("/x", "`"$installer`"", "/qn", "/norestart") -Wait -PassThru + Assert-MsiSuccess $uninstall "uninstall" if (Get-Service AgentDesktop -ErrorAction SilentlyContinue) { throw "Agent Desktop service remains after uninstall" } diff --git a/crates/agentdesktop/macos/scripts/postinstall b/crates/agentdesktop/macos/scripts/postinstall index 66fbcd76..33c3acda 100755 --- a/crates/agentdesktop/macos/scripts/postinstall +++ b/crates/agentdesktop/macos/scripts/postinstall @@ -8,6 +8,8 @@ PLIST_PATH="/Library/LaunchDaemons/${LABEL}.plist" CONFIG_DIRECTORY="/etc/agentdesktop" CONFIG_PATH="${CONFIG_DIRECTORY}/config.yaml" STATE_DIRECTORY="/var/lib/agentdesktop" +APPLICATION="/Applications/agentdesktop.app" +RELAUNCH_MARKER="/var/run/agentdesktop-relaunch" add_user_to_group() { username="$1" @@ -29,6 +31,39 @@ remove_user_launch_agent() { fi } +relaunch_application_for_user() { + uid="$1" + case "${uid}" in + "" | *[!0-9]*) + echo "warning: invalid Agent Desktop relaunch user" >&2 + return + ;; + esac + + username=$(/usr/bin/id -nu "${uid}" 2>/dev/null || true) + if [ -z "${username}" ] || ! /bin/launchctl print "gui/${uid}" >/dev/null 2>&1; then + echo "warning: cannot relaunch Agent Desktop because user ${uid} is no longer logged in" >&2 + return + fi + + if ! /bin/launchctl asuser "${uid}" /usr/bin/sudo -u "${username}" \ + /usr/bin/open -g "${APPLICATION}"; then + echo "warning: could not relaunch Agent Desktop for user ${uid}" >&2 + fi +} + +relaunch_applications() { + if [ ! -f "${RELAUNCH_MARKER}" ]; then + return + fi + + user_ids=$(/bin/cat "${RELAUNCH_MARKER}" 2>/dev/null || true) + /bin/rm -f "${RELAUNCH_MARKER}" + for uid in ${user_ids}; do + relaunch_application_for_user "${uid}" + done +} + if ! /usr/sbin/dseditgroup -q -o read -n . "${GROUP_NAME}" >/dev/null 2>&1; then /usr/sbin/dseditgroup -q -o create -n . \ -r "Agent Desktop local API users" "${GROUP_NAME}" @@ -70,4 +105,6 @@ fi /bin/launchctl enable "system/${LABEL}" /bin/launchctl kickstart -k "system/${LABEL}" -exit 0 \ No newline at end of file +relaunch_applications + +exit 0 diff --git a/crates/agentdesktop/macos/scripts/preinstall b/crates/agentdesktop/macos/scripts/preinstall index 178076eb..ae04586b 100755 --- a/crates/agentdesktop/macos/scripts/preinstall +++ b/crates/agentdesktop/macos/scripts/preinstall @@ -2,10 +2,130 @@ set -eu LABEL="dev.agentdesktop.daemon" +USER_LAUNCH_AGENT_LABEL="dev.agentdesktop.daemon.user" +APPLICATION="/Applications/agentdesktop.app" LEGACY_APPLICATION="/Applications/Agent Desktop.app" +RELAUNCH_MARKER="/var/run/agentdesktop-relaunch" + +remove_user_launch_agent() { + username="$1" + uid=$(/usr/bin/id -u "${username}" 2>/dev/null || true) + if [ -n "${uid}" ]; then + /bin/launchctl bootout "gui/${uid}/${USER_LAUNCH_AGENT_LABEL}" >/dev/null 2>&1 || true + fi + home_directory=$(/usr/bin/dscl . -read "/Users/${username}" NFSHomeDirectory 2>/dev/null | + /usr/bin/cut -d ' ' -f 2- || true) + if [ -n "${home_directory}" ]; then + /bin/rm -f "${home_directory}/Library/LaunchAgents/${USER_LAUNCH_AGENT_LABEL}.plist" + fi +} + +bundle_executable() { + application="$1" + if [ -d "${application}" ]; then + executable_name=$(/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" \ + "${application}/Contents/Info.plist" 2>/dev/null || true) + if [ -n "${executable_name}" ]; then + printf '%s/Contents/MacOS/%s\n' "${application}" "${executable_name}" + fi + fi + return 0 +} + +application_pids() { + executable="$1" + /bin/ps -axo pid=,command= | /usr/bin/awk -v executable="${executable}" ' + { + pid = $1 + $1 = "" + sub(/^[[:space:]]+/, "", $0) + if ($0 == executable || index($0, executable " -psn_") == 1) print pid + }' +} + +application_user_ids() { + executable="$1" + for pid in $(application_pids "${executable}"); do + uid=$(/bin/ps -p "${pid}" -o uid= 2>/dev/null | /usr/bin/awk '{ print $1 }') + case "${uid}" in + "" | *[!0-9]*) ;; + *) + if [ "${uid}" -ge 500 ] && [ "${uid}" -lt 65534 ]; then + printf '%s\n' "${uid}" + fi + ;; + esac + done +} + +stop_application() { + executable="$1" + pids=$(application_pids "${executable}") + if [ -z "${pids}" ]; then + return + fi + + for pid in ${pids}; do + /bin/kill -TERM "${pid}" >/dev/null 2>&1 || true + done + + attempt=0 + while [ "${attempt}" -lt 10 ]; do + if [ -z "$(application_pids "${executable}")" ]; then + return + fi + /bin/sleep 1 + attempt=$((attempt + 1)) + done + + for pid in $(application_pids "${executable}"); do + /bin/kill -KILL "${pid}" >/dev/null 2>&1 || true + done +} + +/bin/rm -f "${RELAUNCH_MARKER}" + +console_user=$(/usr/bin/stat -f "%Su" /dev/console) +console_uid="" +case "${console_user}" in + "" | root | loginwindow | _mbsetupuser) ;; + *) console_uid=$(/usr/bin/id -u "${console_user}" 2>/dev/null || true) ;; +esac + +relaunch_uids=$( + for application in "${APPLICATION}" "${LEGACY_APPLICATION}"; do + executable=$(bundle_executable "${application}") + if [ -n "${executable}" ]; then + application_user_ids "${executable}" + fi + done | /usr/bin/sort -un +) + +if [ -n "${relaunch_uids}" ]; then + ( + umask 077 + printf '%s\n' "${relaunch_uids}" >"${RELAUNCH_MARKER}" + ) +fi /bin/launchctl bootout "system/${LABEL}" >/dev/null 2>&1 || true +/usr/bin/dscl . -list /Users UniqueID | /usr/bin/awk '$2 >= 500 && $2 < 65534 { print $1 }' | + while IFS= read -r username; do + remove_user_launch_agent "${username}" + done + +if [ -n "${console_uid}" ]; then + remove_user_launch_agent "${console_user}" +fi + +for application in "${APPLICATION}" "${LEGACY_APPLICATION}"; do + executable=$(bundle_executable "${application}") + if [ -n "${executable}" ]; then + stop_application "${executable}" + fi +done + if [ -d "${LEGACY_APPLICATION}" ]; then legacy_identifier=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" \ "${LEGACY_APPLICATION}/Contents/Info.plist" 2>/dev/null || true) diff --git a/crates/agentdesktop/windows/installer.wxs b/crates/agentdesktop/windows/installer.wxs index 5206d9bd..95e13fd9 100644 --- a/crates/agentdesktop/windows/installer.wxs +++ b/crates/agentdesktop/windows/installer.wxs @@ -1,5 +1,7 @@ - + @@ -9,6 +11,16 @@ + + diff --git a/frontend/desktop/MACOS_INSTALLER.md b/frontend/desktop/MACOS_INSTALLER.md index 105caf5d..bec7bc24 100644 --- a/frontend/desktop/MACOS_INSTALLER.md +++ b/frontend/desktop/MACOS_INSTALLER.md @@ -62,6 +62,27 @@ If the application is distributed without the PKG, launching it registers a per-user LaunchAgent and starts the daemon in user mode. Installing the PKG later removes that fallback before starting the privileged service. +## Upgrade behavior + +The PKG owns process handling for in-place upgrades; MDM does not need a custom +process-kill script. Before replacing the application, `preinstall` stops the +system LaunchDaemon and any per-user fallback LaunchAgents, then asks a running +menu-bar application to terminate. If it does not exit within 10 seconds, the +installer terminates it so the bundle can be replaced safely. + +After installing the new payload, `postinstall` starts the system LaunchDaemon. +If the menu-bar application was open before the upgrade and its user is still +logged in, the installer relaunches the new version in that same user session. +This also handles multiple active sessions during fast user switching. An +application that was closed before the upgrade remains closed. + +Configuration under `/etc/agentdesktop` and state under `/var/lib/agentdesktop` +are preserved. A silent MDM upgrade can use the same command as installation: + +```sh +sudo installer -pkg "Agent Desktop.pkg" -target / +``` + ## Configure through MDM Deploy a root-owned configuration to `/etc/agentdesktop/config.yaml`. The diff --git a/frontend/desktop/WINDOWS_INSTALLER.md b/frontend/desktop/WINDOWS_INSTALLER.md index 1ffd27c5..09b7eb87 100644 --- a/frontend/desktop/WINDOWS_INSTALLER.md +++ b/frontend/desktop/WINDOWS_INSTALLER.md @@ -159,6 +159,29 @@ Get-Service AgentDesktop The service should report `Running`, and the status command should exit successfully. +## Upgrade behavior + +The MSI owns process handling for in-place upgrades; MDM does not need a +custom process-kill script. WiX asks every running `agentdesktop.exe` tray +process to close, waits up to 15 seconds, and terminates any remaining process +before replacing files. The existing `ServiceControl` entry stops the +`AgentDesktop` service, waits for shutdown, and starts the new service after the +upgrade. + +The MSI does not relaunch the tray application from an MDM deployment running +as `SYSTEM`, because that could put a GUI in the wrong user session. The tray +application starts at the user's next sign-in through its existing Run entry, or +the user can open it manually. Configuration and state under +`%ProgramData%\AgentDesktop` are preserved. + +Deploy upgrades silently with: + +```powershell +msiexec.exe /i .\agentdesktop.msi /qn /norestart +``` + +Treat exit code `0` as success and `3010` as success with a reboot required. + ## Configure the installed service The installer creates this default configuration: diff --git a/frontend/desktop/scripts/package-macos.test.mjs b/frontend/desktop/scripts/package-macos.test.mjs index 2788a798..bcb3dc97 100644 --- a/frontend/desktop/scripts/package-macos.test.mjs +++ b/frontend/desktop/scripts/package-macos.test.mjs @@ -34,6 +34,11 @@ test("package installs the app at a fixed location", () => { ); assert.match(component, /BundleIsRelocatable<\/key>\s*/); + assert.match(component, /BundleIsVersionChecked<\/key>\s*/); + assert.match( + component, + /BundleOverwriteAction<\/key>\s*upgrade<\/string>/, + ); assert.ok( component.includes("Applications/agentdesktop.app"), ); @@ -58,7 +63,7 @@ test("LaunchDaemon runs the bundled binary with system paths", () => { assert.match(plist, /KeepAlive<\/key>\s*/); }); -test("installer hooks preserve configuration and manage daemon access", () => { +test("installer hooks replace running processes without losing state", () => { const preinstall = readFileSync( path.join(macosDirectory, "scripts", "preinstall"), "utf8", @@ -69,6 +74,14 @@ test("installer hooks preserve configuration and manage daemon access", () => { ); assert.match(preinstall, /launchctl bootout/); + assert.match(preinstall, /dev\.agentdesktop\.daemon\.user/); + assert.match(preinstall, /launchctl bootout "gui\/\$\{uid\}/); + assert.match(preinstall, /application_pids/); + assert.match(preinstall, /application_user_ids/); + assert.match(preinstall, /sort -un/); + assert.match(preinstall, /kill -TERM/); + assert.match(preinstall, /kill -KILL/); + assert.match(preinstall, /agentdesktop-relaunch/); assert.match(preinstall, /\/Applications\/Agent Desktop\.app/); assert.match(preinstall, /dev\.agentdesktop\.tray/); assert.match(postinstall, /dseditgroup .* -o create/); @@ -79,4 +92,9 @@ test("installer hooks preserve configuration and manage daemon access", () => { assert.doesNotMatch(postinstall, /rm .*CONFIG_PATH/); assert.match(postinstall, /launchctl bootstrap system/); assert.match(postinstall, /launchctl kickstart -k/); + assert.match(postinstall, /agentdesktop-relaunch/); + assert.match(postinstall, /launchctl asuser/); + assert.match(postinstall, /sudo -u/); + assert.match(postinstall, /open -g/); + assert.match(postinstall, /for uid in \$\{user_ids\}/); }); diff --git a/frontend/desktop/scripts/package-windows.test.mjs b/frontend/desktop/scripts/package-windows.test.mjs index 56f231b0..84e46593 100644 --- a/frontend/desktop/scripts/package-windows.test.mjs +++ b/frontend/desktop/scripts/package-windows.test.mjs @@ -6,6 +6,25 @@ import { fileURLToPath } from "node:url"; import { createTauriVersionConfig } from "./package-windows-version.mjs"; +test("keeps a stable MSI upgrade identity", () => { + const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); + const config = JSON.parse( + readFileSync( + path.resolve( + scriptDirectory, + "../../../crates/agentdesktop/tauri.windows.conf.json", + ), + "utf8", + ), + ); + + assert.equal(config.bundle.windows.allowDowngrades, false); + assert.equal( + config.bundle.windows.wix.upgradeCode, + "b90e038c-7777-4aa6-ab02-9675fe051e83", + ); +}); + test("writes the Tauri release version to a temporary config file", () => { const config = createTauriVersionConfig("0.1.0"); @@ -42,3 +61,25 @@ test("uses Tauri-preserved environment variables in the WiX fragment", () => { `Tauri removes non-TAURI variables before running WiX: ${environmentVariables.join(", ")}`, ); }); + +test("MSI closes the tray app and restarts the service during upgrades", () => { + const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); + const fragmentPath = path.resolve( + scriptDirectory, + "../../../crates/agentdesktop/windows/installer.wxs", + ); + const fragment = readFileSync(fragmentPath, "utf8"); + + assert.match(fragment, /xmlns:util=.*UtilExtension/); + assert.match(fragment, /