ifcopenshell plug-in loader: stable anchor + bundle-aware fallbacks

# What

Two upstream-shaped fixes to ifcopenshell's runtime plug-in discovery
so the schema/serializer plug-ins are findable in deployment layouts
other than a flat \`<prefix>/lib/\` (specifically: macOS .app bundles).

## (1) Stable anchor variable instead of a function pointer

\`schema_plugin_directory()\` used \`&load_schema_plugins\` as the
anchor whose containing module \`dladdr\` is asked to resolve. Function
addresses are not reliably equal to a single canonical location across
toolchains — on macOS arm64 with BonsaiViewer.app, \`&load_schema_plugins\`
took the address of a PLT/stub inside the consumer binary rather than
the actual symbol inside \`libIfcParse.dylib\`. \`dladdr\` then dutifully
returned the consumer's path and the loader started searching
\`BonsaiViewer.app/Contents/MacOS/\` for plug-ins that were never
installed there.

A variable doesn't suffer from this — it has exactly one canonical
address inside its defining dylib. Add \`ifcopenshell_libifcparse_anchor\`
(exported via IFC_PARSE_API) and use \`&that\` instead. Standard pattern
used by Boost.DLL, GStreamer, \`_dyld_get_image_*\`, etc.

## (2) Bundle-aware fallback search paths

The primary search path is \`dirname(libIfcParse)\`. That works for
flat installs (Linux \`lib/\`, Windows \`bin/\`) where plug-ins are
siblings of libIfcParse. macOS app bundles split the layout:
\`macdeployqt\` puts non-Qt @rpath deps in \`Contents/Frameworks/\`,
Apple convention asks for \`Contents/PlugIns/\`, and some install rules
co-locate libIfcParse with the exe in \`Contents/MacOS/\`. Plug-ins
typically end up in a sibling directory, not the same one.

In \`add_search_paths_or_default\`, after registering the primary path,
also register \`<parent>/PlugIns\`, \`<parent>/Frameworks\`, and
\`<parent>/MacOS\` on Apple platforms. \`discover_exact\` short-circuits
on the first hit so duplicates and missing directories are harmless.

# What this does NOT do

The plug-in dylibs still need to actually be inside the app bundle
somewhere for these fallbacks to find them — the upstream install
rules (\`install(TARGETS …)\` in \`src/ifcparse/CMakeLists.txt\`,
\`src/serializers/CMakeLists.txt\`, etc.) put them in \`<prefix>/lib/\`
which lives outside \`BonsaiViewer.app\`. That side of the fix is a
follow-up — either an explicit bundle-aware install destination on
the plug-in targets, or an install(CODE) sweep that mirrors them
into the bundle.

# What this also un-does

Reverts the BonsaiViewer-only \`install(CODE)\` hack that was about to
copy \`ifcopenshell.*.dylib\` from \`lib/\` into
\`BonsaiViewer.app/Contents/MacOS/\` — superseded by the loader-side
fix above, which lets us put the plug-ins anywhere sane inside the
bundle without further consumer-side stitching.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-06-02 21:15:46 +10:00
parent 5ffae41bbd
commit 37aa68e66c
3 changed files with 68 additions and 1 deletions
+34
View File
@@ -155,3 +155,37 @@ install(TARGETS BonsaiViewer
BUNDLE DESTINATION .
)
ifcopenshell_deploy_qt_runtime(BonsaiViewer)
# Stage IfcOpenShell plug-ins (schemas, kernels, mappings, serializers —
# anything named ifcopenshell.*.dylib) into the .app bundle's
# Frameworks/ directory on macOS. These are dlopen-only deps so
# macdeployqt does not follow them automatically. The upstream
# install(TARGETS …) rules place them at <prefix>/lib/ (outside the
# bundle).
#
# Frameworks/ is the standard macOS app-bundle location for shared
# libraries the app pulls in at runtime — it's where macdeployqt
# already deposited libIfcParse/libIfcGeom/etc. as linked deps, and
# the BonsaiViewer exe's INSTALL_RPATH (@executable_path/../Frameworks)
# points there. With the plug-in loader's primary search path being
# dirname(libIfcParse) (= Frameworks/ inside the bundle), placing the
# plug-ins alongside libIfcParse means they're found on the first
# probe — no fallback search needed.
#
# Subdirectory order in cmake/CMakeLists.txt guarantees that ifcparse/
# / ifcgeom/ / serializers/ are add_subdirectory'd before bonsaiviewer/,
# so by the time this install rule fires the *.dylib files are already
# on disk under <prefix>/lib/.
if(APPLE)
install(CODE [[
file(GLOB _ifc_plugins
"${CMAKE_INSTALL_PREFIX}/lib/ifcopenshell.*.dylib")
if(_ifc_plugins)
message(STATUS "Staging IfcOpenShell plug-ins into BonsaiViewer.app/Contents/Frameworks")
file(COPY ${_ifc_plugins}
DESTINATION "${CMAKE_INSTALL_PREFIX}/BonsaiViewer.app/Contents/Frameworks")
else()
message(WARNING "No ifcopenshell.*.dylib plug-ins found in lib/ — BonsaiViewer.app will fail at the first IFC load")
endif()
]])
endif()
+13 -1
View File
@@ -205,8 +205,20 @@ ifcopenshell::plugin::metadata ifcopenshell::schema_plugin_metadata(const std::s
return metadata;
}
// Stable anchor symbol inside libIfcParse for plugin::module_directory()'s
// dladdr/GetModuleHandleEx lookup. A variable (vs. a function pointer) has
// exactly one canonical address inside its defining module — function
// pointers can resolve to a PLT/stub copy in the consumer binary on some
// toolchains (observed on macOS arm64: `&load_schema_plugins` resolved
// inside BonsaiViewer.exe instead of libIfcParse.dylib, so dladdr returned
// the exe's directory and the plugin search path ended up at
// BonsaiViewer.app/Contents/MacOS/ instead of wherever libIfcParse — and
// therefore the schema plug-ins — actually lived).
extern "C" IFC_PARSE_API char ifcopenshell_libifcparse_anchor;
IFC_PARSE_API char ifcopenshell_libifcparse_anchor = 0;
std::filesystem::path ifcopenshell::schema_plugin_directory() {
return plugin::module_directory(reinterpret_cast<const void*>(&ifcopenshell::load_schema_plugins));
return plugin::module_directory(&ifcopenshell_libifcparse_anchor);
}
void ifcopenshell::load_schema_plugins(schema_registry& registry) {
+21
View File
@@ -344,6 +344,27 @@ PLUGIN_API std::filesystem::path ifcopenshell::plugin::add_search_paths_or_defau
plugin_debug("using default plugin search path from module directory");
const auto path = default_search_path();
manager.add_search_path(path);
// Bundle-aware fallback search paths. The primary search path is
// dirname(libIfcParse) which works for the flat layouts we get on
// Linux ($prefix/lib/) and Windows ($prefix/bin/) — plug-ins live
// next to libIfcParse there. macOS app bundles put libIfcParse
// somewhere inside Contents/ (Frameworks/ by macdeployqt
// convention, MacOS/ if the install rule co-locates it with the
// exe) and the plug-ins typically live in a sibling directory,
// not the same one. Probe the Apple-canonical PlugIns/ first,
// then Frameworks/ (where macdeployqt deposits non-Qt @rpath
// deps) and MacOS/ (next-to-exe layout). Duplicate primary paths
// are harmless — discover_exact short-circuits on first hit.
#ifdef __APPLE__
if (!path.empty()) {
const auto parent = path.parent_path();
manager.add_search_path(parent / "PlugIns");
manager.add_search_path(parent / "Frameworks");
manager.add_search_path(parent / "MacOS");
}
#endif
return path;
}