Skip to content

macos_bundle_dylibs: add an .app layout beside the framework one - #6821

Merged
Fedr merged 4 commits into
masterfrom
bundle-dylibs-app-layout
Sep 10, 2026
Merged

Fedr merged 4 commits into
masterfrom
bundle-dylibs-app-layout

Conversation

@Fedr

@Fedr Fedr commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Teaches scripts/macos_bundle_dylibs.py a second layout, so the same script can bundle a .app as well as MeshLib.framework.

macos_bundle_dylibs.py <framework version dir>                       # unchanged
macos_bundle_dylibs.py <path>.app --layout app --search-dir <dir> ... # new

Note

Nothing in this repo uses the new layout. MeshLib builds no .app — no MACOSX_BUNDLE target, no Info.plist, no Contents/MacOS; it installs into a framework where MeshViewer is a plain executable in bin/. The consumer is MeshInspectorCode, whose .app is bundled by dylibbundler today. This lands here because the script lives here, and it is verified by MeshInspector/MeshInspectorCode#7789, which calls it.

Why

dylibbundler drops absolute LC_RPATH entries while resolving @rpath references, so it warns can't get path for '@rpath/...' 37 times per macOS build on well-formed binaries (auriamg/macdylibbundler#88), and upstream is unmaintained. It also hardcodes /usr/local and /opt/homebrew as its only search prefixes, so it silently bundles nothing on a runner with a custom Homebrew prefix — the reason this script calls brew --prefix in the first place.

The other candidate, CMake's fixup_bundle, copies every prerequisite it can resolve and keys them by file name, so a bundle referencing Python through more than one path ships a second interpreter that then has to be deleted again. This script's SKIP_BASENAME_RE = ^(libpython|Python$) already refuses to bundle Python, so that cannot happen.

Adopting it downstream also means one bundling tool for the framework and the .app instead of two.

What changed

  • _rpath_for(p, dest_dir, exe_dirs) replaces the hardcoded @executable_path/../lib / @loader_path/. choice with a path computed from where the binary sits. This is what makes a nested directory work: Contents/Frameworks/meshlib/*.so needs @loader_path/.., which neither fixed string could express.
  • bundle() takes seed_dirs, dest_dir and exe_dirs; bundle_framework() and bundle_app() name the two layouts.
  • --search-dir (repeatable) adds directories whose libraries are bundled like Homebrew ones, and are searched during basename fallback. A .app is assembled from a build tree, so its own dylibs and prebuilt thirdparty libraries live outside any Homebrew prefix.
  • sign_exes=False for the .app. Signing a bundle's main executable makes codesign sign the enclosing bundle and seal its resources, which fails on a nested item it does not consider code:
    MeshInspector: code object is not signed at all / In subcomponent: Contents/Frameworks/meshlib/__init__.py. McpGateway signs fine one line earlier, because Info.plist does not name it. Those callers sign the bundle as a unit afterwards, which covers the executables anyway.
  • drop_absolute_rpaths=True for the .app. The script only ever added rpaths, so binaries arriving from a build tree kept their rpaths into it — eleven of them across the executables and Python modules, which MeshInspectorCode's load-command check failed on. dylibbundler deleted these on the files it was handed, which is why the one file it was never handed, the pybind11 shim, was the only one still carrying them.
  • _resolve_homebrew_basename_resolve_by_basename, since it now looks in --search-dir too, preferring those over Homebrew.

Compatibility

The framework path is unchanged by construction. --layout defaults to framework; both new behaviours are off by default; with no --search-dir, SEARCH_DIRS is empty and every new branch short-circuits. scripts/distribution_apple.sh:34 calls the script with a single positional argument, so that invocation is untouched, and macOS CI here runs it.

_rpath_for reproduces the old strings exactly, which is worth checking rather than asserting:

binary rpath
bin/MeshViewer @executable_path/../lib
lib/libMRMesh.dylib @loader_path/.
Contents/MacOS/MeshInspector @executable_path/../Frameworks
Contents/Frameworks/libMRMesh.dylib @loader_path/.
Contents/Frameworks/meshlib/mrmeshpy.so @loader_path/..

The first two are the pre-change behaviour; all five verified locally against the function as it appears in this diff.

Measured downstream

From #7789, which uses this against MeshInspector's .app, against the same .app built with dylibbundler and with fixup_bundle:

dylibbundler fixup_bundle this script
files shipped 149 149 149
load-command check clean clean clean
Python / libintl copies 1 / 1 1 / 1 1 / 1
can't get path for warnings 37 0 0
bundling step, arm / x64 26s / 65s 46s / 79s 21s / 48s
packaging script lines 143 158 102

Same payload in all three; the libraries differ only in name, since this script copies each under the name its referrer asked for (libTKBO.7.9.dylib) rather than the realpath, which is its SONAME-drift protection and needs no alias symlinks.

Test plan

  • macOS builds and the .pkg job pass: scripts/distribution_apple.sh runs this script, so the framework layout is covered here.
  • The app layout is exercised end to end by MeshInspector/MeshInspectorCode#7789 — green on both arches, load-command check clean, plugins load and unload in the packaged bundle.

Other platforms are disabled; the script only runs during macOS packaging.

The same bundling problem exists for MeshInspector's .app, and the two
tools available for it are both worse than this script. dylibbundler
drops absolute LC_RPATH entries while resolving @rpath references, which
is where its "can't get path for" warnings come from, and hardcodes two
Homebrew prefixes. fixup_bundle copies every prerequisite it can resolve
with no way to exempt one, and keys them by file name, so a bundle that
references Python through more than one path ends up shipping a second
interpreter. This script calls brew --prefix, and SKIP_BASENAME_RE
already leaves Python alone.

bundle() now takes seed_dirs, dest_dir and exe_dirs, with thin wrappers
naming the two layouts, and the rpath is computed relative to where each
binary sits rather than picked from two fixed strings - which is what
lets Contents/Frameworks/meshlib/*.so get @loader_path/.. . --search-dir
covers libraries outside any Homebrew prefix, needed because a .app is
assembled from a build tree rather than an install tree.

The framework layout is unchanged: it is the default, it passes no
search dirs, and _rpath_for reproduces both of its previous rpaths.
codesign on a bundle's main executable signs the enclosing bundle and
seals its resources, which fails on a nested item it does not consider
code: "code object is not signed at all / In subcomponent:
Contents/Frameworks/meshlib/__init__.py". McpGateway signs fine because
Info.plist does not name it, so only the main executable trips this.

The .app callers already sign the bundle as a unit afterwards, which
covers the executables, so skip them here. The framework layout keeps
signing its bin/ executables: those are standalone, not bundle members.
A .app is assembled from a build tree, so every binary in it arrives
carrying rpaths into that tree - dylibbundler deleted them on the files
it was given, which is why the one file it was never given, the pybind11
shim, was the only one left with them. This script only ever added
rpaths, so MeshInspectorCode's load command check failed on eleven of
them across the executables and the python modules.

Delete anything not @-relative in that layout: dyld searches those paths
on the user's machine, and they leak build paths. The framework layout is
unaffected, both because it is off by default and because an install tree
has none to drop.
The mechanics behind them - copyflag, IGNORE_ITEM, get_item_key, which
tree a layout is assembled from - are in the PR. What stays is the fact
each line exists to prevent: why the rpaths are dropped, why the .app
callers sign the bundle themselves, and the layout table, which has no
other home.
@Fedr
Fedr merged commit 6385c9f into master Sep 10, 2026
23 checks passed
@Fedr
Fedr deleted the bundle-dylibs-app-layout branch September 10, 2026 09:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants