diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index cb12a12d7aa5..d51cfb7d06a9 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -8,7 +8,15 @@ ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" source "$ROOT_DIR/scripts/lib/plistbuddy.sh" source "$ROOT_DIR/scripts/lib/swift-toolchain.sh" source "$ROOT_DIR/scripts/lib/build-metadata.sh" -APP_ROOT="$ROOT_DIR/dist/OpenClaw.app" +DEFAULT_APP_ROOT="$ROOT_DIR/dist/OpenClaw.app" +APP_ROOT="${OPENCLAW_PACKAGE_APP_ROOT:-$DEFAULT_APP_ROOT}" +case "$APP_ROOT" in + "$ROOT_DIR/dist/"*) ;; + *) + echo "ERROR: OPENCLAW_PACKAGE_APP_ROOT must stay under $ROOT_DIR/dist" >&2 + exit 1 + ;; +esac BUILD_ROOT="$ROOT_DIR/apps/macos/.build" PRODUCT="OpenClaw" MLX_TTS_HELPER_PRODUCT="openclaw-mlx-tts" diff --git a/scripts/restart-mac.sh b/scripts/restart-mac.sh index d59c1fcba733..f07010a2ccaf 100755 --- a/scripts/restart-mac.sh +++ b/scripts/restart-mac.sh @@ -27,6 +27,8 @@ TARGET_ONLY=0 TARGET_APP_BUNDLE="${ROOT_DIR}/dist/OpenClaw.app" TARGET_EXECUTABLE="${TARGET_APP_BUNDLE}/${APP_EXECUTABLE_RELATIVE_PATH}" INSTALLED_EXECUTABLE="/Applications/OpenClaw.app/${APP_EXECUTABLE_RELATIVE_PATH}" +STAGED_APP_DIR="${ROOT_DIR}/dist/.openclaw-replacement-${LOCK_KEY}-$$" +STAGED_APP_BUNDLE="${STAGED_APP_DIR}/OpenClaw.app" log() { printf '%s\n' "$*"; } fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } @@ -43,6 +45,9 @@ run_step() { } cleanup() { + if [[ -n "${STAGED_APP_DIR:-}" ]]; then + rm -rf "${STAGED_APP_DIR}" + fi if [[ "${LOCK_HELD}" != "1" || ! -d "${LOCK_DIR}" ]]; then return 0 fi @@ -314,8 +319,28 @@ elif [ "$SIGN" -eq 1 ]; then unset SIGN_IDENTITY fi -# 3) Package app (no embedded gateway). -run_step "package app" bash -lc "cd '${ROOT_DIR}' && SKIP_TSC=${SKIP_TSC:-1} '${ROOT_DIR}/scripts/package-mac-app.sh'" +# 3) Package and sign outside the live bundle. A failed package/sign operation +# must leave the currently running and on-disk app untouched. +run_step "package app" env \ + SKIP_TSC="${SKIP_TSC:-1}" \ + OPENCLAW_PACKAGE_APP_ROOT="${STAGED_APP_BUNDLE}" \ + "${ROOT_DIR}/scripts/package-mac-app.sh" +run_step "verify packaged app" /usr/bin/codesign --verify --deep --strict "${STAGED_APP_BUNDLE}" + +install_staged_app() { + local previous="${ROOT_DIR}/dist/.OpenClaw.app.previous-$$" + rm -rf "${previous}" + if [[ -d "${TARGET_APP_BUNDLE}" ]]; then + mv "${TARGET_APP_BUNDLE}" "${previous}" + fi + if ! mv "${STAGED_APP_BUNDLE}" "${TARGET_APP_BUNDLE}"; then + if [[ -d "${previous}" && ! -d "${TARGET_APP_BUNDLE}" ]]; then + mv "${previous}" "${TARGET_APP_BUNDLE}" + fi + return 1 + fi + rm -rf "${previous}" "${STAGED_APP_DIR}" +} choose_app_bundle() { if [[ -n "${APP_BUNDLE}" ]]; then @@ -339,8 +364,6 @@ choose_app_bundle() { fail "App bundle not found. Set OPENCLAW_APP_BUNDLE to your installed OpenClaw.app" } -choose_app_bundle - # When signed, clear any previous launchagent override marker. if [[ "$NO_SIGN" -ne 1 && "$ATTACH_ONLY" -ne 1 && -f "${LAUNCHAGENT_DISABLE_MARKER}" ]]; then run_step "clear launchagent disable marker" /bin/rm -f "${LAUNCHAGENT_DISABLE_MARKER}" @@ -386,6 +409,9 @@ if [[ "$TARGET_ONLY" -eq 1 ]]; then fi fi +run_step "install packaged app" install_staged_app +choose_app_bundle + # 4) Launch the installed app in the foreground so the menu bar extra appears. # LaunchServices can inherit a huge environment from this shell (secrets, prompt vars, etc.). # That can cause launchd spawn failures and is undesirable for a GUI app anyway. diff --git a/test/scripts/restart-mac.test.ts b/test/scripts/restart-mac.test.ts index 5e16871cc43c..30d75744eaed 100644 --- a/test/scripts/restart-mac.test.ts +++ b/test/scripts/restart-mac.test.ts @@ -395,14 +395,32 @@ describe("scripts/restart-mac.sh", () => { it("keeps the managed app alive until the signed replacement is ready", () => { const script = readFileSync(restartScriptPath, "utf8"); const packageIndex = script.indexOf('run_step "package app"'); + const verifyIndex = script.indexOf('run_step "verify packaged app"'); const switchIndex = script.indexOf('log "==> Switching managed installed'); + const installIndex = script.indexOf('run_step "install packaged app"'); const launchIndex = script.indexOf('run_step "launch app"'); expect(packageIndex).toBeGreaterThan(-1); + expect(script).toContain('OPENCLAW_PACKAGE_APP_ROOT="${STAGED_APP_BUNDLE}"'); + expect(verifyIndex).toBeGreaterThan(packageIndex); expect(switchIndex).toBeGreaterThan(packageIndex); + expect(installIndex).toBeGreaterThan(switchIndex); + expect(launchIndex).toBeGreaterThan(installIndex); expect(launchIndex).toBeGreaterThan(switchIndex); }); + it("restores the previous bundle if the staged install cannot complete", () => { + const script = readFileSync(restartScriptPath, "utf8"); + const installBlock = script.slice( + script.indexOf("install_staged_app()"), + script.indexOf("choose_app_bundle()"), + ); + + expect(installBlock).toContain('mv "${TARGET_APP_BUNDLE}" "${previous}"'); + expect(installBlock).toContain('if ! mv "${STAGED_APP_BUNDLE}" "${TARGET_APP_BUNDLE}"'); + expect(installBlock).toContain('mv "${previous}" "${TARGET_APP_BUNDLE}"'); + }); + it("escalates only exact managed app processes when graceful shutdown stalls", () => { const script = readFileSync(restartScriptPath, "utf8"); const managedKillBlock = script.slice(