Packaging: Fix mac app bundling with homebrew (#24286)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user