#519

fix(launcher): link Cocoa on macOS so App Sandbox / Mac App Store builds can launch

Openarturs-fayns-euwants to merge intofrom

The problem: no Electrobun app can ship on the Mac App Store

Not "is awkward to ship" — cannot launch at all. Any Electrobun app signed with com.apple.security.app-sandbox, which the Mac App Store requires, dies during startup:

Sandbox: bun(20707) deny(1) mach-lookup com.apple.coreservices.launchservicesd
_RegisterApplication(), unable to get application ASN from launchservicesd,
and this application requires an ASN, so aborting.

Nothing in the tracker covers this, so I assume nobody has tried to put an Electrobun app in the App Store yet. The fix is one file.

Root cause: AppKit load order, not process topology

macOS hands an app its ASN while the bundle's main executable starts. A normally-built Cocoa app links AppKit at load time, so _LSApplicationCheckIn runs during dyld initialization while the launch context still exists — the process is handed its ASN and never performs a lookup. The launcher links no AppKit (build.zig has no framework linkage at all), so AppKit only arrives later, when the runtime dlopens the native wrapper. By then the launch window has closed, _RegisterApplication has to ask launchservicesd for an ASN, and App Sandbox denies that mach-lookup. AppKit treats it as fatal.

Outside the sandbox the lookup is permitted, which is why this has never surfaced.

Reproduction — no Electrobun, no Bun, no Zig

Two arms from the same two source files, differing in one link flag on the main executable. This is the whole bug in 30 lines.

repro.sh (verified on macOS 26.6.1, Apple silicon)
#!/bin/bash
set -euo pipefail
cat > wrapper.m <<'EOF'
#import <Cocoa/Cocoa.h>
__attribute__((visibility("default"))) int wrapper_start(void) {
  [NSApplication sharedApplication];          // needs an ASN
  [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
  return 0;
}
EOF
cat > host.c <<'EOF'
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
int main(void) {
  const char *home = getenv("HOME");
  char p[1024]; snprintf(p, sizeof p, "%s/repro.log", home ? home : "/tmp");
  FILE *f = fopen(p, "a"); if (!f) return 70;
  void *h = dlopen("@executable_path/libwrapper.dylib", RTLD_NOW);
  if (!h) { fprintf(f, "%s: dlopen failed\n", VARIANT); return 71; }
  int (*start)(void) = dlsym(h, "wrapper_start");
  fprintf(f, "%s: before NSApplication\n", VARIANT); fflush(f);
  start();
  fprintf(f, "%s: SURVIVED\n", VARIANT); fflush(f);
  fclose(f); return 0;
}
EOF
cat > ent.plist <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>com.apple.security.app-sandbox</key><true/>
  <key>com.apple.security.network.client</key><true/>
  <key>com.apple.security.network.server</key><true/>
  <key>com.apple.security.cs.allow-jit</key><true/>
</dict></plist>
EOF

build_arm() { # $1 = arm name, $2... = extra link flags for the MAIN executable
  local arm="$1"; shift
  local id="sh.example.lsrepro.$arm" app="out/$arm/Repro.app"
  rm -rf "out/$arm"; mkdir -p "$app/Contents/MacOS"
  rm -f "$HOME/Library/Containers/$id/Data/repro.log" 2>/dev/null || true
  xcrun clang -O2 -dynamiclib -framework Cocoa \
    -install_name @executable_path/libwrapper.dylib \
    -o "$app/Contents/MacOS/libwrapper.dylib" wrapper.m
  xcrun clang -O2 -DVARIANT="\"$arm\"" "$@" -o "$app/Contents/MacOS/host" host.c
  cat > "$app/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>CFBundleExecutable</key><string>host</string>
  <key>CFBundleIdentifier</key><string>$id</string>
  <key>CFBundlePackageType</key><string>APPL</string>
</dict></plist>
PLIST
  codesign --force --sign - "$app/Contents/MacOS/libwrapper.dylib" >/dev/null 2>&1
  codesign --force --sign - --entitlements ent.plist "$app" >/dev/null 2>&1
  open -n "$app"; sleep 3
  echo "--- $arm : $(otool -L "$app/Contents/MacOS/host" | grep -c Cocoa) Cocoa load command(s)"
  cat "$HOME/Library/Containers/$id/Data/repro.log" 2>/dev/null || echo "(no log)"
  pkill -f "$app/Contents/MacOS" >/dev/null 2>&1 || true
}

build_arm no-cocoa
build_arm with-cocoa -Wl,-needed_framework,Cocoa
--- no-cocoa   : 0 Cocoa load command(s)
no-cocoa: before NSApplication            <- aborts here
--- with-cocoa : 1 Cocoa load command(s)
with-cocoa: before NSApplication
with-cocoa: SURVIVED

Measured against Electrobun's own launcher

I built the launcher from this branch's parent (v1.18.4-beta.21) with the pinned Zig 0.16.0, put the real binary in a sandboxed .app as CFBundleExecutable, and gave it a stand-in runtime that dlopens a Cocoa-linked dylib and calls NSApplication — the same load-order shape as bun → libElectrobunCore. Ad-hoc signed, shippable entitlements only, fresh bundle id per run, two rounds:

LauncherCocoa linkedProcess modelResult
upstream as-isnofork + waitabort, deny(1) mach-lookup ...launchservicesd
this PRyesfork + waitboots, window visible, NSApp active, 0 denials
exec-in-place onlynoexecve in placeabort, same denial
exec-in-place + Cocoayesexecve in placeboots

Then the same two-arm test against a real Electrobun application bundle — real bun, real libNativeWrapper, real main.js/app.asar, only the launcher binary swapped and the bundle id neutralised:

stock launcher  -> deny(1) mach-lookup com.apple.coreservices.launchservicesd
                   _RegisterApplication() ... so aborting.     [no processes alive]
patched launcher-> launcher(20751) + bun(20755) alive, zero launchservicesd denials

Rows 3 and 4 are there so nobody has to re-derive them. I first assumed the launcher also had to execve in place so the pid carried the registration into the runtime. It does not. Row 4 works but row 3 does not, and row 2 shows the Cocoa link alone is sufficient with the fork intact — so exec-in-place is neither necessary nor sufficient, and I dropped it. That matters, because exec-in-place would have cost you child supervision, exit-code propagation and the SIGINT/SIGALRM handling on macOS.

What this changes

package/src/launcher/build.zig, +17 lines, macOS only.

What this does not change

  • Nothing about the process model. Still fork + wait, still supervises the child, still propagates exit codes, still runs linux_dependencies.shouldDiagnoseChildExit. No trade-off to weigh.
  • Nothing off macOS. The Linux and Windows launchers are byte-identical to upstream — I built both trees at ReleaseSmall for x86_64-linux-gnu, aarch64-linux-gnu and x86_64-windows and compared SHA-256; all three match. The macOS guard means xcrun is never invoked on other hosts.
  • Nothing about Updater. applyUpdate's macOS relaunch (open from a detached shell polling process.pid) and resolve(dirname(process.execPath), "..", "..") behave exactly as before, since the topology is unchanged.
  • Deployment target. LC_BUILD_VERSION minos stays 14.0.

Cost: one extra LC_LOAD_DYLIB, +32 bytes, and ~2 ms of AppKit load at launch (30-run mean, 4.3 ms → 6.3 ms for the launcher process itself).

Deliberately not using the temporary exception

com.apple.security.temporary-exception.mach-lookup.global-name also makes the error go away. It is not in this PR and should not be suggested: apps using it get rejected under App Review Guideline 2.4.5(i) (see AvaloniaUI/Avalonia#6529). A workaround that guarantees rejection does not unlock the App Store.

Build requirement

Linking a framework needs the macOS SDK, resolved via std.zig.system.darwin.getSdk (xcrun --show-sdk-path) — already a hard dependency of the macOS build, which shells out to xcrun --sdk macosx clang++ for the native wrapper. CI builds darwin on macos-14, so it is unaffected. Cross-compiling the launcher to macOS from a non-macOS host now needs an SDK and fails with an explicit message instead of a link error.

Tests

zig build test in package/src/launcher passes (I added none; the change is build-configuration only). As far as I can tell CI never runs the launcher's Zig tests. If you want a guard so this line cannot be quietly dropped, the natural home is a Mach-O load-command assertion beside verify-macho-code-signing / verify-macho-deployment-target — say the word and I will add it rather than guessing at the shape.

One remaining gap, for the record

This fixes the launch gate only. A complete MAS submission also needs the child runtime signed with com.apple.security.inherit rather than the app's own entitlements, and cli/index.ts currently applies one entitlements file to every Mach-O it finds in Contents/MacOS. Out of scope here; happy to open an issue.

Question, and honest expectation

I can see that 1.x code PRs are generally triaged into 2.0 rather than merged, so the useful question is probably not "will you merge this":

Does the 2.0 launcher rewrite cover App Sandbox / Mac App Store distribution, and is the AppKit-link-order constraint already accounted for?

Knowing that is worth more to me than this merging. We are shipping a vendored patched launcher in the meantime, and if 2.0 lands with a different launcher I am happy to redo the work against it. Either way the repro above should save whoever hits this next a couple of days.