diff --git a/src/MacAppBundle/CMakeLists.txt b/src/MacAppBundle/CMakeLists.txt index 6149d9fac9..bacde13b8f 100644 --- a/src/MacAppBundle/CMakeLists.txt +++ b/src/MacAppBundle/CMakeLists.txt @@ -26,52 +26,133 @@ set(PYTHON_DIR_BASENAME python${Python3_VERSION_MAJOR}.${Python3_VERSION_MINOR}) if(PYTHON_LIBRARY MATCHES "(.*Python\\.framework).*") #framework - set(PYTHON_DIR ${CMAKE_MATCH_1}/Versions/Current/lib/${PYTHON_DIR_BASENAME}) + set(PYTHON_DIR ${CMAKE_MATCH_1}/Versions/${PYTHON_VERSION_MAJOR}.${PYTHON_VERSION_MINOR}/lib/${PYTHON_DIR_BASENAME}) else() #unix get_filename_component(PYTHON_DIR ${PYTHON_LIBRARY} PATH) set(PYTHON_DIR ${PYTHON_DIR}/${PYTHON_DIR_BASENAME}) endif() +message(" PYTHON_DIR is ${PYTHON_DIR} --------------------ipatch--") +message(" PYTHON_DIR_BASENAME is ${PYTHON_DIR_BASENAME} --------------------ipatch--") + install(CODE "execute_process(COMMAND ${CMAKE_COMMAND} -E copy_directory ${PYTHON_DIR} ${CMAKE_INSTALL_LIBDIR}/${PYTHON_DIR_BASENAME} )") if(HOMEBREW_PREFIX) - #Homebrew installs python dependencies to a site dir in prefix/libexec - #and installs a .pth file containing its path to the HOMEBREW_PREFIX site dir. - file(GLOB HOMEBREW_PTH_FILES "${PYTHON_DIR}/site-packages/homebrew*.pth") + set(MACOS_BUNDLE_CONTENTS_DIR "Contents") + set(MACOS_BUNDLE_RESOURCES_DIR "${MACOS_BUNDLE_CONTENTS_DIR}/Resources") + set(MACOS_BUNDLE_EXECUTABLES_DIR "${MACOS_BUNDLE_CONTENTS_DIR}/MacOS") + set(MACOS_BUNDLE_FRAMEWORKS_DIR "${MACOS_BUNDLE_CONTENTS_DIR}/Frameworks") + set(MACOS_BUNDLE_LIB_DIR "${MACOS_BUNDLE_CONTENTS_DIR}/lib") - foreach(PTH_FILE ${HOMEBREW_PTH_FILES}) - file(READ ${PTH_FILE} ADDITIONAL_DIR) + message(" PYTHON_DIR is ${PYTHON_DIR} --------------------ipatch--") - string(STRIP "${ADDITIONAL_DIR}" ADDITIONAL_DIR) - string(FIND "${ADDITIONAL_DIR}" "${HOMEBREW_PREFIX}/Cellar" POSITION) - string(LENGTH "${ADDITIONAL_DIR}" DIR_LENGTH) - string(SUBSTRING "${ADDITIONAL_DIR}" ${POSITION} ${DIR_LENGTH}-${POSITION} DIR_TAIL) - string(REGEX MATCHALL "^([/A-Za-z0-9_.@-]+)" CLEAR_TAIL ${DIR_TAIL}) - string(REGEX REPLACE "^${HOMEBREW_PREFIX}/Cellar/([A-Za-z0-9_]+).*$" "\\1" LIB_NAME ${CLEAR_TAIL}) - string(REGEX REPLACE ".*libexec(.*)/site-packages" "libexec/${LIB_NAME}\\1" NEW_SITE_DIR ${CLEAR_TAIL}) + file(GLOB HOMEBREW_PTH_FILES "${HOMEBREW_PREFIX}/lib/python${PYTHON_VERSION_MAJOR}.${PYTHON_VERSION_MINOR}/site-packages/freecad*.pth") - install(DIRECTORY ${CLEAR_TAIL} DESTINATION ${CMAKE_INSTALL_PREFIX}/${NEW_SITE_DIR}) + message(STATUS "HOMEBREW_PTH_FILES are ${HOMEBREW_PTH_FILES} -----------------------ipatch--") - #update the paths of the .pth files copied into the bundle - get_filename_component(PTH_FILENAME ${PTH_FILE} NAME) - install(CODE - "file(WRITE - ${CMAKE_INSTALL_LIBDIR}/${PYTHON_DIR_BASENAME}/site-packages/${PTH_FILENAME} - \"../../../${NEW_SITE_DIR}/site-packages\" - )" - ) - endforeach(PTH_FILE) + # process each .pth file found + foreach(PTH_FILE ${HOMEBREW_PTH_FILES}) + # read the .pth file into a single string + file(READ ${PTH_FILE} FILE_CONTENT) + + # split the content by newlines into a list of lines + string(REPLACE "\n" ";" LINES "${FILE_CONTENT}") + + # Process each line from the .pth file. + foreach(LINE ${LINES}) + # Remove leading/trailing whitespace. + string(STRIP "${LINE}" CLEAN_LINE) + + # skip empty lines or comments. + if(CLEAN_LINE MATCHES "^#|^$") + continue() + endif() + + # handle both site.addsitedir() calls and direct paths + if(CLEAN_LINE MATCHES "site\\.addsitedir\\('([^']+)'\\)") + # Extract the path from site.addsitedir() function call + string(REGEX MATCH "'([^']+)'" PATH_MATCH "${CLEAN_LINE}") + string(REPLACE "'" "" ADDITIONAL_DIR "${PATH_MATCH}") + elseif(NOT CLEAN_LINE MATCHES "import site") + # Direct path (like the last line in your example) + set(ADDITIONAL_DIR "${CLEAN_LINE}") + else() + # Skip import statements + continue() + endif() + + # check if extracted path is valid + if(ADDITIONAL_DIR AND EXISTS "${ADDITIONAL_DIR}") + message(STATUS "Processing directory: ${ADDITIONAL_DIR}") + + # Check if the path matches the Homebrew Cellar format or other paths + if(ADDITIONAL_DIR MATCHES "${HOMEBREW_PREFIX}/Cellar" OR ADDITIONAL_DIR MATCHES "/usr/local/Cellar") + + # Get the package name from the path for organizing in site-packages + # This handles paths like: /usr/local/Cellar/coin3d@4.0.3_py312/4.0.3_1/lib/python3.12/site-packages/ + # need to extract "coin3d@4.0.3_py312" from the path + string(REGEX MATCH "/Cellar/([^/]+)/" PACKAGE_MATCH "${ADDITIONAL_DIR}") + if(PACKAGE_MATCH) + string(REGEX REPLACE "/Cellar/([^/]+)/" "\\1" PACKAGE_NAME "${PACKAGE_MATCH}") + else() + # Fallback - just use the directory name before version + get_filename_component(PARENT_DIR "${ADDITIONAL_DIR}" DIRECTORY) + get_filename_component(GRANDPARENT_DIR "${PARENT_DIR}" DIRECTORY) + get_filename_component(PACKAGE_NAME "${GRANDPARENT_DIR}" NAME) + endif() + + message(STATUS "Package name: ${PACKAGE_NAME}") + + # Define the destination in the bundle's site-packages + set(BUNDLE_SITE_PACKAGES_DIR "${CMAKE_INSTALL_PREFIX}/lib/python3.12/site-packages") + + # Copy all contents from the source site-packages to bundle site-packages + # Using glob to get all directories and files in the source + file(GLOB PACKAGE_CONTENTS "${ADDITIONAL_DIR}/*") + + foreach(CONTENT_ITEM ${PACKAGE_CONTENTS}) + get_filename_component(ITEM_NAME "${CONTENT_ITEM}" NAME) + + if(IS_DIRECTORY "${CONTENT_ITEM}") + # Install directory + install(DIRECTORY "${CONTENT_ITEM}/" + DESTINATION "${BUNDLE_SITE_PACKAGES_DIR}/${ITEM_NAME}" + USE_SOURCE_PERMISSIONS) + message(STATUS " Installing directory: ${ITEM_NAME}") + else() + # Install file + install(FILES "${CONTENT_ITEM}" + DESTINATION "${BUNDLE_SITE_PACKAGES_DIR}") + message(STATUS " Installing file: ${ITEM_NAME}") + endif() + endforeach() + + message(STATUS "Copied package contents from: ${ADDITIONAL_DIR}") + message(STATUS " To: ${BUNDLE_SITE_PACKAGES_DIR}") + + else() + message(STATUS "Skipping non-Homebrew path: ${ADDITIONAL_DIR}") + endif() + else() + message(STATUS "Path does not exist or is invalid: ${ADDITIONAL_DIR}") + endif() + + # Reset for next iteration + set(ADDITIONAL_DIR "") + + endforeach(LINE) + endforeach(PTH_FILE) endif() set(QT_PLUGINS_DIR "${Qt5Core_DIR}/../../../plugins") execute_process(COMMAND "xcode-select" "--print-path" - OUTPUT_VARIABLE XCODE_PATH - ERROR_QUIET - ) + OUTPUT_VARIABLE XCODE_PATH + ERROR_QUIET +) string(STRIP ${XCODE_PATH} XCODE_PATH) set(XCTEST_PATH "${XCODE_PATH}/Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework/Versions/Current") @@ -79,7 +160,6 @@ set(XCTEST_PATH "${XCODE_PATH}/Platforms/MacOSX.platform/Developer/Library/Frame # add qt assistant to bundle install(PROGRAMS "${Qt5Core_DIR}/../../../libexec/Assistant.app/Contents/MacOS/Assistant" DESTINATION ${CMAKE_INSTALL_PREFIX}/MacOS) - # Ensure the actual plugin files are installed instead of symlinks. file(GLOB _subdirs RELATIVE "${QT_PLUGINS_DIR}" "${QT_PLUGINS_DIR}/*") @@ -102,25 +182,20 @@ install(CODE "execute_process(COMMAND chmod -R a+w ${CMAKE_INSTALL_LIBDIR})") get_filename_component(APP_PATH ${CMAKE_INSTALL_PREFIX} PATH) -file(GLOB CONFIG_ICU "${HOMEBREW_PREFIX}/opt/icu4c/lib") - -file(GLOB CONFIG_LLVM "${HOMEBREW_PREFIX}/opt/llvm/lib/c++") - -file(GLOB CONFIG_GCC "${HOMEBREW_PREFIX}/opt/gcc/lib/gcc/current") - -execute_process( - COMMAND find -L /usr/local/Cellar/nglib -name MacOS - OUTPUT_VARIABLE CONFIG_NGLIB) - install(CODE - "message(STATUS \"Making bundle relocatable...\") - # The top-level CMakeLists.txt should prevent multiple package manager - # prefixes from being set, so the lib path will resolve correctly... - execute_process( - COMMAND python3 - ${CMAKE_SOURCE_DIR}/src/Tools/MakeMacBundleRelocatable.py - ${APP_PATH} ${HOMEBREW_PREFIX}${MACPORTS_PREFIX}/lib ${CONFIG_ICU} ${CONFIG_LLVM} ${CONFIG_GCC} /usr/local/opt ${CONFIG_NGLIB} ${Qt5Core_DIR}/../../.. ${XCTEST_PATH} + "message(STATUS \"Making bundle relocatable...\") + # The top-level CMakeLists.txt should prevent multiple package manager + # prefixes from being set, so the lib path will resolve correctly... + execute_process( + COMMAND python3 + ${CMAKE_SOURCE_DIR}/src/Tools/MakeMacBundleRelocatable.py + ${APP_PATH} + ${HOMEBREW_PREFIX}${MACPORTS_PREFIX}/lib + ${HOMEBREW_PREFIX}/opt + ${HOMEBREW_PREFIX}/opt/*/lib + ${Qt5Core_DIR}/../../.. + ${XCTEST_PATH} )" -) + ) endif(FREECAD_CREATE_MAC_APP) diff --git a/src/Tools/MakeMacBundleRelocatable.py b/src/Tools/MakeMacBundleRelocatable.py index 2d013bfedd..59ab6a5107 100755 --- a/src/Tools/MakeMacBundleRelocatable.py +++ b/src/Tools/MakeMacBundleRelocatable.py @@ -25,6 +25,9 @@ systemPaths = [ # that libraries found there aren't meant to be bundled. warnPaths = ["/Library/Frameworks/"] +# dynamically get homebrew prefix ie. `brew --prefix` +brew_prefix = check_output(["brew", "--prefix"], text=True).strip() + class LibraryNotFound(Exception): pass @@ -120,11 +123,76 @@ def is_system_lib(lib): def get_path(name, search_paths): for path in search_paths: - if os.path.isfile(os.path.join(path, name)): + full_path = os.path.join(path, name) + if os.path.isfile(full_path): return path + # also check if it's a symlink and resolve it + if os.path.islink(full_path): + real_path = os.path.realpath(full_path) + if os.path.isfile(real_path): + return path return None +def resolve_loader_path(lib_path, referencing_lib_path): + """ + resolve @loader_path in lib_path relative to referencing_lib_path + """ + if lib_path.startswith("@loader_path/"): + # get directory containing the referencing library + referencing_dir = os.path.dirname(referencing_lib_path) + # replace @loader_path with referencing directory + resolved_path = lib_path.replace("@loader_path/", referencing_dir + "/") + return resolved_path + return lib_path + + +def get_rpaths_for_resolution(library_path): + """get rpaths from a library for resolving @rpath dependencies""" + try: + rpaths = get_rpaths(library_path) + resolved_rpaths = [] + for rpath in rpaths: + if rpath.startswith("@loader_path"): + # resolve @loader_path in rpath + lib_dir = os.path.dirname(library_path) + resolved = rpath.replace("@loader_path", lib_dir) + resolved_rpaths.append(resolved) + else: + resolved_rpaths.append(rpath) + return resolved_rpaths + except: + return [] + + +def resolve_rpath(lib_path, search_paths, referencing_lib_path=None): + """ + resolve @rpath is lib_path by searching in search_paths and rpaths from referencing library + """ + if lib_path.startswith("@rpath/"): + lib_name = lib_path.replace("@rpath/", "") + + # first check rpaths from the referencing library + if referencing_lib_path: + rpaths = get_rpaths_for_resolution(referencing_lib_path) + for rpath in rpaths: + full_path = os.path.join(rpath, lib_name) + if os.path.isfile(full_path): + return full_path + + # then check search paths as fallback + # search for the library in all search paths + for search_path in search_paths: + full_path = os.path.join(search_path, lib_name) + if os.path.isfile(full_path): + return full_path + if os.path.islink(full_path): + real_path = os.path.realpath(full_path) + if os.path.isfile(real_path): + return full_path + return lib_path + + def list_install_names(path_macho): output = check_output(["otool", "-L", path_macho]) lines = output.split(b"\t") @@ -159,18 +227,32 @@ def library_paths(install_names, search_paths): return paths -def create_dep_nodes(install_names, search_paths): +def create_dep_nodes(install_names, search_paths, referencing_lib_path=None): """ Return a list of Node objects from the provided install names. + referencing_lib_path: path to the library that references these dependencies """ nodes = [] for lib in install_names: + original_lib = lib + + # resolve @loader_path if present + if referencing_lib_path and lib.startswith("@loader_path/"): + lib = resolve_loader_path(lib, referencing_lib_path) + logging.debug( + f"Resolved {original_lib} to {lib} (referencing from {referencing_lib_path})" + ) + + # resolve @rpath if present + elif lib.startswith("@rpath/"): + resolved_lib = resolve_rpath(lib, search_paths) + if resolved_lib != lib: + lib = resolved_lib + logging.debug(f"resolved {original_lib} to {lib}") + install_path = os.path.dirname(lib) lib_name = os.path.basename(lib) - # even if install_path is absolute, see if library can be found by - # searching search_paths, so that we have control over what library - # location to use path = get_path(lib_name, search_paths) if install_path != "" and lib[0] != "@": @@ -179,8 +261,13 @@ def create_dep_nodes(install_names, search_paths): path = install_path if not path: - logging.error("Unable to find LC_DYLD_LOAD entry: " + lib) - raise LibraryNotFound(lib_name + " not found in given search paths") + logging.error("unable to find LC_DYLD_LOAD entry: " + original_lib) + if referencing_lib_path: + logging.error(f" referenced from: {referencing_lib_path}") + logging.error(f" resolved to: {lib}") + logging.error(f" searching for: {lib_name}") + logging.error(f" search paths: {search_paths}") + raise LibraryNotFound(lib_name + " not found in given search paths:") nodes.append(Node(lib_name, path)) @@ -239,6 +326,21 @@ def build_deps_graph(graph, bundle_path, dirs_filter=None, search_paths=[]): s_paths.insert(0, root) + # Automatically add Homebrew Cellar lib directories to search paths + homebrew_cellar = os.path.join(brew_prefix, "Cellar") + if os.path.exists(homebrew_cellar): + for cellar_dir in os.listdir(homebrew_cellar): + cellar_path = os.path.join(homebrew_cellar, cellar_dir) + if os.path.isdir(cellar_path): + # Look for version directories + for version_dir in os.listdir(cellar_path): + version_path = os.path.join(cellar_path, version_dir) + lib_path = os.path.join(version_path, "lib") + if os.path.isdir(lib_path): + if lib_path not in s_paths: + s_paths.append(lib_path) + logging.debug(f"Auto-discovered Homebrew lib path: {lib_path}") + for f in files: fpath = os.path.join(root, f) ext = os.path.splitext(f)[1] @@ -258,7 +360,7 @@ def build_deps_graph(graph, bundle_path, dirs_filter=None, search_paths=[]): graph.add_node(node) try: - deps = create_dep_nodes(list_install_names(k2), s_paths) + deps = create_dep_nodes(list_install_names(k2), s_paths, k2) except Exception: logging.error("Failed to resolve dependency in " + k2) raise @@ -395,7 +497,17 @@ def main(): bundle_path = os.path.abspath(os.path.join(path, "Contents")) graph = DepsGraph() dir_filter = ["MacOS", "lib", "Mod"] - search_paths = [bundle_path + "/lib"] + sys.argv[2:] + + # get the initial search paths + initial_search_paths = [bundle_path + "/lib"] + sys.argv[2:] + + # add additional search paths if required + additional_search_paths = [os.path.join(brew_prefix, "lib", "gcc", "current")] + + # combine the initial + additional search paths + search_paths = initial_search_paths + [ + p for p in additional_search_paths if p not in initial_search_paths + ] # change to level to logging.DEBUG for diagnostic messages logging.basicConfig(