fix(mac): opening General Settings crashes in packaged app (#111300)

* fix(macos): package KeyboardShortcuts resources

* fix(macos): keep dependency patch whitespace-safe

* fix(macos): preserve locked Swift resolution

* fix(macos): guard Swift lock across builds

* fix(macos): simplify KeyboardShortcuts packaging
This commit is contained in:
Peter Steinberger
2026-07-19 02:30:49 -07:00
committed by GitHub
parent 4e7ef6223c
commit b3bb40551b
3 changed files with 147 additions and 34 deletions
@@ -155,6 +155,14 @@ struct SettingsViewSmokeTests {
_ = view.body
}
@Test func `general settings renders the keyboard shortcut recorder`() {
let state = AppState(preview: true)
let hosting = NSHostingView(rootView: GeneralSettings(state: state))
hosting.frame = NSRect(x: 0, y: 0, width: 760, height: 640)
hosting.layoutSubtreeIfNeeded()
_ = hosting.fittingSize
}
@Test func `general settings exercises branches`() {
GeneralSettings.exerciseForTesting()
}
+37 -1
View File
@@ -89,6 +89,28 @@ sparkle_framework_for_arch() {
echo "$(build_path_for_arch "$1")/$BUILD_CONFIG/Sparkle.framework"
}
run_with_locked_swift_packages() {
local resolved_file="$ROOT_DIR/apps/macos/Package.resolved"
local resolved_snapshot
local command_status=0
if [[ ! -f "$resolved_file" ]]; then
echo "ERROR: Swift package lockfile not found at $resolved_file" >&2
return 1
fi
resolved_snapshot="$(mktemp)"
cp "$resolved_file" "$resolved_snapshot"
"$@" || command_status=$?
if ! cmp -s "$resolved_snapshot" "$resolved_file"; then
cp "$resolved_snapshot" "$resolved_file"
rm "$resolved_snapshot"
echo "ERROR: Swift package resolution changed Package.resolved; update it in a separate reviewed change" >&2
return 1
fi
rm "$resolved_snapshot"
return "$command_status"
}
PNPM_CMD=()
resolve_pnpm_cmd() {
@@ -220,8 +242,10 @@ cd "$ROOT_DIR/apps/macos"
echo "🔨 Building $PRODUCT ($BUILD_CONFIG) [${BUILD_ARCHS[*]}]"
for arch in "${BUILD_ARCHS[@]}"; do
BUILD_PATH="$(build_path_for_arch "$arch")"
echo "📦 Resolving Swift packages [$arch]"
run_with_locked_swift_packages swift package --scratch-path "$BUILD_PATH" resolve
echo "🔨 Building $PRODUCT ($BUILD_CONFIG) [$arch]"
swift build -c "$BUILD_CONFIG" --product "$PRODUCT" --build-path "$BUILD_PATH" --arch "$arch" -Xlinker -rpath -Xlinker @executable_path/../Frameworks
run_with_locked_swift_packages swift build -c "$BUILD_CONFIG" --product "$PRODUCT" --build-path "$BUILD_PATH" --arch "$arch" -Xlinker -rpath -Xlinker @executable_path/../Frameworks
echo "🔨 Building $MLX_TTS_HELPER_PRODUCT ($BUILD_CONFIG) [$arch]"
swift build --package-path "$MLX_TTS_HELPER_ROOT" -c "$BUILD_CONFIG" --product "$MLX_TTS_HELPER_PRODUCT" --build-path "$(helper_build_path_for_arch "$arch")" --arch "$arch"
done
@@ -366,6 +390,18 @@ else
exit 1
fi
echo "⌨️ Copying KeyboardShortcuts resources"
KEYBOARD_SHORTCUTS_BUNDLE="$(build_path_for_arch "$PRIMARY_ARCH")/$BUILD_CONFIG/KeyboardShortcuts_KeyboardShortcuts.bundle"
if [ -d "$KEYBOARD_SHORTCUTS_BUNDLE" ]; then
# SwiftPM's generated Bundle.module accessor searches Bundle.main.resourceURL for app resources.
# Keep this under Contents/Resources or Recorder localization traps before Settings renders.
rm -rf "$APP_ROOT/Contents/Resources/KeyboardShortcuts_KeyboardShortcuts.bundle"
cp -R "$KEYBOARD_SHORTCUTS_BUNDLE" "$APP_ROOT/Contents/Resources/KeyboardShortcuts_KeyboardShortcuts.bundle"
else
echo "ERROR: KeyboardShortcuts resource bundle not found at $KEYBOARD_SHORTCUTS_BUNDLE" >&2
exit 1
fi
running_packaged_app_pids() {
command -v pgrep >/dev/null 2>&1 || return 0
local app_binary="$APP_ROOT/Contents/MacOS/OpenClaw"
+102 -33
View File
@@ -1,16 +1,15 @@
// Package Mac App tests cover package mac app script behavior.
import { spawnSync } from "node:child_process";
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const tempDirs: string[] = [];
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const scriptPath = "scripts/package-mac-app.sh";
function makePlist(): string {
const dir = mkdtempSync(path.join(tmpdir(), "openclaw-plistbuddy-"));
tempDirs.push(dir);
const dir = tempDirs.make("openclaw-plistbuddy-");
const plist = path.join(dir, "Info.plist");
writeFileSync(
plist,
@@ -70,6 +69,17 @@ function getSparkleBuildHelperBlock(): string {
return script.slice(start, end);
}
function getSwiftPackageResolutionBlock(): string {
const script = readFileSync(scriptPath, "utf8");
const start = script.indexOf("run_with_locked_swift_packages()");
const end = script.indexOf("PNPM_CMD=()");
expect(start).toBeGreaterThanOrEqual(0);
expect(end).toBeGreaterThan(start);
return script.slice(start, end);
}
function getStopPackagedAppBlock(): string {
const script = readFileSync(scriptPath, "utf8");
const start = script.indexOf("running_packaged_app_pids()");
@@ -93,9 +103,8 @@ function getSwiftCompatibilityBlock(): string {
}
function runStopPackagedAppHarness(killZeroStatus: 0 | 1) {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-package-stop-root-"));
const toolsDir = mkdtempSync(path.join(tmpdir(), "openclaw-package-stop-tools-"));
tempDirs.push(root, toolsDir);
const root = tempDirs.make("openclaw-package-stop-root-");
const toolsDir = tempDirs.make("openclaw-package-stop-tools-");
const appRoot = path.join(root, "dist", "OpenClaw.app");
const appBinary = path.join(appRoot, "Contents", "MacOS", "OpenClaw");
@@ -131,12 +140,11 @@ function runStopPackagedAppHarness(killZeroStatus: 0 | 1) {
}
function runSwiftCompatibilityHarness(buildConfig: "debug" | "release") {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-package-swift-root-"));
const toolsDir = mkdtempSync(path.join(tmpdir(), "openclaw-package-swift-tools-"));
const root = tempDirs.make("openclaw-package-swift-root-");
const toolsDir = tempDirs.make("openclaw-package-swift-tools-");
const developerDir = path.join(root, "Xcode.app", "Contents", "Developer");
const appRoot = path.join(root, "OpenClaw.app");
const xcodeSelectPath = path.join(toolsDir, "xcode-select");
tempDirs.push(root, toolsDir);
writeFileSync(
xcodeSelectPath,
@@ -155,11 +163,34 @@ function runSwiftCompatibilityHarness(buildConfig: "debug" | "release") {
`);
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function runSwiftPackageResolutionHarness(mutateLockfile: boolean) {
const root = tempDirs.make("openclaw-swift-resolve-root-");
const toolsDir = tempDirs.make("openclaw-swift-resolve-tools-");
const resolvedFile = path.join(root, "apps", "macos", "Package.resolved");
const swiftPath = path.join(toolsDir, "swift");
mkdirSync(path.dirname(resolvedFile), { recursive: true });
writeFileSync(resolvedFile, "locked\n", { encoding: "utf8", flag: "wx" });
writeFileSync(
swiftPath,
[
"#!/usr/bin/env bash",
mutateLockfile ? `printf 'changed\\n' > ${JSON.stringify(resolvedFile)}` : ":",
].join("\n"),
"utf8",
);
chmodSync(swiftPath, 0o755);
const result = runHelper(`
set -euo pipefail
ROOT_DIR=${JSON.stringify(root)}
PATH=${JSON.stringify(`${toolsDir}:/usr/bin:/bin`)}
${getSwiftPackageResolutionBlock()}
run_with_locked_swift_packages swift package --scratch-path "$ROOT_DIR/apps/macos/.build/arm64" resolve
`);
return { result, resolvedFile };
}
describe("package-mac-app plist stamping", () => {
it("resolves canonical build provenance and rejects explicit invalid overrides", () => {
@@ -354,10 +385,9 @@ describe("package-mac-app plist stamping", () => {
it("falls back to corepack pnpm when the pnpm shim is absent", () => {
const helperBlock = getPackageManagerHelperBlock();
const tempRoot = mkdtempSync(path.join(tmpdir(), "openclaw-package-pnpm-root-"));
const toolsDir = mkdtempSync(path.join(tmpdir(), "openclaw-package-pnpm-tools-"));
const tempRoot = tempDirs.make("openclaw-package-pnpm-root-");
const toolsDir = tempDirs.make("openclaw-package-pnpm-tools-");
const logPath = path.join(tempRoot, "corepack.log");
tempDirs.push(tempRoot, toolsDir);
const corepackPath = path.join(toolsDir, "corepack");
writeFileSync(
@@ -396,11 +426,10 @@ describe("package-mac-app plist stamping", () => {
it("prefers repo Corepack pnpm over a global pnpm shim", () => {
const helperBlock = getPackageManagerHelperBlock();
const tempRoot = mkdtempSync(path.join(tmpdir(), "openclaw-package-pnpm-root-"));
const outerRoot = mkdtempSync(path.join(tmpdir(), "openclaw-package-pnpm-outer-"));
const toolsDir = mkdtempSync(path.join(tmpdir(), "openclaw-package-pnpm-tools-"));
const tempRoot = tempDirs.make("openclaw-package-pnpm-root-");
const outerRoot = tempDirs.make("openclaw-package-pnpm-outer-");
const toolsDir = tempDirs.make("openclaw-package-pnpm-tools-");
const logPath = path.join(tempRoot, "pnpm.log");
tempDirs.push(tempRoot, outerRoot, toolsDir);
writeFileSync(
path.join(tempRoot, "package.json"),
@@ -458,9 +487,8 @@ describe("package-mac-app plist stamping", () => {
it("fails with an actionable error when neither pnpm nor corepack pnpm is available", () => {
const helperBlock = getPackageManagerHelperBlock();
const tempRoot = mkdtempSync(path.join(tmpdir(), "openclaw-package-pnpm-root-"));
const toolsDir = mkdtempSync(path.join(tmpdir(), "openclaw-package-pnpm-tools-"));
tempDirs.push(tempRoot, toolsDir);
const tempRoot = tempDirs.make("openclaw-package-pnpm-root-");
const toolsDir = tempDirs.make("openclaw-package-pnpm-tools-");
const result = runHelper(`
set -euo pipefail
@@ -485,8 +513,7 @@ describe("package-mac-app plist stamping", () => {
it("fails with an actionable error when Swift tools are too old", () => {
const helperBlock = getSwiftToolchainBlock();
const toolsDir = mkdtempSync(path.join(tmpdir(), "openclaw-package-swift-tools-"));
tempDirs.push(toolsDir);
const toolsDir = tempDirs.make("openclaw-package-swift-tools-");
const swiftPath = path.join(toolsDir, "swift");
writeFileSync(
@@ -514,8 +541,7 @@ describe("package-mac-app plist stamping", () => {
it("accepts Swift tools 6.2 or newer", () => {
const helperBlock = getSwiftToolchainBlock();
const toolsDir = mkdtempSync(path.join(tmpdir(), "openclaw-package-swift-tools-"));
tempDirs.push(toolsDir);
const toolsDir = tempDirs.make("openclaw-package-swift-tools-");
const swiftPath = path.join(toolsDir, "swift");
writeFileSync(
@@ -542,9 +568,8 @@ describe("package-mac-app plist stamping", () => {
it("runs Sparkle build metadata derivation from the repository root", () => {
const helperBlock = getSparkleBuildHelperBlock();
const tempRoot = mkdtempSync(path.join(tmpdir(), "openclaw-package-sparkle-root-"));
const toolsDir = mkdtempSync(path.join(tmpdir(), "openclaw-package-sparkle-tools-"));
tempDirs.push(tempRoot, toolsDir);
const tempRoot = tempDirs.make("openclaw-package-sparkle-root-");
const toolsDir = tempDirs.make("openclaw-package-sparkle-tools-");
const nodePath = path.join(toolsDir, "node");
writeFileSync(
@@ -642,6 +667,10 @@ describe("package-mac-app plist stamping", () => {
script.indexOf(
'OPENCLAWKIT_BUNDLE="$(build_path_for_arch "$PRIMARY_ARCH")/$BUILD_CONFIG/OpenClawKit_OpenClawKit.bundle"',
),
script.indexOf('echo "⌨️ Copying KeyboardShortcuts resources"'),
);
const keyboardShortcutsBlock = script.slice(
script.indexOf('echo "⌨️ Copying KeyboardShortcuts resources"'),
script.indexOf("running_packaged_app_pids()"),
);
@@ -653,10 +682,50 @@ describe("package-mac-app plist stamping", () => {
expect(openClawKitBlock).toContain("exit 1");
expect(openClawKitBlock).not.toContain("WARN:");
expect(openClawKitBlock).not.toContain("continuing");
expect(keyboardShortcutsBlock).toContain("KeyboardShortcuts_KeyboardShortcuts.bundle");
expect(keyboardShortcutsBlock).toContain(
'cp -R "$KEYBOARD_SHORTCUTS_BUNDLE" "$APP_ROOT/Contents/Resources/KeyboardShortcuts_KeyboardShortcuts.bundle"',
);
expect(keyboardShortcutsBlock).toContain("ERROR: KeyboardShortcuts resource bundle not found");
expect(keyboardShortcutsBlock).toContain("exit 1");
expect(keyboardShortcutsBlock).not.toContain("WARN:");
expect(keyboardShortcutsBlock).not.toContain("continuing");
expect(script).not.toContain("Textual resource bundle");
expect(script).not.toContain("ALLOW_MISSING_TEXTUAL_BUNDLE");
});
it("preserves locked Swift package resolution before building", () => {
const script = readFileSync(scriptPath, "utf8");
const resolveCall =
'run_with_locked_swift_packages swift package --scratch-path "$BUILD_PATH" resolve';
const buildCall =
'run_with_locked_swift_packages swift build -c "$BUILD_CONFIG" --product "$PRODUCT"';
expect(script).toContain('resolved_file="$ROOT_DIR/apps/macos/Package.resolved"');
expect(script).toContain('cmp -s "$resolved_snapshot" "$resolved_file"');
expect(script).toContain('cp "$resolved_snapshot" "$resolved_file"');
expect(script).toContain("ERROR: Swift package resolution changed Package.resolved");
expect(script).toContain(resolveCall);
expect(script).toContain(buildCall);
expect(script.indexOf(resolveCall)).toBeLessThan(script.indexOf(buildCall));
});
it("restores and rejects a Swift package resolution that changes the lockfile", () => {
const { result, resolvedFile } = runSwiftPackageResolutionHarness(true);
expect(result.status).toBe(1);
expect(result.stderr).toContain("ERROR: Swift package resolution changed Package.resolved");
expect(readFileSync(resolvedFile, "utf8")).toBe("locked\n");
});
it("accepts a Swift package resolution that preserves the lockfile", () => {
const { result, resolvedFile } = runSwiftPackageResolutionHarness(false);
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
expect(readFileSync(resolvedFile, "utf8")).toBe("locked\n");
});
it("embeds the canonical CLI installer as a signed app resource", () => {
const script = readFileSync(scriptPath, "utf8");