* Previously, the macOS bundle executables were installed into
bin which does not adhere to Apple guidelines and causes
issues with certain frameworks like Qt. The install has
been refactored to install the executables into MacOS
Fixes #2928
390 lines
12 KiB
Python
Executable File
390 lines
12 KiB
Python
Executable File
import os
|
|
import sys
|
|
from subprocess import Popen, PIPE, check_call, check_output
|
|
import pprint
|
|
import re
|
|
import logging
|
|
|
|
# This script is intended to help copy dynamic libraries used by FreeCAD into
|
|
# a Mac application bundle and change dyld commands as appropriate. There are
|
|
# two key items that this currently does differently from other similar tools:
|
|
#
|
|
# * @rpath is used rather than @executable_path because the libraries need to
|
|
# be loadable through a Python interpreter and the FreeCAD binaries.
|
|
# * We need to be able to add multiple rpaths in some libraries.
|
|
|
|
# Assume any libraries in these paths don't need to be bundled
|
|
systemPaths = [ "/System/", "/usr/lib/",
|
|
"/Library/Frameworks/3DconnexionClient.framework/" ]
|
|
|
|
# If a library is in these paths, but not systemPaths, a warning will be
|
|
# issued and it will NOT be bundled. Generally, libraries installed by
|
|
# MacPorts or Homebrew won't end up in /Library/Frameworks, so we assume
|
|
# that libraries found there aren't meant to be bundled.
|
|
warnPaths = ["/Library/Frameworks/"]
|
|
|
|
class LibraryNotFound(Exception):
|
|
pass
|
|
|
|
class Node:
|
|
"""
|
|
self.path should be an absolute path to self.name
|
|
"""
|
|
def __init__(self, name, path="", children=None):
|
|
self.name = name
|
|
self.path = path
|
|
if not children:
|
|
children = list()
|
|
self.children = children
|
|
self._marked = False
|
|
def __eq__(self, other):
|
|
if not isinstance(other, Node):
|
|
return False
|
|
return self.name == other.name
|
|
def __ne__(self, other):
|
|
return not self.__eq__(other)
|
|
def __hash__(self):
|
|
return hash(self.name)
|
|
def __str__(self):
|
|
return self.name + " path: " + self.path + " num children: " + str(len(self.children))
|
|
|
|
class DepsGraph:
|
|
graph = {}
|
|
|
|
def in_graph(self, node):
|
|
return node.name in self.graph.keys()
|
|
|
|
def add_node(self, node):
|
|
self.graph[node.name] = node
|
|
|
|
def get_node(self, name):
|
|
if name in self.graph:
|
|
return self.graph[name]
|
|
return None
|
|
|
|
def visit(self, operation, op_args=[]):
|
|
""""
|
|
Preform a depth first visit of the graph, calling operation
|
|
on each node.
|
|
"""
|
|
stack = []
|
|
|
|
for k in self.graph.keys():
|
|
self.graph[k]._marked = False
|
|
|
|
for k in self.graph.keys():
|
|
if not self.graph[k]._marked:
|
|
stack.append(k)
|
|
while stack:
|
|
node_key = stack.pop()
|
|
self.graph[node_key]._marked = True
|
|
for ck in self.graph[node_key].children:
|
|
if not self.graph[ck]._marked:
|
|
stack.append(ck)
|
|
operation(self, self.graph[node_key], *op_args)
|
|
|
|
|
|
def is_macho(path):
|
|
output = check_output(["file", path])
|
|
if output.count("Mach-O") != 0:
|
|
return True
|
|
|
|
return False
|
|
|
|
def is_system_lib(lib):
|
|
for p in systemPaths:
|
|
if lib.startswith(p):
|
|
return True
|
|
for p in warnPaths:
|
|
if lib.startswith(p):
|
|
logging.warning("WARNING: library %s will not be bundled!" % lib)
|
|
logging.warning("See MakeMacRelocatable.py for more information.")
|
|
return True
|
|
return False
|
|
|
|
def get_path(name, search_paths):
|
|
for path in search_paths:
|
|
if os.path.isfile(os.path.join(path, name)):
|
|
return path
|
|
return None
|
|
|
|
def list_install_names(path_macho):
|
|
output = check_output(["otool", "-L", path_macho])
|
|
lines = output.split("\t")
|
|
libs = []
|
|
|
|
#first line is the the filename, and if it is a library, the second line
|
|
#is the install name of it
|
|
if path_macho.endswith(os.path.basename(lines[1].split(" (")[0])):
|
|
lines = lines[2:]
|
|
else:
|
|
lines = lines[1:]
|
|
|
|
for line in lines:
|
|
lib = line.split(" (")[0]
|
|
if not is_system_lib(lib):
|
|
libs.append(lib)
|
|
return libs
|
|
|
|
def library_paths(install_names, search_paths):
|
|
paths = []
|
|
for name in install_names:
|
|
path = os.path.dirname(name)
|
|
lib_name = os.path.basename(name)
|
|
|
|
if path == "" or name[0] == "@":
|
|
#not absolute -- we need to find the path of this lib
|
|
path = get_path(lib_name, search_paths)
|
|
|
|
paths.append(os.path.join(path, lib_name))
|
|
|
|
return paths
|
|
|
|
def create_dep_nodes(install_names, search_paths):
|
|
"""
|
|
Return a list of Node objects from the provided install names.
|
|
"""
|
|
nodes = []
|
|
for lib in install_names:
|
|
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] != "@":
|
|
#we have an absolute path install name
|
|
if not path:
|
|
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")
|
|
|
|
nodes.append(Node(lib_name, path))
|
|
|
|
return nodes
|
|
|
|
def paths_at_depth(prefix, paths, depth):
|
|
filtered = []
|
|
for p in paths:
|
|
dirs = os.path.join(prefix, p).strip('/').split('/')
|
|
if len(dirs) == depth:
|
|
filtered.append(p)
|
|
return filtered
|
|
|
|
def should_visit(prefix, path_filters, path):
|
|
s_path = path.strip('/').split('/')
|
|
filters = []
|
|
#we only want to use filters if they have the same parent as path
|
|
for rel_pf in path_filters:
|
|
pf = os.path.join(prefix, rel_pf)
|
|
if os.path.split(pf)[0] == os.path.split(path)[0]:
|
|
filters.append(pf)
|
|
if not filters:
|
|
#no filter that applies to this path
|
|
return True
|
|
|
|
for pf in filters:
|
|
s_filter = pf.strip('/').split('/')
|
|
length = len(s_filter)
|
|
matched = 0
|
|
for i in range(len(s_path)):
|
|
if s_path[i] == s_filter[i]:
|
|
matched += 1
|
|
if matched == length or matched == len(s_path):
|
|
return True
|
|
|
|
return False
|
|
|
|
def build_deps_graph(graph, bundle_path, dirs_filter=None, search_paths=[]):
|
|
"""
|
|
Walk bundle_path and build a graph of the encountered Mach-O binaries
|
|
and there dependencies
|
|
"""
|
|
#make a local copy since we add to it
|
|
s_paths = list(search_paths)
|
|
|
|
visited = {}
|
|
|
|
for root, dirs, files in os.walk(bundle_path):
|
|
if dirs_filter != None:
|
|
dirs[:] = [d for d in dirs if should_visit(bundle_path, dirs_filter,
|
|
os.path.join(root, d))]
|
|
|
|
s_paths.insert(0, root)
|
|
|
|
for f in files:
|
|
fpath = os.path.join(root, f)
|
|
ext = os.path.splitext(f)[1]
|
|
if ( (ext == "" and is_macho(fpath)) or
|
|
ext == ".so" or ext == ".dylib" ):
|
|
visited[fpath] = False
|
|
|
|
stack = []
|
|
for k in visited.keys():
|
|
if not visited[k]:
|
|
stack.append(k)
|
|
while stack:
|
|
k2 = stack.pop()
|
|
visited[k2] = True
|
|
|
|
node = Node(os.path.basename(k2), os.path.dirname(k2))
|
|
if not graph.in_graph(node):
|
|
graph.add_node(node)
|
|
|
|
try:
|
|
deps = create_dep_nodes(list_install_names(k2), s_paths)
|
|
except:
|
|
logging.error("Failed to resolve dependency in " + k2)
|
|
raise
|
|
|
|
for d in deps:
|
|
if d.name not in node.children:
|
|
node.children.append(d.name)
|
|
|
|
dk = os.path.join(d.path, d.name)
|
|
if dk not in visited.keys():
|
|
visited[dk] = False
|
|
if not visited[dk]:
|
|
stack.append(dk)
|
|
|
|
def in_bundle(lib, bundle_path):
|
|
if lib.startswith(bundle_path):
|
|
return True
|
|
return False
|
|
|
|
def copy_into_bundle(graph, node, bundle_path):
|
|
if not in_bundle(node.path, bundle_path):
|
|
source = os.path.join(node.path, node.name)
|
|
target = os.path.join(bundle_path, "lib", node.name)
|
|
logging.info("Bundling {}".format(source))
|
|
|
|
check_call([ "cp", "-L", source, target ])
|
|
|
|
node.path = os.path.dirname(target)
|
|
|
|
#fix permissions
|
|
check_call([ "chmod", "a+w", target ])
|
|
|
|
def get_rpaths(library):
|
|
"Returns a list of rpaths specified within library"
|
|
|
|
out = check_output(["otool", "-l", library])
|
|
|
|
pathRegex = r"^path (.*) \(offset \d+\)$"
|
|
expectingRpath = False
|
|
rpaths = []
|
|
for line in out.split('\n'):
|
|
line = line.strip()
|
|
|
|
if "cmd LC_RPATH" in line:
|
|
expectingRpath = True
|
|
elif "Load command" in line:
|
|
expectingRpath = False
|
|
elif expectingRpath:
|
|
m = re.match(pathRegex, line)
|
|
if m:
|
|
rpaths.append(m.group(1))
|
|
expectingRpath = False
|
|
|
|
return rpaths
|
|
|
|
def add_rpaths(graph, node, bundle_path):
|
|
lib = os.path.join(node.path, node.name)
|
|
|
|
if in_bundle(lib, bundle_path):
|
|
logging.debug(lib)
|
|
|
|
# Remove existing rpaths that could take precedence
|
|
for rpath in get_rpaths(lib):
|
|
logging.debug(" - rpath: " + rpath)
|
|
check_call(["install_name_tool", "-delete_rpath", rpath, lib])
|
|
|
|
if node.children:
|
|
install_names = list_install_names(lib)
|
|
rpaths = []
|
|
|
|
|
|
for install_name in install_names:
|
|
name = os.path.basename(install_name)
|
|
#change install names to use rpaths
|
|
logging.debug(" ~ rpath: " + name + " => @rpath/" + name)
|
|
check_call([ "install_name_tool", "-change",
|
|
install_name, "@rpath/" + name, lib ])
|
|
|
|
dep_node = node.children[node.children.index(name)]
|
|
rel_path = os.path.relpath(graph.get_node(dep_node).path,
|
|
node.path)
|
|
rpath = ""
|
|
if rel_path == ".":
|
|
rpath = "@loader_path/"
|
|
else:
|
|
rpath = "@loader_path/" + rel_path + "/"
|
|
if rpath not in rpaths:
|
|
rpaths.append(rpath)
|
|
|
|
for rpath in rpaths:
|
|
# Ensure that lib has rpath set
|
|
if not rpath in get_rpaths(lib):
|
|
logging.debug(" + rpath: " + rpath)
|
|
check_call([ "install_name_tool", "-add_rpath", rpath, lib ])
|
|
|
|
def change_libid(graph, node, bundle_path):
|
|
lib = os.path.join(node.path, node.name)
|
|
|
|
logging.debug(lib)
|
|
|
|
if in_bundle(lib, bundle_path):
|
|
logging.debug(" ~ id: " + node.name)
|
|
try:
|
|
check_call([ "install_name_tool", "-id", node.name, lib ])
|
|
except:
|
|
logging.warning("Failed to change bundle id {} in lib {}".format(node.name, lib))
|
|
|
|
def print_child(graph, node, path):
|
|
logging.debug(" >" + str(node))
|
|
|
|
def print_node(graph, node, path):
|
|
logging.debug(node)
|
|
graph.visit(print_child, [node])
|
|
|
|
def main():
|
|
if len(sys.argv) < 2:
|
|
print "Usage " + sys.argv[0] + " path [additional search paths]"
|
|
quit()
|
|
|
|
path = sys.argv[1]
|
|
bundle_path = os.path.abspath(os.path.join(path, "Contents"))
|
|
graph = DepsGraph()
|
|
dir_filter = ["MacOS", "lib", "Mod",
|
|
"lib/python2.7/site-packages",
|
|
"lib/python2.7/lib-dynload"]
|
|
search_paths = [bundle_path + "/lib"] + sys.argv[2:]
|
|
|
|
#change to level to logging.DEBUG for diagnostic messages
|
|
logging.basicConfig(stream=sys.stdout, level=logging.INFO,
|
|
format="-- %(levelname)s: %(message)s" )
|
|
|
|
logging.info("Analyzing bundle dependencies...")
|
|
build_deps_graph(graph, bundle_path, dir_filter, search_paths)
|
|
|
|
if logging.getLogger().getEffectiveLevel() == logging.DEBUG:
|
|
graph.visit(print_node, [bundle_path])
|
|
|
|
logging.info("Copying external dependencies to bundle...")
|
|
graph.visit(copy_into_bundle, [bundle_path])
|
|
|
|
logging.info("Updating dynamic loader paths...")
|
|
graph.visit(add_rpaths, [bundle_path])
|
|
|
|
logging.info("Setting bundled library IDs...")
|
|
graph.visit(change_libid, [bundle_path])
|
|
|
|
logging.info("Done.")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|