osx.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757
  1. #-----------------------------------------------------------------------------
  2. # Copyright (c) 2014-2023, PyInstaller Development Team.
  3. #
  4. # Distributed under the terms of the GNU General Public License (version 2
  5. # or later) with exception for distributing the bootloader.
  6. #
  7. # The full license is in the file COPYING.txt, distributed with this software.
  8. #
  9. # SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
  10. #-----------------------------------------------------------------------------
  11. """
  12. Utils for macOS platform.
  13. """
  14. import math
  15. import os
  16. import pathlib
  17. import subprocess
  18. import shutil
  19. import tempfile
  20. from macholib.mach_o import (
  21. LC_BUILD_VERSION,
  22. LC_CODE_SIGNATURE,
  23. LC_ID_DYLIB,
  24. LC_LOAD_DYLIB,
  25. LC_LOAD_UPWARD_DYLIB,
  26. LC_LOAD_WEAK_DYLIB,
  27. LC_PREBOUND_DYLIB,
  28. LC_REEXPORT_DYLIB,
  29. LC_RPATH,
  30. LC_SEGMENT_64,
  31. LC_SYMTAB,
  32. LC_UUID,
  33. LC_VERSION_MIN_MACOSX,
  34. )
  35. from macholib.MachO import MachO
  36. import macholib.util
  37. import PyInstaller.log as logging
  38. from PyInstaller import compat
  39. logger = logging.getLogger(__name__)
  40. def is_homebrew_env():
  41. """
  42. Check if Python interpreter was installed via Homebrew command 'brew'.
  43. :return: True if Homebrew else otherwise.
  44. """
  45. # Python path prefix should start with Homebrew prefix.
  46. env_prefix = get_homebrew_prefix()
  47. if env_prefix and compat.base_prefix.startswith(env_prefix):
  48. return True
  49. return False
  50. def is_macports_env():
  51. """
  52. Check if Python interpreter was installed via Macports command 'port'.
  53. :return: True if Macports else otherwise.
  54. """
  55. # Python path prefix should start with Macports prefix.
  56. env_prefix = get_macports_prefix()
  57. if env_prefix and compat.base_prefix.startswith(env_prefix):
  58. return True
  59. return False
  60. def get_homebrew_prefix():
  61. """
  62. :return: Root path of the Homebrew environment.
  63. """
  64. prefix = shutil.which('brew')
  65. # Conversion: /usr/local/bin/brew -> /usr/local
  66. prefix = os.path.dirname(os.path.dirname(prefix))
  67. return prefix
  68. def get_macports_prefix():
  69. """
  70. :return: Root path of the Macports environment.
  71. """
  72. prefix = shutil.which('port')
  73. # Conversion: /usr/local/bin/port -> /usr/local
  74. prefix = os.path.dirname(os.path.dirname(prefix))
  75. return prefix
  76. def _find_version_cmd(header):
  77. """
  78. Helper that finds the version command in the given MachO header.
  79. """
  80. # The SDK version is stored in LC_BUILD_VERSION command (used when targeting the latest versions of macOS) or in
  81. # older LC_VERSION_MIN_MACOSX command. Check for presence of either.
  82. version_cmd = [cmd for cmd in header.commands if cmd[0].cmd in {LC_BUILD_VERSION, LC_VERSION_MIN_MACOSX}]
  83. assert len(version_cmd) == 1, \
  84. f"Expected exactly one LC_BUILD_VERSION or LC_VERSION_MIN_MACOSX command, found {len(version_cmd)}!"
  85. return version_cmd[0]
  86. def get_macos_sdk_version(filename):
  87. """
  88. Obtain the version of macOS SDK against which the given binary was built.
  89. NOTE: currently, version is retrieved only from the first arch slice in the binary.
  90. :return: (major, minor, revision) tuple
  91. """
  92. binary = MachO(filename)
  93. header = binary.headers[0]
  94. # Find version command using helper
  95. version_cmd = _find_version_cmd(header)
  96. return _hex_triplet(version_cmd[1].sdk)
  97. def _hex_triplet(version):
  98. # Parse SDK version number
  99. major = (version & 0xFF0000) >> 16
  100. minor = (version & 0xFF00) >> 8
  101. revision = (version & 0xFF)
  102. return major, minor, revision
  103. def macosx_version_min(filename: str) -> tuple:
  104. """
  105. Get the -macosx-version-min used to compile a macOS binary.
  106. For fat binaries, the minimum version is selected.
  107. """
  108. versions = []
  109. for header in MachO(filename).headers:
  110. cmd = _find_version_cmd(header)
  111. if cmd[0].cmd == LC_VERSION_MIN_MACOSX:
  112. versions.append(cmd[1].version)
  113. else:
  114. # macOS >= 10.14 uses LC_BUILD_VERSION instead.
  115. versions.append(cmd[1].minos)
  116. return min(map(_hex_triplet, versions))
  117. def set_macos_sdk_version(filename, major, minor, revision):
  118. """
  119. Overwrite the macOS SDK version declared in the given binary with the specified version.
  120. NOTE: currently, only version in the first arch slice is modified.
  121. """
  122. # Validate values
  123. assert 0 <= major <= 255, "Invalid major version value!"
  124. assert 0 <= minor <= 255, "Invalid minor version value!"
  125. assert 0 <= revision <= 255, "Invalid revision value!"
  126. # Open binary
  127. binary = MachO(filename)
  128. header = binary.headers[0]
  129. # Find version command using helper
  130. version_cmd = _find_version_cmd(header)
  131. # Write new SDK version number
  132. version_cmd[1].sdk = major << 16 | minor << 8 | revision
  133. # Write changes back.
  134. with open(binary.filename, 'rb+') as fp:
  135. binary.write(fp)
  136. def fix_exe_for_code_signing(filename):
  137. """
  138. Fixes the Mach-O headers to make code signing possible.
  139. Code signing on macOS does not work out of the box with embedding .pkg archive into the executable.
  140. The fix is done this way:
  141. - Make the embedded .pkg archive part of the Mach-O 'String Table'. 'String Table' is at end of the macOS exe file,
  142. so just change the size of the table to cover the end of the file.
  143. - Fix the size of the __LINKEDIT segment.
  144. Note: the above fix works only if the single-arch thin executable or the last arch slice in a multi-arch fat
  145. executable is not signed, because LC_CODE_SIGNATURE comes after LC_SYMTAB, and because modification of headers
  146. invalidates the code signature. On modern arm64 macOS, code signature is mandatory, and therefore compilers
  147. create a dummy signature when executable is built. In such cases, that signature needs to be removed before this
  148. function is called.
  149. Mach-O format specification: http://developer.apple.com/documentation/Darwin/Reference/ManPages/man5/Mach-O.5.html
  150. """
  151. # Estimate the file size after data was appended
  152. file_size = os.path.getsize(filename)
  153. # Take the last available header. A single-arch thin binary contains a single slice, while a multi-arch fat binary
  154. # contains multiple, and we need to modify the last one, which is adjacent to the appended data.
  155. executable = MachO(filename)
  156. header = executable.headers[-1]
  157. # Sanity check: ensure the executable slice is not signed (otherwise signature's section comes last in the
  158. # __LINKEDIT segment).
  159. sign_sec = [cmd for cmd in header.commands if cmd[0].cmd == LC_CODE_SIGNATURE]
  160. assert len(sign_sec) == 0, "Executable contains code signature!"
  161. # Find __LINKEDIT segment by name (16-byte zero padded string)
  162. __LINKEDIT_NAME = b'__LINKEDIT\x00\x00\x00\x00\x00\x00'
  163. linkedit_seg = [cmd for cmd in header.commands if cmd[0].cmd == LC_SEGMENT_64 and cmd[1].segname == __LINKEDIT_NAME]
  164. assert len(linkedit_seg) == 1, "Expected exactly one __LINKEDIT segment!"
  165. linkedit_seg = linkedit_seg[0][1] # Take the segment command entry
  166. # Find SYMTAB section
  167. symtab_sec = [cmd for cmd in header.commands if cmd[0].cmd == LC_SYMTAB]
  168. assert len(symtab_sec) == 1, "Expected exactly one SYMTAB section!"
  169. symtab_sec = symtab_sec[0][1] # Take the symtab command entry
  170. # The string table is located at the end of the SYMTAB section, which in turn is the last section in the __LINKEDIT
  171. # segment. Therefore, the end of SYMTAB section should be aligned with the end of __LINKEDIT segment, and in turn
  172. # both should be aligned with the end of the file (as we are in the last or the only arch slice).
  173. #
  174. # However, when removing the signature from the executable using codesign under macOS 10.13, the codesign utility
  175. # may produce an invalid file, with the declared length of the __LINKEDIT segment (linkedit_seg.filesize) pointing
  176. # beyond the end of file, as reported in issue #6167.
  177. #
  178. # We can compensate for that by not using the declared sizes anywhere, and simply recompute them. In the final
  179. # binary, the __LINKEDIT segment and the SYMTAB section MUST end at the end of the file (otherwise, we have bigger
  180. # issues...). So simply recompute the declared sizes as difference between the final file length and the
  181. # corresponding start offset (NOTE: the offset is relative to start of the slice, which is stored in header.offset.
  182. # In thin binaries, header.offset is zero and start offset is relative to the start of file, but with fat binaries,
  183. # header.offset is non-zero)
  184. symtab_sec.strsize = file_size - (header.offset + symtab_sec.stroff)
  185. linkedit_seg.filesize = file_size - (header.offset + linkedit_seg.fileoff)
  186. # Compute new vmsize by rounding filesize up to full page size.
  187. page_size = (0x4000 if _get_arch_string(header.header).startswith('arm64') else 0x1000)
  188. linkedit_seg.vmsize = math.ceil(linkedit_seg.filesize / page_size) * page_size
  189. # NOTE: according to spec, segments need to be aligned to page boundaries: 0x4000 (16 kB) for arm64, 0x1000 (4 kB)
  190. # for other arches. But it seems we can get away without rounding and padding the segment file size - perhaps
  191. # because it is the last one?
  192. # Write changes
  193. with open(filename, 'rb+') as fp:
  194. executable.write(fp)
  195. # In fat binaries, we also need to adjust the fat header. macholib as of version 1.14 does not support this, so we
  196. # need to do it ourselves...
  197. if executable.fat:
  198. from macholib.mach_o import (FAT_MAGIC, FAT_MAGIC_64, fat_arch, fat_arch64, fat_header)
  199. with open(filename, 'rb+') as fp:
  200. # Taken from MachO.load_fat() implementation. The fat header's signature has already been validated when we
  201. # loaded the file for the first time.
  202. fat = fat_header.from_fileobj(fp)
  203. if fat.magic == FAT_MAGIC:
  204. archs = [fat_arch.from_fileobj(fp) for i in range(fat.nfat_arch)]
  205. elif fat.magic == FAT_MAGIC_64:
  206. archs = [fat_arch64.from_fileobj(fp) for i in range(fat.nfat_arch)]
  207. # Adjust the size in the fat header for the last slice.
  208. arch = archs[-1]
  209. arch.size = file_size - arch.offset
  210. # Now write the fat headers back to the file.
  211. fp.seek(0)
  212. fat.to_fileobj(fp)
  213. for arch in archs:
  214. arch.to_fileobj(fp)
  215. def _get_arch_string(header):
  216. """
  217. Converts cputype and cpusubtype from mach_o.mach_header_64 into arch string comparible with lipo/codesign.
  218. The list of supported architectures can be found in man(1) arch.
  219. """
  220. # NOTE: the constants below are taken from macholib.mach_o
  221. cputype = header.cputype
  222. cpusubtype = header.cpusubtype & 0x0FFFFFFF
  223. if cputype == 0x01000000 | 7:
  224. if cpusubtype == 8:
  225. return 'x86_64h' # 64-bit intel (haswell)
  226. else:
  227. return 'x86_64' # 64-bit intel
  228. elif cputype == 0x01000000 | 12:
  229. if cpusubtype == 2:
  230. return 'arm64e'
  231. else:
  232. return 'arm64'
  233. elif cputype == 7:
  234. return 'i386' # 32-bit intel
  235. assert False, 'Unhandled architecture!'
  236. def update_exe_identifier(filename, pkg_filename):
  237. """
  238. Modifies the Mach-O image UUID stored in the LC_UUID command (if present) in order to ensure that different
  239. frozen applications have different identifiers. See TN3178 for details on why this is required:
  240. https://developer.apple.com/documentation/technotes/tn3178-checking-for-and-resolving-build-uuid-problems
  241. """
  242. # Compute hash of the PKG
  243. import hashlib
  244. pkg_hash = hashlib.sha1()
  245. with open(pkg_filename, 'rb') as fp:
  246. for chunk in iter(lambda: fp.read(8192), b""):
  247. pkg_hash.update(chunk)
  248. # Modify UUID in all arch slices of the executable.
  249. executable = MachO(filename)
  250. for header in executable.headers:
  251. # Find LC_UUID command
  252. uuid_cmd = [cmd for cmd in header.commands if cmd[0].cmd == LC_UUID]
  253. if not uuid_cmd:
  254. continue
  255. uuid_cmd = uuid_cmd[0]
  256. # Read the existing UUID (which is based on bootloader executable itself).
  257. original_uuid = uuid_cmd[1].uuid
  258. # Add original UUID to the hash; this is similar to what UUID v3/v5 do with namespace + name, except
  259. # that in our case, the prefix UUID (namespace) is added at the end, so that PKG hash needs to be
  260. # (pre)computed only once.
  261. combined_hash = pkg_hash.copy()
  262. combined_hash.update(original_uuid)
  263. new_uuid = combined_hash.digest()[:16] # Same as uuid.uuid3() / uuid.uuid5().
  264. assert len(new_uuid) == 16
  265. uuid_cmd[1].uuid = new_uuid
  266. # Write changes
  267. with open(filename, 'rb+') as fp:
  268. executable.write(fp)
  269. class InvalidBinaryError(Exception):
  270. """
  271. Exception raised by `get_binary_architectures` when it is passed an invalid binary.
  272. """
  273. pass
  274. class IncompatibleBinaryArchError(Exception):
  275. """
  276. Exception raised by `binary_to_target_arch` when the passed binary fails the strict architecture check.
  277. """
  278. def __init__(self, message):
  279. url = "https://pyinstaller.org/en/stable/feature-notes.html#macos-multi-arch-support"
  280. super().__init__(f"{message} For details about this error message, see: {url}")
  281. def get_binary_architectures(filename):
  282. """
  283. Inspects the given binary and returns tuple (is_fat, archs), where is_fat is boolean indicating fat/thin binary,
  284. and arch is list of architectures with lipo/codesign compatible names.
  285. """
  286. try:
  287. executable = MachO(filename)
  288. except ValueError as e:
  289. raise InvalidBinaryError("Invalid Mach-O binary!") from e
  290. return bool(executable.fat), [_get_arch_string(hdr.header) for hdr in executable.headers]
  291. def convert_binary_to_thin_arch(filename, thin_arch, output_filename=None):
  292. """
  293. Convert the given fat binary into thin one with the specified target architecture.
  294. """
  295. output_filename = output_filename or filename
  296. cmd_args = ['lipo', '-thin', thin_arch, filename, '-output', output_filename]
  297. p = subprocess.run(cmd_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding='utf-8')
  298. if p.returncode:
  299. raise SystemError(f"lipo command ({cmd_args}) failed with error code {p.returncode}!\noutput: {p.stdout}")
  300. def merge_into_fat_binary(output_filename, *slice_filenames):
  301. """
  302. Merge the given single-arch thin binary files into a fat binary.
  303. """
  304. cmd_args = ['lipo', '-create', '-output', output_filename, *slice_filenames]
  305. p = subprocess.run(cmd_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding='utf-8')
  306. if p.returncode:
  307. raise SystemError(f"lipo command ({cmd_args}) failed with error code {p.returncode}!\noutput: {p.stdout}")
  308. def binary_to_target_arch(filename, target_arch, display_name=None):
  309. """
  310. Check that the given binary contains required architecture slice(s) and convert the fat binary into thin one,
  311. if necessary.
  312. """
  313. if not display_name:
  314. display_name = filename # Same as input file
  315. # Check the binary
  316. is_fat, archs = get_binary_architectures(filename)
  317. if target_arch == 'universal2':
  318. if not is_fat:
  319. raise IncompatibleBinaryArchError(f"{display_name} is not a fat binary!")
  320. # Assume fat binary is universal2; nothing to do
  321. else:
  322. if is_fat:
  323. if target_arch not in archs:
  324. raise IncompatibleBinaryArchError(f"{display_name} does not contain slice for {target_arch}!")
  325. # Convert to thin arch
  326. logger.debug("Converting fat binary %s (%s) to thin binary (%s)", filename, display_name, target_arch)
  327. convert_binary_to_thin_arch(filename, target_arch)
  328. else:
  329. if target_arch not in archs:
  330. raise IncompatibleBinaryArchError(
  331. f"{display_name} is incompatible with target arch {target_arch} (has arch: {archs[0]})!"
  332. )
  333. # Binary has correct arch; nothing to do
  334. def remove_signature_from_binary(filename):
  335. """
  336. Remove the signature from all architecture slices of the given binary file using the codesign utility.
  337. """
  338. logger.debug("Removing signature from file %r", filename)
  339. cmd_args = ['/usr/bin/codesign', '--remove', '--all-architectures', filename]
  340. p = subprocess.run(cmd_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding='utf-8')
  341. if p.returncode:
  342. raise SystemError(f"codesign command ({cmd_args}) failed with error code {p.returncode}!\noutput: {p.stdout}")
  343. def sign_binary(filename, identity=None, entitlements_file=None, deep=False):
  344. """
  345. Sign the binary using codesign utility. If no identity is provided, ad-hoc signing is performed.
  346. """
  347. extra_args = []
  348. if not identity:
  349. identity = '-' # ad-hoc signing
  350. else:
  351. extra_args.append('--options=runtime') # hardened runtime
  352. if entitlements_file:
  353. extra_args.append('--entitlements')
  354. extra_args.append(entitlements_file)
  355. if deep:
  356. extra_args.append('--deep')
  357. logger.debug("Signing file %r", filename)
  358. cmd_args = [
  359. '/usr/bin/codesign', '-s', identity, '--force', '--all-architectures', '--timestamp', *extra_args, filename
  360. ]
  361. p = subprocess.run(cmd_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding='utf-8')
  362. if p.returncode:
  363. raise SystemError(f"codesign command ({cmd_args}) failed with error code {p.returncode}!\noutput: {p.stdout}")
  364. def set_dylib_dependency_paths(filename, target_rpath):
  365. """
  366. Modify the given dylib's identity (in LC_ID_DYLIB command) and the paths to dependent dylibs (in LC_LOAD_DYLIB)
  367. commands into `@rpath/<basename>` format, remove any existing rpaths (LC_RPATH commands), and add a new rpath
  368. (LC_RPATH command) with the specified path.
  369. Uses `install-tool-name` utility to make the changes.
  370. The system libraries (e.g., the ones found in /usr/lib) are exempted from path rewrite.
  371. For multi-arch fat binaries, this function extracts each slice into temporary file, processes it separately,
  372. and then merges all processed slices back into fat binary. This is necessary because `install-tool-name` cannot
  373. modify rpaths in cases when an existing rpath is present only in one slice.
  374. """
  375. # Check if we are dealing with a fat binary; the `install-name-tool` seems to be unable to remove an rpath that is
  376. # present only in one slice, so we need to extract each slice, process it separately, and then stich processed
  377. # slices back into a fat binary.
  378. is_fat, archs = get_binary_architectures(filename)
  379. if is_fat:
  380. with tempfile.TemporaryDirectory() as tmpdir:
  381. slice_filenames = []
  382. for arch in archs:
  383. slice_filename = os.path.join(tmpdir, arch)
  384. convert_binary_to_thin_arch(filename, arch, output_filename=slice_filename)
  385. _set_dylib_dependency_paths(slice_filename, target_rpath)
  386. slice_filenames.append(slice_filename)
  387. merge_into_fat_binary(filename, *slice_filenames)
  388. else:
  389. # Thin binary - we can process it directly
  390. _set_dylib_dependency_paths(filename, target_rpath)
  391. def _set_dylib_dependency_paths(filename, target_rpath):
  392. """
  393. The actual implementation of set_dylib_dependency_paths functionality.
  394. Implicitly assumes that a single-arch thin binary is given.
  395. """
  396. # Relocatable commands that we should overwrite - same list as used by `macholib`.
  397. _RELOCATABLE = {
  398. LC_LOAD_DYLIB,
  399. LC_LOAD_UPWARD_DYLIB,
  400. LC_LOAD_WEAK_DYLIB,
  401. LC_PREBOUND_DYLIB,
  402. LC_REEXPORT_DYLIB,
  403. }
  404. # Parse dylib's header to extract the following commands:
  405. # - LC_LOAD_DYLIB (or any member of _RELOCATABLE list): dylib load commands (dependent libraries)
  406. # - LC_RPATH: rpath definitions
  407. # - LC_ID_DYLIB: dylib's identity
  408. binary = MachO(filename)
  409. dylib_id = None
  410. rpaths = set()
  411. linked_libs = set()
  412. for header in binary.headers:
  413. for cmd in header.commands:
  414. lc_type = cmd[0].cmd
  415. if lc_type not in _RELOCATABLE and lc_type not in {LC_RPATH, LC_ID_DYLIB}:
  416. continue
  417. # Decode path, strip trailing NULL characters
  418. path = cmd[2].decode('utf-8').rstrip('\x00')
  419. if lc_type in _RELOCATABLE:
  420. linked_libs.add(path)
  421. elif lc_type == LC_RPATH:
  422. rpaths.add(path)
  423. elif lc_type == LC_ID_DYLIB:
  424. dylib_id = path
  425. del binary
  426. # If dylib has identifier set, compute the normalized version, in form of `@rpath/basename`.
  427. normalized_dylib_id = None
  428. if dylib_id:
  429. normalized_dylib_id = str(pathlib.PurePath('@rpath') / pathlib.PurePath(dylib_id).name)
  430. # Find dependent libraries that should have their prefix path changed to `@rpath`. If any dependent libraries
  431. # end up using `@rpath` (originally or due to rewrite), set the `rpath_required` boolean to True, so we know
  432. # that we need to add our rpath.
  433. changed_lib_paths = []
  434. rpath_required = False
  435. for linked_lib in linked_libs:
  436. # Leave system dynamic libraries unchanged.
  437. if macholib.util.in_system_path(linked_lib):
  438. continue
  439. # The older python.org builds that use system Tcl/Tk framework have their _tkinter.cpython-*-darwin.so
  440. # library linked against /Library/Frameworks/Tcl.framework/Versions/8.5/Tcl and
  441. # /Library/Frameworks/Tk.framework/Versions/8.5/Tk, although the actual frameworks are located in
  442. # /System/Library/Frameworks. Therefore, they slip through the above in_system_path() check, and we need to
  443. # exempt them manually.
  444. _exemptions = [
  445. '/Library/Frameworks/Tcl.framework/',
  446. '/Library/Frameworks/Tk.framework/',
  447. ]
  448. if any([x in linked_lib for x in _exemptions]):
  449. continue
  450. # This linked library will end up using `@rpath`, whether modified or not...
  451. rpath_required = True
  452. new_path = str(pathlib.PurePath('@rpath') / pathlib.PurePath(linked_lib).name)
  453. if linked_lib == new_path:
  454. continue
  455. changed_lib_paths.append((linked_lib, new_path))
  456. # Gather arguments for `install-name-tool`
  457. install_name_tool_args = []
  458. # Modify the dylib identifier if necessary
  459. if normalized_dylib_id and normalized_dylib_id != dylib_id:
  460. install_name_tool_args += ["-id", normalized_dylib_id]
  461. # Changed libs
  462. for original_path, new_path in changed_lib_paths:
  463. install_name_tool_args += ["-change", original_path, new_path]
  464. # Remove all existing rpaths except for the target rpath (if it already exists). `install_name_tool` disallows using
  465. # `-delete_rpath` and `-add_rpath` with the same argument.
  466. for rpath in rpaths:
  467. if rpath == target_rpath:
  468. continue
  469. install_name_tool_args += [
  470. "-delete_rpath",
  471. rpath,
  472. ]
  473. # If any of linked libraries use @rpath now and our target rpath is not already added, add it.
  474. # NOTE: @rpath in the dylib identifier does not actually require the rpath to be set on the binary...
  475. if rpath_required and target_rpath not in rpaths:
  476. install_name_tool_args += [
  477. "-add_rpath",
  478. target_rpath,
  479. ]
  480. # If we have no arguments, finish immediately.
  481. if not install_name_tool_args:
  482. return
  483. # Run `install_name_tool`
  484. cmd_args = ["install_name_tool", *install_name_tool_args, filename]
  485. p = subprocess.run(cmd_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding='utf-8')
  486. if p.returncode:
  487. raise SystemError(
  488. f"install_name_tool command ({cmd_args}) failed with error code {p.returncode}!\noutput: {p.stdout}"
  489. )
  490. def is_framework_bundle_lib(lib_path):
  491. """
  492. Check if the given shared library is part of a .framework bundle.
  493. """
  494. lib_path = pathlib.PurePath(lib_path)
  495. # For now, focus only on versioned layout, such as `QtCore.framework/Versions/5/QtCore`
  496. if lib_path.parent.parent.name != "Versions":
  497. return False
  498. if lib_path.parent.parent.parent.name != lib_path.name + ".framework":
  499. return False
  500. return True
  501. def collect_files_from_framework_bundles(collected_files):
  502. """
  503. Scan the given TOC list of collected files for shared libraries that are collected from macOS .framework bundles,
  504. and collect the bundles' Info.plist files. Additionally, create/restore the following symbolic links:
  505. - `Versions/Current` pointing to the `Versions/<version>` directory containing the binary
  506. - `<name>` in the top-level .framework directory, pointing to `Versions/Current/<name>`
  507. - `Resources` in the top-level .framework directory, pointing to `Versions/Current/Resources`
  508. - additional directories in top-level .framework directory, pointing to their counterparts in `Versions/Current`
  509. directory.
  510. Returns updated TOC list with added entries for the discovered Info.plist files and generated symbolic links.
  511. """
  512. invalid_framework_found = False
  513. framework_entries = set() # Additional TOC entries for collected files. Use set for de-duplication.
  514. framework_paths = set() # Registered framework paths for 2nd pass.
  515. framework_symlinked_dirs = set() # Symlinked directories for filtering in 3rd pass.
  516. # 1st pass: discover binaries from .framework bundles, and for each such binary:
  517. # - collect `Info.plist`
  518. # - create `Current` -> `<version>` symlink in `<name>.framework/Versions` directory.
  519. # - create `<name>.framework/<name>` -> `<name>.framework/Versions/Current/<name>` symlink.
  520. # - create `<name>.framework/Resources` -> `<name>.framework/Versions/Current/Resources` symlink.
  521. for dest_name, src_name, typecode in collected_files:
  522. if typecode != 'BINARY':
  523. continue
  524. src_path = pathlib.Path(src_name) # /src/path/to/<name>.framework/Versions/<version>/<name>
  525. dest_path = pathlib.PurePath(dest_name) # /dest/path/to/<name>.framework/Versions/<version>/<name>
  526. # Check whether binary originates from a .framework bundle
  527. if not is_framework_bundle_lib(src_path):
  528. continue
  529. # Check whether binary is also collected into a .framework bundle (i.e., the original layout is preserved)
  530. if not is_framework_bundle_lib(dest_path):
  531. continue
  532. # Assuming versioned layout, Info.plist should exist in Resources directory located next to the binary.
  533. info_plist_src = src_path.parent / "Resources" / "Info.plist"
  534. if not info_plist_src.is_file():
  535. # Alas, the .framework bundles shipped with PySide/PyQt might have Info.plist available only in the
  536. # top-level Resources directory. So accommodate this scenario as well, but collect the file into
  537. # versioned directory to appease the code-signing gods...
  538. info_plist_src_top = src_path.parent.parent.parent / "Resources" / "Info.plist"
  539. if not info_plist_src_top.is_file():
  540. # Strictly speaking, a .framework bundle without Info.plist is invalid. However, that did not prevent
  541. # PyQt from shipping such Qt .framework bundles up until v5.14.1. So by default, we just complain via
  542. # a warning message; if such binaries work in unfrozen python, they should also work in frozen
  543. # application. The codesign will refuse to sign the .app bundle (if we are generating one), but there
  544. # is nothing we can do about that.
  545. invalid_framework_found = True
  546. framework_dir = src_path.parent.parent.parent
  547. if compat.strict_collect_mode:
  548. raise SystemError(f"Could not find Info.plist in {framework_dir}!")
  549. else:
  550. logger.warning("Could not find Info.plist in %s!", framework_dir)
  551. continue
  552. info_plist_src = info_plist_src_top
  553. info_plist_dest = dest_path.parent / "Resources" / "Info.plist"
  554. framework_entries.add((str(info_plist_dest), str(info_plist_src), "DATA"))
  555. # Reconstruct the symlink Versions/Current -> Versions/<version>.
  556. # This one seems to be necessary for code signing, but might be absent from .framework bundles shipped with
  557. # python packages (i.e., PyPI wheels that do not support symlinks). So we always create it ourselves.
  558. framework_entries.add((str(dest_path.parent.parent / "Current"), str(dest_path.parent.name), "SYMLINK"))
  559. framework_symlinked_dirs.add(dest_path.parent.parent / "Current") # Cleanup in 3rd pass
  560. dest_framework_path = dest_path.parent.parent.parent # Top-level .framework directory path.
  561. # Symlink the binary in the `Current` directory to the top-level .framework directory.
  562. # If TOC also contains an entry for a hard-copy entry in the top-level directory, it will be replaced by this
  563. # symlink entry due to how our TOC normalization works.
  564. framework_entries.add((
  565. str(dest_framework_path / dest_path.name),
  566. str(pathlib.PurePath("Versions/Current") / dest_path.name),
  567. "SYMLINK",
  568. ))
  569. # Ditto for the `Resources` directory.
  570. framework_entries.add((
  571. str(dest_framework_path / "Resources"),
  572. "Versions/Current/Resources",
  573. "SYMLINK",
  574. ))
  575. framework_symlinked_dirs.add(dest_framework_path / "Resources") # Cleanup in 3rd pass
  576. # Register the framework parent path to use in additional directories scan in subsequent pass.
  577. framework_paths.add(dest_framework_path)
  578. # 2nd pass: scan for additional collected directories from .framework bundles, and create symlinks to the top-level
  579. # application directory. Make the outer loop go over the registered framework paths, so it becomes no-op if no
  580. # framework paths are registered.
  581. VALID_SUBDIRS = {'Documentation', 'Frameworks', 'Headers', 'Helpers', 'Libraries', 'Resources'}
  582. for dest_framework_path in framework_paths:
  583. for dest_name, src_name, typecode in collected_files:
  584. dest_path = pathlib.PurePath(dest_name)
  585. # Try matching against framework path
  586. try:
  587. remaining_path = dest_path.relative_to(dest_framework_path)
  588. except ValueError: # dest_path is not subpath of dest_framework_path
  589. continue
  590. remaining_path_parts = remaining_path.parts
  591. # We are interested only in entries under Versions directory.
  592. if remaining_path_parts[0] != 'Versions':
  593. continue
  594. # If the entry name is among valid sub-directory names, create symlink.
  595. dir_name = remaining_path_parts[2]
  596. if dir_name not in VALID_SUBDIRS:
  597. continue
  598. framework_entries.add((
  599. str(dest_framework_path / dir_name),
  600. str(pathlib.PurePath("Versions/Current") / dir_name),
  601. "SYMLINK",
  602. ))
  603. framework_symlinked_dirs.add(dest_framework_path / dir_name) # Cleanup in 3rd pass
  604. # 3rd pass: remove TOC entries under directories for which we are trying to restore symbolic links. These may be
  605. # present when a python package (i.e., a PyPI wheel) ships a .framework bundle where symlinks were mangled into hard
  606. # copies (due to lack of support for symlinks in wheels) AND these hard copies are collected through use of
  607. # `collect_data` / `collect_binaries` / `collect_all` (either by user or by a hook).
  608. if framework_symlinked_dirs:
  609. filtered_toc = []
  610. for dest_name, src_name, typecode in collected_files:
  611. dest_path = pathlib.PurePath(dest_name)
  612. if any(dest_parent in framework_symlinked_dirs for dest_parent in dest_path.parents):
  613. continue # Inside symlinked directory; remove
  614. filtered_toc.append((dest_name, src_name, typecode))
  615. else:
  616. filtered_toc = collected_files
  617. # If we encountered an invalid .framework bundle without Info.plist, warn the user that code-signing will most
  618. # likely fail.
  619. if invalid_framework_found:
  620. logger.warning(
  621. "One or more collected .framework bundles have missing Info.plist file. If you are building an .app "
  622. "bundle, you will most likely not be able to code-sign it."
  623. )
  624. return filtered_toc + sorted(framework_entries)