BuildMacOSUniversalBinary.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. #!/usr/bin/env python3
  2. """
  3. The current tooling supported in CMake, Homebrew, and Qt5 are insufficient for
  4. creating macOS universal binaries automatically for applications like Dolphin
  5. which have more complicated build requirements (like different libraries, build
  6. flags and source files for each target architecture).
  7. So instead, this script manages the configuration and compilation of distinct
  8. builds and project files for each target architecture and then merges the two
  9. binaries into a single universal binary.
  10. Running this script will:
  11. 1) Generate Xcode project files for the ARM build (if project files don't
  12. already exist)
  13. 2) Generate Xcode project files for the x64 build (if project files don't
  14. already exist)
  15. 3) Build the ARM project for the selected build_target
  16. 4) Build the x64 project for the selected build_target
  17. 5) Generate universal .app packages combining the ARM and x64 packages
  18. 6) Use the lipo tool to combine the binary objects inside each of the
  19. packages into universal binaries
  20. 7) Code sign the final universal binaries using the specified
  21. codesign_identity
  22. """
  23. import argparse
  24. import filecmp
  25. import glob
  26. import json
  27. import multiprocessing
  28. import os
  29. import shutil
  30. import subprocess
  31. # The config variables listed below are the defaults, but they can be
  32. # overridden by command line arguments see parse_args(), or run:
  33. # BuildMacOSUniversalBinary.py --help
  34. DEFAULT_CONFIG = {
  35. # Location of destination universal binary
  36. "dst_app": "universal/",
  37. # Build Target (dolphin-emu to just build the emulator and skip the tests)
  38. "build_target": "ALL_BUILD",
  39. # Location for CMake to search for files (default is for homebrew)
  40. "arm64_cmake_prefix": "/opt/homebrew",
  41. "x86_64_cmake_prefix": "/usr/local",
  42. # Locations to qt5 directories for arm and x64 libraries
  43. # The default values of these paths are taken from the default
  44. # paths used for homebrew
  45. "arm64_qt5_path": "/opt/homebrew/opt/qt5",
  46. "x86_64_qt5_path": "/usr/local/opt/qt5",
  47. # Identity to use for code signing. "-" indicates that the app will not
  48. # be cryptographically signed/notarized but will instead just use a
  49. # SHA checksum to verify the integrity of the app. This doesn't
  50. # protect against malicious actors, but it does protect against
  51. # running corrupted binaries and allows for access to the extended
  52. # permisions needed for ARM builds
  53. "codesign_identity": "-",
  54. # Minimum macOS version for each architecture slice
  55. "arm64_mac_os_deployment_target": "11.0.0",
  56. "x86_64_mac_os_deployment_target": "10.15.0",
  57. # CMake Generator to use for building
  58. "generator": "Unix Makefiles",
  59. "build_type": "Release",
  60. "run_unit_tests": False,
  61. # Whether our autoupdate functionality is enabled or not.
  62. "autoupdate": True,
  63. # The distributor for this build.
  64. "distributor": "None"
  65. }
  66. # Architectures to build for. This is explicity left out of the command line
  67. # config options for several reasons:
  68. # 1) Adding new architectures will generally require more code changes
  69. # 2) Single architecture builds should utilize the normal generated cmake
  70. # project files rather than this wrapper script
  71. ARCHITECTURES = ["x86_64", "arm64"]
  72. def parse_args(conf=DEFAULT_CONFIG):
  73. """
  74. Parses the command line arguments into a config dictionary.
  75. """
  76. parser = argparse.ArgumentParser(
  77. formatter_class=argparse.ArgumentDefaultsHelpFormatter)
  78. parser.add_argument(
  79. "--target",
  80. help="Build target in generated project files",
  81. default=conf["build_target"],
  82. dest="build_target")
  83. parser.add_argument(
  84. "-G",
  85. help="CMake Generator to use for creating project files",
  86. default=conf["generator"],
  87. dest="generator")
  88. parser.add_argument(
  89. "--build_type",
  90. help="CMake build type [Debug, Release, RelWithDebInfo, MinSizeRel]",
  91. default=conf["build_type"],
  92. dest="build_type")
  93. parser.add_argument(
  94. "--dst_app",
  95. help="Directory where universal binary will be stored",
  96. default=conf["dst_app"])
  97. parser.add_argument("--run_unit_tests", action="store_true",
  98. default=conf["run_unit_tests"])
  99. parser.add_argument(
  100. "--autoupdate",
  101. help="Enables our autoupdate functionality",
  102. action=argparse.BooleanOptionalAction,
  103. default=conf["autoupdate"])
  104. parser.add_argument(
  105. "--distributor",
  106. help="Sets the distributor for this build",
  107. default=conf["distributor"])
  108. parser.add_argument(
  109. "--codesign",
  110. help="Code signing identity to use to sign the applications",
  111. default=conf["codesign_identity"],
  112. dest="codesign_identity")
  113. for arch in ARCHITECTURES:
  114. parser.add_argument(
  115. f"--{arch}_cmake_prefix",
  116. help="Folder for cmake to search for packages",
  117. default=conf[arch+"_cmake_prefix"],
  118. dest=arch+"_cmake_prefix")
  119. parser.add_argument(
  120. f"--{arch}_qt5_path",
  121. help=f"Install path for {arch} qt5 libraries",
  122. default=conf[arch+"_qt5_path"])
  123. parser.add_argument(
  124. f"--{arch}_mac_os_deployment_target",
  125. help=f"Deployment architecture for {arch} slice",
  126. default=conf[arch+"_mac_os_deployment_target"])
  127. return vars(parser.parse_args())
  128. def lipo(path0, path1, dst):
  129. if subprocess.call(["lipo", "-create", "-output", dst, path0, path1]) != 0:
  130. print(f"WARNING: {path0} and {path1} cannot be lipo'd")
  131. shutil.copy(path0, dst)
  132. def recursive_merge_binaries(src0, src1, dst):
  133. """
  134. Merges two build trees together for different architectures into a single
  135. universal binary.
  136. The rules for merging are:
  137. 1) Files that exist in either src tree are copied into the dst tree
  138. 2) Files that exist in both trees and are identical are copied over
  139. unmodified
  140. 3) Files that exist in both trees and are non-identical are lipo'd
  141. 4) Symlinks are created in the destination tree to mirror the hierarchy in
  142. the source trees
  143. """
  144. # Check that all files present in the folder are of the same type and that
  145. # links link to the same relative location
  146. for newpath0 in glob.glob(src0+"/*"):
  147. filename = os.path.basename(newpath0)
  148. newpath1 = os.path.join(src1, filename)
  149. if not os.path.exists(newpath1):
  150. continue
  151. if os.path.islink(newpath0) and os.path.islink(newpath1):
  152. if os.path.relpath(newpath0, src0) == os.path.relpath(newpath1, src1):
  153. continue
  154. if os.path.isdir(newpath0) and os.path.isdir(newpath1):
  155. continue
  156. # isfile() can be true for links so check that both are not links
  157. # before checking if they are both files
  158. if (not os.path.islink(newpath0)) and (not os.path.islink(newpath1)):
  159. if os.path.isfile(newpath0) and os.path.isfile(newpath1):
  160. continue
  161. raise Exception(f"{newpath0} and {newpath1} cannot be " +
  162. "merged into a universal binary because they are of " +
  163. "incompatible types. Perhaps the installed libraries" +
  164. " are from different versions for each architecture")
  165. for newpath0 in glob.glob(src0+"/*"):
  166. filename = os.path.basename(newpath0)
  167. newpath1 = os.path.join(src1, filename)
  168. new_dst_path = os.path.join(dst, filename)
  169. if os.path.islink(newpath0):
  170. # Symlinks will be fixed after files are resolved
  171. continue
  172. if not os.path.exists(newpath1):
  173. if os.path.isdir(newpath0):
  174. shutil.copytree(newpath0, new_dst_path)
  175. else:
  176. shutil.copy(newpath0, new_dst_path)
  177. continue
  178. if os.path.isdir(newpath1):
  179. os.mkdir(new_dst_path)
  180. recursive_merge_binaries(newpath0, newpath1, new_dst_path)
  181. continue
  182. if filecmp.cmp(newpath0, newpath1):
  183. shutil.copy(newpath0, new_dst_path)
  184. else:
  185. lipo(newpath0, newpath1, new_dst_path)
  186. # Loop over files in src1 and copy missing things over to dst
  187. for newpath1 in glob.glob(src1+"/*"):
  188. filename = os.path.basename(newpath1)
  189. newpath0 = os.path.join(src0, filename)
  190. new_dst_path = os.path.join(dst, filename)
  191. if (not os.path.exists(newpath0)) and (not os.path.islink(newpath1)):
  192. if os.path.isdir(newpath1):
  193. shutil.copytree(newpath1, new_dst_path)
  194. else:
  195. shutil.copy(newpath1, new_dst_path)
  196. # Fix up symlinks for path0
  197. for newpath0 in glob.glob(src0+"/*"):
  198. filename = os.path.basename(newpath0)
  199. new_dst_path = os.path.join(dst, filename)
  200. if os.path.islink(newpath0):
  201. relative_path = os.path.relpath(os.path.realpath(newpath0), src0)
  202. os.symlink(relative_path, new_dst_path)
  203. # Fix up symlinks for path1
  204. for newpath1 in glob.glob(src1+"/*"):
  205. filename = os.path.basename(newpath1)
  206. new_dst_path = os.path.join(dst, filename)
  207. newpath0 = os.path.join(src0, filename)
  208. if os.path.islink(newpath1) and not os.path.exists(newpath0):
  209. relative_path = os.path.relpath(os.path.realpath(newpath1), src1)
  210. os.symlink(relative_path, new_dst_path)
  211. def python_to_cmake_bool(boolean):
  212. return "ON" if boolean else "OFF"
  213. def build(config):
  214. """
  215. Builds the project with the parameters specified in config.
  216. """
  217. print("Building config:")
  218. print(json.dumps(config, indent=4))
  219. # Configure and build single architecture builds for each architecture
  220. for arch in ARCHITECTURES:
  221. if not os.path.exists(arch):
  222. os.mkdir(arch)
  223. # Place Qt on the prefix path.
  224. prefix_path = config[arch+"_qt5_path"]+';'+config[arch+"_cmake_prefix"]
  225. env = os.environ.copy()
  226. env["CMAKE_OSX_ARCHITECTURES"] = arch
  227. env["CMAKE_PREFIX_PATH"] = prefix_path
  228. # Add the other architecture's prefix path to the ignore path so that
  229. # CMake doesn't try to pick up the wrong architecture's libraries when
  230. # cross compiling.
  231. ignore_path = ""
  232. for a in ARCHITECTURES:
  233. if a != arch:
  234. ignore_path = config[a+"_cmake_prefix"]
  235. subprocess.check_call([
  236. "cmake", "../../", "-G", config["generator"],
  237. "-DCMAKE_BUILD_TYPE=" + config["build_type"],
  238. '-DCMAKE_CXX_FLAGS="-DMACOS_UNIVERSAL_BUILD=1"',
  239. '-DCMAKE_C_FLAGS="-DMACOS_UNIVERSAL_BUILD=1"',
  240. # System name needs to be specified for CMake to use
  241. # the specified CMAKE_SYSTEM_PROCESSOR
  242. "-DCMAKE_SYSTEM_NAME=Darwin",
  243. "-DCMAKE_PREFIX_PATH="+prefix_path,
  244. "-DCMAKE_SYSTEM_PROCESSOR="+arch,
  245. "-DCMAKE_IGNORE_PATH="+ignore_path,
  246. "-DCMAKE_OSX_DEPLOYMENT_TARGET="
  247. + config[arch+"_mac_os_deployment_target"],
  248. "-DMACOS_CODE_SIGNING_IDENTITY="
  249. + config["codesign_identity"],
  250. '-DMACOS_CODE_SIGNING="ON"',
  251. "-DENABLE_AUTOUPDATE="
  252. + python_to_cmake_bool(config["autoupdate"]),
  253. '-DDISTRIBUTOR=' + config['distributor'],
  254. # Always use libraries from Externals to prevent any libraries
  255. # installed by Homebrew from leaking in to the app
  256. "-DUSE_SYSTEM_LIBS=OFF",
  257. # However, we should still use the macOS provided versions of
  258. # iconv, bzip2, and curl
  259. "-DUSE_SYSTEM_ICONV=ON",
  260. "-DUSE_SYSTEM_BZIP2=ON",
  261. "-DUSE_SYSTEM_CURL=ON"
  262. ],
  263. env=env, cwd=arch)
  264. threads = multiprocessing.cpu_count()
  265. subprocess.check_call(["cmake", "--build", ".",
  266. "--config", config["build_type"],
  267. "--parallel", f"{threads}"], cwd=arch)
  268. dst_app = config["dst_app"]
  269. if os.path.exists(dst_app):
  270. shutil.rmtree(dst_app)
  271. # Create and codesign the universal binary/
  272. os.mkdir(dst_app)
  273. # Source binary trees to merge together
  274. src_app0 = ARCHITECTURES[0]+"/Binaries/"
  275. src_app1 = ARCHITECTURES[1]+"/Binaries/"
  276. recursive_merge_binaries(src_app0, src_app1, dst_app)
  277. if config["autoupdate"]:
  278. subprocess.check_call([
  279. "../Tools/mac-codesign.sh",
  280. "-t",
  281. "-e", "preserve",
  282. config["codesign_identity"],
  283. dst_app+"/Dolphin.app/Contents/Helpers/Dolphin Updater.app"])
  284. subprocess.check_call([
  285. "../Tools/mac-codesign.sh",
  286. "-t",
  287. "-e", "preserve",
  288. config["codesign_identity"],
  289. dst_app+"/Dolphin.app"])
  290. print("Built Universal Binary successfully!")
  291. # Build and run unit tests for each architecture
  292. unit_test_results = {}
  293. if config["run_unit_tests"]:
  294. for arch in ARCHITECTURES:
  295. if not os.path.exists(arch):
  296. os.mkdir(arch)
  297. print(f"Building and running unit tests for: {arch}")
  298. unit_test_results[arch] = \
  299. subprocess.call(["cmake", "--build", ".",
  300. "--config", config["build_type"],
  301. "--target", "unittests",
  302. "--parallel", f"{threads}"], cwd=arch)
  303. passed_unit_tests = True
  304. for a in unit_test_results:
  305. code = unit_test_results[a]
  306. passed = code == 0
  307. status_string = "PASSED"
  308. if not passed:
  309. passed_unit_tests = False
  310. status_string = f"FAILED ({code})"
  311. print(a + " Unit Tests: " + status_string)
  312. if not passed_unit_tests:
  313. exit(-1)
  314. print("Passed all unit tests")
  315. if __name__ == "__main__":
  316. conf = parse_args()
  317. build(conf)