From f87580ceb88580fce03c92c5763bfa13d7d91ee2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 14 Aug 2026 08:28:43 -0700 Subject: [PATCH] build(macos): add OPENCLAW_SKIP_MLX_TTS to unblock beta-toolchain builds (#123698) The openclaw-mlx-tts voice helper pulls in the full mlx-swift Metal shader stack, which some beta Xcode toolchains (e.g. Xcode 27 / macOS 27 SDK) cannot compile: the metal compiler dies non-deterministically (a different .metal file each run, 'Could not read serialized diagnostics file'). The main app builds fine, so an unrelated dev/proof build should not be blocked by the helper. Add OPENCLAW_SKIP_MLX_TTS=1 (matching the sibling SKIP_TSC/SKIP_UI_BUILD toggles) to package the app without the voice helper, gating both the per-arch build and the bundle copy. Refuse the flag for release builds, which must ship the helper (notarization verifies it), so a skipped build can never become a silently incomplete release. --- docs/platforms/mac/dev-setup.md | 26 +++++++++++++++++ scripts/package-mac-app.sh | 43 ++++++++++++++++++++-------- test/scripts/package-mac-app.test.ts | 39 +++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 12 deletions(-) diff --git a/docs/platforms/mac/dev-setup.md b/docs/platforms/mac/dev-setup.md index 42e2f0115b35..60c5402e61bb 100644 --- a/docs/platforms/mac/dev-setup.md +++ b/docs/platforms/mac/dev-setup.md @@ -31,6 +31,12 @@ pnpm install Outputs `dist/OpenClaw.app`. Without an Apple Developer ID certificate, the script falls back to ad-hoc signing. +Set `OPENCLAW_SKIP_MLX_TTS=1` to package a dev/proof build without the local +MLX voice helper. This skips the `openclaw-mlx-tts` binary and its large +mlx-swift Metal shader stack, which some beta Xcode toolchains cannot compile. +The resulting app has no on-device MLX voice; it is rejected for `release` +builds, which must ship the helper. + For dev run modes, signing flags, and Team ID troubleshooting, see [apps/macos/README.md](https://github.com/openclaw/openclaw/blob/main/apps/macos/README.md). Fast dev loop from repo root: `scripts/restart-mac.sh` (add `--no-sign` for @@ -70,6 +76,26 @@ xcrun swift --version If versions don't match, update macOS/Xcode and re-run the build. +### Build fails: MLX voice helper Metal shaders + +On a beta-only Xcode toolchain (for example Xcode 27 with the macOS 27 SDK), +only the `openclaw-mlx-tts` helper may fail while the main app builds fine. The +mlx-swift Metal compilation errors non-deterministically (a different `.metal` +file each run, `Could not read serialized diagnostics file` then a nonzero +`metal` exit), because the beta `metal` compiler and its separately downloaded +Metal Toolchain are still unstable. This is an upstream toolchain issue, not an +OpenClaw one. + +If you do not need on-device MLX voice, skip the helper: + +```bash +OPENCLAW_SKIP_MLX_TTS=1 ./scripts/package-mac-app.sh +``` + +Otherwise, install the Metal Toolchain +(`xcodebuild -downloadComponent MetalToolchain`) and build from a stable Xcode +release. + ### App crashes on permission grant If the app crashes when you try to allow **Speech Recognition** or diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index d542e481d022..e7a2cf4cd33c 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -25,6 +25,17 @@ MLX_TTS_HELPER_BUILD_ROOT="$MLX_TTS_HELPER_ROOT/.build" BUNDLE_ID="${BUNDLE_ID:-ai.openclaw.mac.debug}" PKG_VERSION="$(cd "$ROOT_DIR" && node -p "require('./package.json').version" 2>/dev/null || echo "0.0.0")" BUILD_CONFIG="${BUILD_CONFIG:-debug}" +# OPENCLAW_SKIP_MLX_TTS=1 packages the app without the local MLX voice helper. +# The helper pulls in the full mlx-swift Metal shader stack, which some beta +# Xcode toolchains cannot compile (flaky `metal` diagnostics), needlessly +# blocking unrelated dev/proof builds. Release builds must always ship the +# helper (notarization verifies it), so refuse the skip there instead of +# producing a silently incomplete release bundle. +SKIP_MLX_TTS="${OPENCLAW_SKIP_MLX_TTS:-0}" +if [[ "$SKIP_MLX_TTS" == "1" && "$BUILD_CONFIG" == "release" ]]; then + echo "ERROR: OPENCLAW_SKIP_MLX_TTS is not allowed for release builds; the MLX voice helper must ship in release." >&2 + exit 1 +fi BUILD_TS="$(openclaw_resolve_build_timestamp)" if [[ "$BUILD_CONFIG" == "release" ]]; then OPENCLAW_REQUIRE_BUILD_METADATA=1 @@ -431,8 +442,12 @@ for arch in "${BUILD_ARCHS[@]}"; do echo "🔨 Building $PRODUCT ($BUILD_CONFIG) [$arch]" run_with_locked_swift_packages swift build -c "$BUILD_CONFIG" --product "$PRODUCT" --build-path "$BUILD_PATH" --arch "$arch" -Xlinker -rpath -Xlinker @executable_path/../Frameworks restore_swiftpm_resource_sources - echo "🔨 Building $MLX_TTS_HELPER_PRODUCT ($BUILD_CONFIG) [$arch]" - build_mlx_tts_helper "$arch" + if [[ "$SKIP_MLX_TTS" == "1" ]]; then + echo "🔇 Skipping $MLX_TTS_HELPER_PRODUCT (OPENCLAW_SKIP_MLX_TTS=1) — app will lack the local MLX voice helper [$arch]" + else + echo "🔨 Building $MLX_TTS_HELPER_PRODUCT ($BUILD_CONFIG) [$arch]" + build_mlx_tts_helper "$arch" + fi done BIN_PRIMARY="$(bin_for_arch "$PRIMARY_ARCH")" @@ -490,17 +505,21 @@ chmod +x "$APP_ROOT/Contents/MacOS/OpenClaw" # SwiftPM outputs ad-hoc signed binaries; strip the signature before install_name_tool to avoid warnings. /usr/bin/codesign --remove-signature "$APP_ROOT/Contents/MacOS/OpenClaw" 2>/dev/null || true -echo "🚚 Copying MLX TTS helper" -cp "$(helper_bin_for_arch "$PRIMARY_ARCH")" "$APP_ROOT/Contents/MacOS/$MLX_TTS_HELPER_PRODUCT" -if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then - HELPER_BIN_INPUTS=() - for arch in "${BUILD_ARCHS[@]}"; do - HELPER_BIN_INPUTS+=("$(helper_bin_for_arch "$arch")") - done - /usr/bin/lipo -create "${HELPER_BIN_INPUTS[@]}" -output "$APP_ROOT/Contents/MacOS/$MLX_TTS_HELPER_PRODUCT" +if [[ "$SKIP_MLX_TTS" == "1" ]]; then + echo "🔇 Skipping MLX TTS helper copy (OPENCLAW_SKIP_MLX_TTS=1) — bundle omits Contents/MacOS/$MLX_TTS_HELPER_PRODUCT" +else + echo "🚚 Copying MLX TTS helper" + cp "$(helper_bin_for_arch "$PRIMARY_ARCH")" "$APP_ROOT/Contents/MacOS/$MLX_TTS_HELPER_PRODUCT" + if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then + HELPER_BIN_INPUTS=() + for arch in "${BUILD_ARCHS[@]}"; do + HELPER_BIN_INPUTS+=("$(helper_bin_for_arch "$arch")") + done + /usr/bin/lipo -create "${HELPER_BIN_INPUTS[@]}" -output "$APP_ROOT/Contents/MacOS/$MLX_TTS_HELPER_PRODUCT" + fi + chmod +x "$APP_ROOT/Contents/MacOS/$MLX_TTS_HELPER_PRODUCT" + /usr/bin/codesign --remove-signature "$APP_ROOT/Contents/MacOS/$MLX_TTS_HELPER_PRODUCT" 2>/dev/null || true fi -chmod +x "$APP_ROOT/Contents/MacOS/$MLX_TTS_HELPER_PRODUCT" -/usr/bin/codesign --remove-signature "$APP_ROOT/Contents/MacOS/$MLX_TTS_HELPER_PRODUCT" 2>/dev/null || true SPARKLE_FRAMEWORK_PRIMARY="$(sparkle_framework_for_arch "$PRIMARY_ARCH")" if [ -d "$SPARKLE_FRAMEWORK_PRIMARY" ]; then diff --git a/test/scripts/package-mac-app.test.ts b/test/scripts/package-mac-app.test.ts index cc40b4bae4e4..0f640d51d1c7 100644 --- a/test/scripts/package-mac-app.test.ts +++ b/test/scripts/package-mac-app.test.ts @@ -827,6 +827,45 @@ describe("package-mac-app plist stamping", () => { } }); + it("skips the MLX TTS helper build and copy when OPENCLAW_SKIP_MLX_TTS=1", () => { + const script = readFileSync(scriptPath, "utf8"); + + // Both the per-arch build and the bundle copy are gated on the same flag so + // a skipped build never tries to copy a helper binary that was not built. + expect(script).toContain( + 'if [[ "$SKIP_MLX_TTS" == "1" ]]; then\n echo "🔇 Skipping $MLX_TTS_HELPER_PRODUCT (OPENCLAW_SKIP_MLX_TTS=1)', + ); + expect(script).toContain( + 'if [[ "$SKIP_MLX_TTS" == "1" ]]; then\n echo "🔇 Skipping MLX TTS helper copy (OPENCLAW_SKIP_MLX_TTS=1)', + ); + }); + + it("refuses OPENCLAW_SKIP_MLX_TTS for release builds but allows it for dev builds", () => { + const script = readFileSync(scriptPath, "utf8"); + + // Run the real guard snippet from the script (not a copy) so the release + // safety invariant stays coupled to source: release bundles must ship the + // voice helper, which notarization later verifies. + const guardStart = script.indexOf('SKIP_MLX_TTS="${OPENCLAW_SKIP_MLX_TTS:-0}"'); + const guardEnd = script.indexOf("BUILD_TS=", guardStart); + expect(guardStart).toBeGreaterThanOrEqual(0); + expect(guardEnd).toBeGreaterThan(guardStart); + const guard = script.slice(guardStart, guardEnd); + + const released = runHelper( + `set -euo pipefail\nexport OPENCLAW_SKIP_MLX_TTS=1\nBUILD_CONFIG=release\n${guard}\necho reached-build`, + ); + expect(released.status).toBe(1); + expect(released.stderr).toContain("not allowed for release builds"); + expect(released.stdout).not.toContain("reached-build"); + + const dev = runHelper( + `set -euo pipefail\nexport OPENCLAW_SKIP_MLX_TTS=1\nBUILD_CONFIG=debug\n${guard}\necho reached-build`, + ); + expect(dev.status, dev.stderr).toBe(0); + expect(dev.stdout).toContain("reached-build"); + }); + it("falls back to corepack pnpm when the pnpm shim is absent", () => { const helperBlock = getPackageManagerHelperBlock(); const tempRoot = tempDirs.make("openclaw-package-pnpm-root-");