Skip to content

Commit 694e3cf

Browse files
JRickeyclaude
andcommitted
package-macos: bundle Homebrew dylibs into Contents/Frameworks (#43)
The macOS package script copied the BattleShip / torch binaries verbatim into Contents/MacOS without bundling their Homebrew runtime dependencies, so the resulting .app embedded absolute load commands like /opt/homebrew/opt/sdl2/lib/libSDL2-2.0.0.dylib. On any Mac without Homebrew at the developer's prefix and exact package versions, dyld bails before main() with `Library not loaded: /opt/homebrew/*/libSDL2- 2.0.0.dylib` — verbatim crash from issue #43 (Apple M4, macOS Tahoe 26.3). Linux and Windows packaging already handle this (linuxdeploy walks NEEDED for AppImage; vcpkg drops SDL2.dll next to the .exe). macOS was the only platform shipping host-locked binaries. Fix uses the standard `dylibbundler` Homebrew package: walks the binaries' transitive Homebrew deps, stages each into Contents/Frameworks/, rewrites each dylib's id to @executable_path/../Frameworks/lib*.dylib, retargets the binaries' load commands via install_name_tool, and replaces the inherited Homebrew rpaths with @executable_path/../Frameworks/. Using the absolute @executable_path form (rather than @rpath + LC_RPATH) avoids dylibbundler's existing-rpath rewrite stomping the LC_RPATH with literal `@rpath/`, which produces a recursive load command dyld can't resolve. The post-bundle adhoc `codesign --deep --force --sign -` already in the script picks up the new Frameworks/ tree and re-signs both the dylibs and the now-modified executables (install_name_tool invalidates the linker signature). Added a sanity check after dylibbundler that scans the binaries for any remaining /opt/homebrew or /usr/local/Cellar load commands and fails the build loudly if one slips through, rather than shipping a host-locked .app to users. Verified locally on a copy of dist/BattleShip.app: every Homebrew reference replaced with @executable_path/../Frameworks/, dylib ids match, codesign --verify --deep --strict passes. CI: added `dylibbundler` to the brew install line in .github/workflows/release.yml so macos-14 runners have the tool. Closes #43. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 64c705e commit 694e3cf

2 files changed

Lines changed: 52 additions & 1 deletion

File tree

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ jobs:
4040
run: |
4141
brew install cmake ninja \
4242
sdl2 glew libzip nlohmann-json tinyxml2 spdlog \
43-
zip create-dmg
43+
zip create-dmg dylibbundler
4444
- name: Package macOS .app + .dmg
4545
run: bash scripts/package-macos.sh
4646
- uses: actions/upload-artifact@v4

scripts/package-macos.sh

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,57 @@ EOF
135135
# Make the binaries executable (cp preserves mode, but be defensive).
136136
chmod +x "$APP/Contents/MacOS/$APP_NAME" "$APP/Contents/MacOS/torch"
137137

138+
# ── 4a. Bundle Homebrew dylib dependencies (fixes #43) ──
139+
# CMake links BattleShip / torch against Homebrew dylibs (SDL2, GLEW,
140+
# libzip, tinyxml2, spdlog, fmt, …) using their absolute install paths
141+
# (`/opt/homebrew/opt/<pkg>/lib/lib*.dylib`). On a developer machine the
142+
# .app launches fine because every load command resolves verbatim. On
143+
# any user's Mac without Homebrew at the same prefix and exact package
144+
# versions, dyld bails before main() with `Library not loaded:
145+
# /opt/homebrew/*/libSDL2-2.0.0.dylib`. This is the macOS analogue of
146+
# bundling SDL2.dll on Windows / `.so` deps via linuxdeploy on Linux —
147+
# the package script must walk the binaries' transitive Homebrew deps,
148+
# stage them into Contents/Frameworks/, rewrite each dylib's id to
149+
# `@rpath/lib*.dylib`, retarget the binaries' references via
150+
# `install_name_tool`, and add an `LC_RPATH` of `@executable_path/../Frameworks`.
151+
#
152+
# `dylibbundler` (Homebrew package) automates all of that. `-of` =
153+
# overwrite-files (idempotent re-runs), `-b` = bundle transitive deps,
154+
# `-x` = files to fix (repeatable for torch alongside BattleShip),
155+
# `-d` = destination directory, `-p` = install_name prefix, `-cd` =
156+
# create destination, `-ns` = skip dylibbundler's own adhoc codesign
157+
# pass since the bundle-level `codesign --deep --force` below resigns
158+
# everything in one shot.
159+
#
160+
# `-p @executable_path/../Frameworks/` makes the rewritten install_names
161+
# fully self-resolving (dyld substitutes `@executable_path` with the
162+
# directory of the loading executable — Contents/MacOS — so the path
163+
# lands at Contents/Frameworks/lib*.dylib). Setting `-p @rpath/` would
164+
# require an LC_RPATH on the binary, and dylibbundler's existing-rpath
165+
# rewrite stomps on that path with literal `@rpath/`, producing a
166+
# recursive load command that dyld can't resolve. Using the absolute
167+
# `@executable_path` form sidesteps that.
168+
step "Bundling Homebrew dylib dependencies"
169+
command -v dylibbundler >/dev/null \
170+
|| fail "dylibbundler not in PATH — install with: brew install dylibbundler"
171+
dylibbundler -of -b -cd -ns \
172+
-x "$APP/Contents/MacOS/$APP_NAME" \
173+
-x "$APP/Contents/MacOS/torch" \
174+
-d "$APP/Contents/Frameworks/" \
175+
-p "@executable_path/../Frameworks/"
176+
177+
# Sanity-check: no /opt/homebrew or /usr/local references should remain in
178+
# the binaries' load commands. Catches the case where dylibbundler missed a
179+
# transitive dep (rare, but worth failing loudly here rather than letting
180+
# the .app ship and hit the user with a runtime "Library not loaded:").
181+
for bin in "$APP/Contents/MacOS/$APP_NAME" "$APP/Contents/MacOS/torch"; do
182+
if otool -L "$bin" | grep -qE '(/opt/homebrew|/usr/local/Cellar)'; then
183+
echo " remaining unbundled refs in $bin:" >&2
184+
otool -L "$bin" | grep -E '(/opt/homebrew|/usr/local/Cellar)' >&2
185+
fail "dylibbundler left non-portable load commands in $(basename "$bin")"
186+
fi
187+
done
188+
138189
# ── 4b. Adhoc-sign the bundle as a unit ──
139190
# Modern Gatekeeper (Sequoia / 15.x+) flags downloaded adhoc-signed
140191
# bundles as "damaged" if the signature isn't deep enough to cover

0 commit comments

Comments
 (0)