| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757 |
- #-----------------------------------------------------------------------------
- # Copyright (c) 2014-2023, PyInstaller Development Team.
- #
- # Distributed under the terms of the GNU General Public License (version 2
- # or later) with exception for distributing the bootloader.
- #
- # The full license is in the file COPYING.txt, distributed with this software.
- #
- # SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
- #-----------------------------------------------------------------------------
- """
- Utils for macOS platform.
- """
- import math
- import os
- import pathlib
- import subprocess
- import shutil
- import tempfile
- from macholib.mach_o import (
- LC_BUILD_VERSION,
- LC_CODE_SIGNATURE,
- LC_ID_DYLIB,
- LC_LOAD_DYLIB,
- LC_LOAD_UPWARD_DYLIB,
- LC_LOAD_WEAK_DYLIB,
- LC_PREBOUND_DYLIB,
- LC_REEXPORT_DYLIB,
- LC_RPATH,
- LC_SEGMENT_64,
- LC_SYMTAB,
- LC_UUID,
- LC_VERSION_MIN_MACOSX,
- )
- from macholib.MachO import MachO
- import macholib.util
- import PyInstaller.log as logging
- from PyInstaller import compat
- logger = logging.getLogger(__name__)
- def is_homebrew_env():
- """
- Check if Python interpreter was installed via Homebrew command 'brew'.
- :return: True if Homebrew else otherwise.
- """
- # Python path prefix should start with Homebrew prefix.
- env_prefix = get_homebrew_prefix()
- if env_prefix and compat.base_prefix.startswith(env_prefix):
- return True
- return False
- def is_macports_env():
- """
- Check if Python interpreter was installed via Macports command 'port'.
- :return: True if Macports else otherwise.
- """
- # Python path prefix should start with Macports prefix.
- env_prefix = get_macports_prefix()
- if env_prefix and compat.base_prefix.startswith(env_prefix):
- return True
- return False
- def get_homebrew_prefix():
- """
- :return: Root path of the Homebrew environment.
- """
- prefix = shutil.which('brew')
- # Conversion: /usr/local/bin/brew -> /usr/local
- prefix = os.path.dirname(os.path.dirname(prefix))
- return prefix
- def get_macports_prefix():
- """
- :return: Root path of the Macports environment.
- """
- prefix = shutil.which('port')
- # Conversion: /usr/local/bin/port -> /usr/local
- prefix = os.path.dirname(os.path.dirname(prefix))
- return prefix
- def _find_version_cmd(header):
- """
- Helper that finds the version command in the given MachO header.
- """
- # The SDK version is stored in LC_BUILD_VERSION command (used when targeting the latest versions of macOS) or in
- # older LC_VERSION_MIN_MACOSX command. Check for presence of either.
- version_cmd = [cmd for cmd in header.commands if cmd[0].cmd in {LC_BUILD_VERSION, LC_VERSION_MIN_MACOSX}]
- assert len(version_cmd) == 1, \
- f"Expected exactly one LC_BUILD_VERSION or LC_VERSION_MIN_MACOSX command, found {len(version_cmd)}!"
- return version_cmd[0]
- def get_macos_sdk_version(filename):
- """
- Obtain the version of macOS SDK against which the given binary was built.
- NOTE: currently, version is retrieved only from the first arch slice in the binary.
- :return: (major, minor, revision) tuple
- """
- binary = MachO(filename)
- header = binary.headers[0]
- # Find version command using helper
- version_cmd = _find_version_cmd(header)
- return _hex_triplet(version_cmd[1].sdk)
- def _hex_triplet(version):
- # Parse SDK version number
- major = (version & 0xFF0000) >> 16
- minor = (version & 0xFF00) >> 8
- revision = (version & 0xFF)
- return major, minor, revision
- def macosx_version_min(filename: str) -> tuple:
- """
- Get the -macosx-version-min used to compile a macOS binary.
- For fat binaries, the minimum version is selected.
- """
- versions = []
- for header in MachO(filename).headers:
- cmd = _find_version_cmd(header)
- if cmd[0].cmd == LC_VERSION_MIN_MACOSX:
- versions.append(cmd[1].version)
- else:
- # macOS >= 10.14 uses LC_BUILD_VERSION instead.
- versions.append(cmd[1].minos)
- return min(map(_hex_triplet, versions))
- def set_macos_sdk_version(filename, major, minor, revision):
- """
- Overwrite the macOS SDK version declared in the given binary with the specified version.
- NOTE: currently, only version in the first arch slice is modified.
- """
- # Validate values
- assert 0 <= major <= 255, "Invalid major version value!"
- assert 0 <= minor <= 255, "Invalid minor version value!"
- assert 0 <= revision <= 255, "Invalid revision value!"
- # Open binary
- binary = MachO(filename)
- header = binary.headers[0]
- # Find version command using helper
- version_cmd = _find_version_cmd(header)
- # Write new SDK version number
- version_cmd[1].sdk = major << 16 | minor << 8 | revision
- # Write changes back.
- with open(binary.filename, 'rb+') as fp:
- binary.write(fp)
- def fix_exe_for_code_signing(filename):
- """
- Fixes the Mach-O headers to make code signing possible.
- Code signing on macOS does not work out of the box with embedding .pkg archive into the executable.
- The fix is done this way:
- - Make the embedded .pkg archive part of the Mach-O 'String Table'. 'String Table' is at end of the macOS exe file,
- so just change the size of the table to cover the end of the file.
- - Fix the size of the __LINKEDIT segment.
- Note: the above fix works only if the single-arch thin executable or the last arch slice in a multi-arch fat
- executable is not signed, because LC_CODE_SIGNATURE comes after LC_SYMTAB, and because modification of headers
- invalidates the code signature. On modern arm64 macOS, code signature is mandatory, and therefore compilers
- create a dummy signature when executable is built. In such cases, that signature needs to be removed before this
- function is called.
- Mach-O format specification: http://developer.apple.com/documentation/Darwin/Reference/ManPages/man5/Mach-O.5.html
- """
- # Estimate the file size after data was appended
- file_size = os.path.getsize(filename)
- # Take the last available header. A single-arch thin binary contains a single slice, while a multi-arch fat binary
- # contains multiple, and we need to modify the last one, which is adjacent to the appended data.
- executable = MachO(filename)
- header = executable.headers[-1]
- # Sanity check: ensure the executable slice is not signed (otherwise signature's section comes last in the
- # __LINKEDIT segment).
- sign_sec = [cmd for cmd in header.commands if cmd[0].cmd == LC_CODE_SIGNATURE]
- assert len(sign_sec) == 0, "Executable contains code signature!"
- # Find __LINKEDIT segment by name (16-byte zero padded string)
- __LINKEDIT_NAME = b'__LINKEDIT\x00\x00\x00\x00\x00\x00'
- linkedit_seg = [cmd for cmd in header.commands if cmd[0].cmd == LC_SEGMENT_64 and cmd[1].segname == __LINKEDIT_NAME]
- assert len(linkedit_seg) == 1, "Expected exactly one __LINKEDIT segment!"
- linkedit_seg = linkedit_seg[0][1] # Take the segment command entry
- # Find SYMTAB section
- symtab_sec = [cmd for cmd in header.commands if cmd[0].cmd == LC_SYMTAB]
- assert len(symtab_sec) == 1, "Expected exactly one SYMTAB section!"
- symtab_sec = symtab_sec[0][1] # Take the symtab command entry
- # The string table is located at the end of the SYMTAB section, which in turn is the last section in the __LINKEDIT
- # segment. Therefore, the end of SYMTAB section should be aligned with the end of __LINKEDIT segment, and in turn
- # both should be aligned with the end of the file (as we are in the last or the only arch slice).
- #
- # However, when removing the signature from the executable using codesign under macOS 10.13, the codesign utility
- # may produce an invalid file, with the declared length of the __LINKEDIT segment (linkedit_seg.filesize) pointing
- # beyond the end of file, as reported in issue #6167.
- #
- # We can compensate for that by not using the declared sizes anywhere, and simply recompute them. In the final
- # binary, the __LINKEDIT segment and the SYMTAB section MUST end at the end of the file (otherwise, we have bigger
- # issues...). So simply recompute the declared sizes as difference between the final file length and the
- # corresponding start offset (NOTE: the offset is relative to start of the slice, which is stored in header.offset.
- # In thin binaries, header.offset is zero and start offset is relative to the start of file, but with fat binaries,
- # header.offset is non-zero)
- symtab_sec.strsize = file_size - (header.offset + symtab_sec.stroff)
- linkedit_seg.filesize = file_size - (header.offset + linkedit_seg.fileoff)
- # Compute new vmsize by rounding filesize up to full page size.
- page_size = (0x4000 if _get_arch_string(header.header).startswith('arm64') else 0x1000)
- linkedit_seg.vmsize = math.ceil(linkedit_seg.filesize / page_size) * page_size
- # NOTE: according to spec, segments need to be aligned to page boundaries: 0x4000 (16 kB) for arm64, 0x1000 (4 kB)
- # for other arches. But it seems we can get away without rounding and padding the segment file size - perhaps
- # because it is the last one?
- # Write changes
- with open(filename, 'rb+') as fp:
- executable.write(fp)
- # In fat binaries, we also need to adjust the fat header. macholib as of version 1.14 does not support this, so we
- # need to do it ourselves...
- if executable.fat:
- from macholib.mach_o import (FAT_MAGIC, FAT_MAGIC_64, fat_arch, fat_arch64, fat_header)
- with open(filename, 'rb+') as fp:
- # Taken from MachO.load_fat() implementation. The fat header's signature has already been validated when we
- # loaded the file for the first time.
- fat = fat_header.from_fileobj(fp)
- if fat.magic == FAT_MAGIC:
- archs = [fat_arch.from_fileobj(fp) for i in range(fat.nfat_arch)]
- elif fat.magic == FAT_MAGIC_64:
- archs = [fat_arch64.from_fileobj(fp) for i in range(fat.nfat_arch)]
- # Adjust the size in the fat header for the last slice.
- arch = archs[-1]
- arch.size = file_size - arch.offset
- # Now write the fat headers back to the file.
- fp.seek(0)
- fat.to_fileobj(fp)
- for arch in archs:
- arch.to_fileobj(fp)
- def _get_arch_string(header):
- """
- Converts cputype and cpusubtype from mach_o.mach_header_64 into arch string comparible with lipo/codesign.
- The list of supported architectures can be found in man(1) arch.
- """
- # NOTE: the constants below are taken from macholib.mach_o
- cputype = header.cputype
- cpusubtype = header.cpusubtype & 0x0FFFFFFF
- if cputype == 0x01000000 | 7:
- if cpusubtype == 8:
- return 'x86_64h' # 64-bit intel (haswell)
- else:
- return 'x86_64' # 64-bit intel
- elif cputype == 0x01000000 | 12:
- if cpusubtype == 2:
- return 'arm64e'
- else:
- return 'arm64'
- elif cputype == 7:
- return 'i386' # 32-bit intel
- assert False, 'Unhandled architecture!'
- def update_exe_identifier(filename, pkg_filename):
- """
- Modifies the Mach-O image UUID stored in the LC_UUID command (if present) in order to ensure that different
- frozen applications have different identifiers. See TN3178 for details on why this is required:
- https://developer.apple.com/documentation/technotes/tn3178-checking-for-and-resolving-build-uuid-problems
- """
- # Compute hash of the PKG
- import hashlib
- pkg_hash = hashlib.sha1()
- with open(pkg_filename, 'rb') as fp:
- for chunk in iter(lambda: fp.read(8192), b""):
- pkg_hash.update(chunk)
- # Modify UUID in all arch slices of the executable.
- executable = MachO(filename)
- for header in executable.headers:
- # Find LC_UUID command
- uuid_cmd = [cmd for cmd in header.commands if cmd[0].cmd == LC_UUID]
- if not uuid_cmd:
- continue
- uuid_cmd = uuid_cmd[0]
- # Read the existing UUID (which is based on bootloader executable itself).
- original_uuid = uuid_cmd[1].uuid
- # Add original UUID to the hash; this is similar to what UUID v3/v5 do with namespace + name, except
- # that in our case, the prefix UUID (namespace) is added at the end, so that PKG hash needs to be
- # (pre)computed only once.
- combined_hash = pkg_hash.copy()
- combined_hash.update(original_uuid)
- new_uuid = combined_hash.digest()[:16] # Same as uuid.uuid3() / uuid.uuid5().
- assert len(new_uuid) == 16
- uuid_cmd[1].uuid = new_uuid
- # Write changes
- with open(filename, 'rb+') as fp:
- executable.write(fp)
- class InvalidBinaryError(Exception):
- """
- Exception raised by `get_binary_architectures` when it is passed an invalid binary.
- """
- pass
- class IncompatibleBinaryArchError(Exception):
- """
- Exception raised by `binary_to_target_arch` when the passed binary fails the strict architecture check.
- """
- def __init__(self, message):
- url = "https://pyinstaller.org/en/stable/feature-notes.html#macos-multi-arch-support"
- super().__init__(f"{message} For details about this error message, see: {url}")
- def get_binary_architectures(filename):
- """
- Inspects the given binary and returns tuple (is_fat, archs), where is_fat is boolean indicating fat/thin binary,
- and arch is list of architectures with lipo/codesign compatible names.
- """
- try:
- executable = MachO(filename)
- except ValueError as e:
- raise InvalidBinaryError("Invalid Mach-O binary!") from e
- return bool(executable.fat), [_get_arch_string(hdr.header) for hdr in executable.headers]
- def convert_binary_to_thin_arch(filename, thin_arch, output_filename=None):
- """
- Convert the given fat binary into thin one with the specified target architecture.
- """
- output_filename = output_filename or filename
- cmd_args = ['lipo', '-thin', thin_arch, filename, '-output', output_filename]
- p = subprocess.run(cmd_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding='utf-8')
- if p.returncode:
- raise SystemError(f"lipo command ({cmd_args}) failed with error code {p.returncode}!\noutput: {p.stdout}")
- def merge_into_fat_binary(output_filename, *slice_filenames):
- """
- Merge the given single-arch thin binary files into a fat binary.
- """
- cmd_args = ['lipo', '-create', '-output', output_filename, *slice_filenames]
- p = subprocess.run(cmd_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding='utf-8')
- if p.returncode:
- raise SystemError(f"lipo command ({cmd_args}) failed with error code {p.returncode}!\noutput: {p.stdout}")
- def binary_to_target_arch(filename, target_arch, display_name=None):
- """
- Check that the given binary contains required architecture slice(s) and convert the fat binary into thin one,
- if necessary.
- """
- if not display_name:
- display_name = filename # Same as input file
- # Check the binary
- is_fat, archs = get_binary_architectures(filename)
- if target_arch == 'universal2':
- if not is_fat:
- raise IncompatibleBinaryArchError(f"{display_name} is not a fat binary!")
- # Assume fat binary is universal2; nothing to do
- else:
- if is_fat:
- if target_arch not in archs:
- raise IncompatibleBinaryArchError(f"{display_name} does not contain slice for {target_arch}!")
- # Convert to thin arch
- logger.debug("Converting fat binary %s (%s) to thin binary (%s)", filename, display_name, target_arch)
- convert_binary_to_thin_arch(filename, target_arch)
- else:
- if target_arch not in archs:
- raise IncompatibleBinaryArchError(
- f"{display_name} is incompatible with target arch {target_arch} (has arch: {archs[0]})!"
- )
- # Binary has correct arch; nothing to do
- def remove_signature_from_binary(filename):
- """
- Remove the signature from all architecture slices of the given binary file using the codesign utility.
- """
- logger.debug("Removing signature from file %r", filename)
- cmd_args = ['/usr/bin/codesign', '--remove', '--all-architectures', filename]
- p = subprocess.run(cmd_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding='utf-8')
- if p.returncode:
- raise SystemError(f"codesign command ({cmd_args}) failed with error code {p.returncode}!\noutput: {p.stdout}")
- def sign_binary(filename, identity=None, entitlements_file=None, deep=False):
- """
- Sign the binary using codesign utility. If no identity is provided, ad-hoc signing is performed.
- """
- extra_args = []
- if not identity:
- identity = '-' # ad-hoc signing
- else:
- extra_args.append('--options=runtime') # hardened runtime
- if entitlements_file:
- extra_args.append('--entitlements')
- extra_args.append(entitlements_file)
- if deep:
- extra_args.append('--deep')
- logger.debug("Signing file %r", filename)
- cmd_args = [
- '/usr/bin/codesign', '-s', identity, '--force', '--all-architectures', '--timestamp', *extra_args, filename
- ]
- p = subprocess.run(cmd_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding='utf-8')
- if p.returncode:
- raise SystemError(f"codesign command ({cmd_args}) failed with error code {p.returncode}!\noutput: {p.stdout}")
- def set_dylib_dependency_paths(filename, target_rpath):
- """
- Modify the given dylib's identity (in LC_ID_DYLIB command) and the paths to dependent dylibs (in LC_LOAD_DYLIB)
- commands into `@rpath/<basename>` format, remove any existing rpaths (LC_RPATH commands), and add a new rpath
- (LC_RPATH command) with the specified path.
- Uses `install-tool-name` utility to make the changes.
- The system libraries (e.g., the ones found in /usr/lib) are exempted from path rewrite.
- For multi-arch fat binaries, this function extracts each slice into temporary file, processes it separately,
- and then merges all processed slices back into fat binary. This is necessary because `install-tool-name` cannot
- modify rpaths in cases when an existing rpath is present only in one slice.
- """
- # Check if we are dealing with a fat binary; the `install-name-tool` seems to be unable to remove an rpath that is
- # present only in one slice, so we need to extract each slice, process it separately, and then stich processed
- # slices back into a fat binary.
- is_fat, archs = get_binary_architectures(filename)
- if is_fat:
- with tempfile.TemporaryDirectory() as tmpdir:
- slice_filenames = []
- for arch in archs:
- slice_filename = os.path.join(tmpdir, arch)
- convert_binary_to_thin_arch(filename, arch, output_filename=slice_filename)
- _set_dylib_dependency_paths(slice_filename, target_rpath)
- slice_filenames.append(slice_filename)
- merge_into_fat_binary(filename, *slice_filenames)
- else:
- # Thin binary - we can process it directly
- _set_dylib_dependency_paths(filename, target_rpath)
- def _set_dylib_dependency_paths(filename, target_rpath):
- """
- The actual implementation of set_dylib_dependency_paths functionality.
- Implicitly assumes that a single-arch thin binary is given.
- """
- # Relocatable commands that we should overwrite - same list as used by `macholib`.
- _RELOCATABLE = {
- LC_LOAD_DYLIB,
- LC_LOAD_UPWARD_DYLIB,
- LC_LOAD_WEAK_DYLIB,
- LC_PREBOUND_DYLIB,
- LC_REEXPORT_DYLIB,
- }
- # Parse dylib's header to extract the following commands:
- # - LC_LOAD_DYLIB (or any member of _RELOCATABLE list): dylib load commands (dependent libraries)
- # - LC_RPATH: rpath definitions
- # - LC_ID_DYLIB: dylib's identity
- binary = MachO(filename)
- dylib_id = None
- rpaths = set()
- linked_libs = set()
- for header in binary.headers:
- for cmd in header.commands:
- lc_type = cmd[0].cmd
- if lc_type not in _RELOCATABLE and lc_type not in {LC_RPATH, LC_ID_DYLIB}:
- continue
- # Decode path, strip trailing NULL characters
- path = cmd[2].decode('utf-8').rstrip('\x00')
- if lc_type in _RELOCATABLE:
- linked_libs.add(path)
- elif lc_type == LC_RPATH:
- rpaths.add(path)
- elif lc_type == LC_ID_DYLIB:
- dylib_id = path
- del binary
- # If dylib has identifier set, compute the normalized version, in form of `@rpath/basename`.
- normalized_dylib_id = None
- if dylib_id:
- normalized_dylib_id = str(pathlib.PurePath('@rpath') / pathlib.PurePath(dylib_id).name)
- # Find dependent libraries that should have their prefix path changed to `@rpath`. If any dependent libraries
- # end up using `@rpath` (originally or due to rewrite), set the `rpath_required` boolean to True, so we know
- # that we need to add our rpath.
- changed_lib_paths = []
- rpath_required = False
- for linked_lib in linked_libs:
- # Leave system dynamic libraries unchanged.
- if macholib.util.in_system_path(linked_lib):
- continue
- # The older python.org builds that use system Tcl/Tk framework have their _tkinter.cpython-*-darwin.so
- # library linked against /Library/Frameworks/Tcl.framework/Versions/8.5/Tcl and
- # /Library/Frameworks/Tk.framework/Versions/8.5/Tk, although the actual frameworks are located in
- # /System/Library/Frameworks. Therefore, they slip through the above in_system_path() check, and we need to
- # exempt them manually.
- _exemptions = [
- '/Library/Frameworks/Tcl.framework/',
- '/Library/Frameworks/Tk.framework/',
- ]
- if any([x in linked_lib for x in _exemptions]):
- continue
- # This linked library will end up using `@rpath`, whether modified or not...
- rpath_required = True
- new_path = str(pathlib.PurePath('@rpath') / pathlib.PurePath(linked_lib).name)
- if linked_lib == new_path:
- continue
- changed_lib_paths.append((linked_lib, new_path))
- # Gather arguments for `install-name-tool`
- install_name_tool_args = []
- # Modify the dylib identifier if necessary
- if normalized_dylib_id and normalized_dylib_id != dylib_id:
- install_name_tool_args += ["-id", normalized_dylib_id]
- # Changed libs
- for original_path, new_path in changed_lib_paths:
- install_name_tool_args += ["-change", original_path, new_path]
- # Remove all existing rpaths except for the target rpath (if it already exists). `install_name_tool` disallows using
- # `-delete_rpath` and `-add_rpath` with the same argument.
- for rpath in rpaths:
- if rpath == target_rpath:
- continue
- install_name_tool_args += [
- "-delete_rpath",
- rpath,
- ]
- # If any of linked libraries use @rpath now and our target rpath is not already added, add it.
- # NOTE: @rpath in the dylib identifier does not actually require the rpath to be set on the binary...
- if rpath_required and target_rpath not in rpaths:
- install_name_tool_args += [
- "-add_rpath",
- target_rpath,
- ]
- # If we have no arguments, finish immediately.
- if not install_name_tool_args:
- return
- # Run `install_name_tool`
- cmd_args = ["install_name_tool", *install_name_tool_args, filename]
- p = subprocess.run(cmd_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding='utf-8')
- if p.returncode:
- raise SystemError(
- f"install_name_tool command ({cmd_args}) failed with error code {p.returncode}!\noutput: {p.stdout}"
- )
- def is_framework_bundle_lib(lib_path):
- """
- Check if the given shared library is part of a .framework bundle.
- """
- lib_path = pathlib.PurePath(lib_path)
- # For now, focus only on versioned layout, such as `QtCore.framework/Versions/5/QtCore`
- if lib_path.parent.parent.name != "Versions":
- return False
- if lib_path.parent.parent.parent.name != lib_path.name + ".framework":
- return False
- return True
- def collect_files_from_framework_bundles(collected_files):
- """
- Scan the given TOC list of collected files for shared libraries that are collected from macOS .framework bundles,
- and collect the bundles' Info.plist files. Additionally, create/restore the following symbolic links:
- - `Versions/Current` pointing to the `Versions/<version>` directory containing the binary
- - `<name>` in the top-level .framework directory, pointing to `Versions/Current/<name>`
- - `Resources` in the top-level .framework directory, pointing to `Versions/Current/Resources`
- - additional directories in top-level .framework directory, pointing to their counterparts in `Versions/Current`
- directory.
- Returns updated TOC list with added entries for the discovered Info.plist files and generated symbolic links.
- """
- invalid_framework_found = False
- framework_entries = set() # Additional TOC entries for collected files. Use set for de-duplication.
- framework_paths = set() # Registered framework paths for 2nd pass.
- framework_symlinked_dirs = set() # Symlinked directories for filtering in 3rd pass.
- # 1st pass: discover binaries from .framework bundles, and for each such binary:
- # - collect `Info.plist`
- # - create `Current` -> `<version>` symlink in `<name>.framework/Versions` directory.
- # - create `<name>.framework/<name>` -> `<name>.framework/Versions/Current/<name>` symlink.
- # - create `<name>.framework/Resources` -> `<name>.framework/Versions/Current/Resources` symlink.
- for dest_name, src_name, typecode in collected_files:
- if typecode != 'BINARY':
- continue
- src_path = pathlib.Path(src_name) # /src/path/to/<name>.framework/Versions/<version>/<name>
- dest_path = pathlib.PurePath(dest_name) # /dest/path/to/<name>.framework/Versions/<version>/<name>
- # Check whether binary originates from a .framework bundle
- if not is_framework_bundle_lib(src_path):
- continue
- # Check whether binary is also collected into a .framework bundle (i.e., the original layout is preserved)
- if not is_framework_bundle_lib(dest_path):
- continue
- # Assuming versioned layout, Info.plist should exist in Resources directory located next to the binary.
- info_plist_src = src_path.parent / "Resources" / "Info.plist"
- if not info_plist_src.is_file():
- # Alas, the .framework bundles shipped with PySide/PyQt might have Info.plist available only in the
- # top-level Resources directory. So accommodate this scenario as well, but collect the file into
- # versioned directory to appease the code-signing gods...
- info_plist_src_top = src_path.parent.parent.parent / "Resources" / "Info.plist"
- if not info_plist_src_top.is_file():
- # Strictly speaking, a .framework bundle without Info.plist is invalid. However, that did not prevent
- # PyQt from shipping such Qt .framework bundles up until v5.14.1. So by default, we just complain via
- # a warning message; if such binaries work in unfrozen python, they should also work in frozen
- # application. The codesign will refuse to sign the .app bundle (if we are generating one), but there
- # is nothing we can do about that.
- invalid_framework_found = True
- framework_dir = src_path.parent.parent.parent
- if compat.strict_collect_mode:
- raise SystemError(f"Could not find Info.plist in {framework_dir}!")
- else:
- logger.warning("Could not find Info.plist in %s!", framework_dir)
- continue
- info_plist_src = info_plist_src_top
- info_plist_dest = dest_path.parent / "Resources" / "Info.plist"
- framework_entries.add((str(info_plist_dest), str(info_plist_src), "DATA"))
- # Reconstruct the symlink Versions/Current -> Versions/<version>.
- # This one seems to be necessary for code signing, but might be absent from .framework bundles shipped with
- # python packages (i.e., PyPI wheels that do not support symlinks). So we always create it ourselves.
- framework_entries.add((str(dest_path.parent.parent / "Current"), str(dest_path.parent.name), "SYMLINK"))
- framework_symlinked_dirs.add(dest_path.parent.parent / "Current") # Cleanup in 3rd pass
- dest_framework_path = dest_path.parent.parent.parent # Top-level .framework directory path.
- # Symlink the binary in the `Current` directory to the top-level .framework directory.
- # If TOC also contains an entry for a hard-copy entry in the top-level directory, it will be replaced by this
- # symlink entry due to how our TOC normalization works.
- framework_entries.add((
- str(dest_framework_path / dest_path.name),
- str(pathlib.PurePath("Versions/Current") / dest_path.name),
- "SYMLINK",
- ))
- # Ditto for the `Resources` directory.
- framework_entries.add((
- str(dest_framework_path / "Resources"),
- "Versions/Current/Resources",
- "SYMLINK",
- ))
- framework_symlinked_dirs.add(dest_framework_path / "Resources") # Cleanup in 3rd pass
- # Register the framework parent path to use in additional directories scan in subsequent pass.
- framework_paths.add(dest_framework_path)
- # 2nd pass: scan for additional collected directories from .framework bundles, and create symlinks to the top-level
- # application directory. Make the outer loop go over the registered framework paths, so it becomes no-op if no
- # framework paths are registered.
- VALID_SUBDIRS = {'Documentation', 'Frameworks', 'Headers', 'Helpers', 'Libraries', 'Resources'}
- for dest_framework_path in framework_paths:
- for dest_name, src_name, typecode in collected_files:
- dest_path = pathlib.PurePath(dest_name)
- # Try matching against framework path
- try:
- remaining_path = dest_path.relative_to(dest_framework_path)
- except ValueError: # dest_path is not subpath of dest_framework_path
- continue
- remaining_path_parts = remaining_path.parts
- # We are interested only in entries under Versions directory.
- if remaining_path_parts[0] != 'Versions':
- continue
- # If the entry name is among valid sub-directory names, create symlink.
- dir_name = remaining_path_parts[2]
- if dir_name not in VALID_SUBDIRS:
- continue
- framework_entries.add((
- str(dest_framework_path / dir_name),
- str(pathlib.PurePath("Versions/Current") / dir_name),
- "SYMLINK",
- ))
- framework_symlinked_dirs.add(dest_framework_path / dir_name) # Cleanup in 3rd pass
- # 3rd pass: remove TOC entries under directories for which we are trying to restore symbolic links. These may be
- # present when a python package (i.e., a PyPI wheel) ships a .framework bundle where symlinks were mangled into hard
- # copies (due to lack of support for symlinks in wheels) AND these hard copies are collected through use of
- # `collect_data` / `collect_binaries` / `collect_all` (either by user or by a hook).
- if framework_symlinked_dirs:
- filtered_toc = []
- for dest_name, src_name, typecode in collected_files:
- dest_path = pathlib.PurePath(dest_name)
- if any(dest_parent in framework_symlinked_dirs for dest_parent in dest_path.parents):
- continue # Inside symlinked directory; remove
- filtered_toc.append((dest_name, src_name, typecode))
- else:
- filtered_toc = collected_files
- # If we encountered an invalid .framework bundle without Info.plist, warn the user that code-signing will most
- # likely fail.
- if invalid_framework_found:
- logger.warning(
- "One or more collected .framework bundles have missing Info.plist file. If you are building an .app "
- "bundle, you will most likely not be able to code-sign it."
- )
- return filtered_toc + sorted(framework_entries)
|