From 37aa68e66c3603cfb3051a9f3ca4870acd7fb9ed Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 2 Jun 2026 21:15:46 +1000 Subject: [PATCH] ifcopenshell plug-in loader: stable anchor + bundle-aware fallbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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 \`/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 \`/PlugIns\`, \`/Frameworks\`, and \`/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 \`/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 --- src/bonsaiviewer/CMakeLists.txt | 34 +++++++++++++++++++++++++++++++++ src/ifcparse/schema.cpp | 14 +++++++++++++- src/plugin/plugin.cpp | 21 ++++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/bonsaiviewer/CMakeLists.txt b/src/bonsaiviewer/CMakeLists.txt index 29ed79e00c..179eee2a2e 100644 --- a/src/bonsaiviewer/CMakeLists.txt +++ b/src/bonsaiviewer/CMakeLists.txt @@ -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 /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 /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() diff --git a/src/ifcparse/schema.cpp b/src/ifcparse/schema.cpp index 5a4741c203..3e8462f584 100644 --- a/src/ifcparse/schema.cpp +++ b/src/ifcparse/schema.cpp @@ -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(&ifcopenshell::load_schema_plugins)); + return plugin::module_directory(&ifcopenshell_libifcparse_anchor); } void ifcopenshell::load_schema_plugins(schema_registry& registry) { diff --git a/src/plugin/plugin.cpp b/src/plugin/plugin.cpp index 288622d4c7..1e0e59844a 100644 --- a/src/plugin/plugin.cpp +++ b/src/plugin/plugin.cpp @@ -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; }