#----------------------------------------------------------------------------- # Copyright (c) 2005-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) #----------------------------------------------------------------------------- import os import pathlib import plistlib import shutil import subprocess from PyInstaller.building.api import COLLECT, EXE from PyInstaller.building.datastruct import Target, logger, normalize_toc from PyInstaller.building.utils import _check_path_overlap, _rmtree, process_collected_binary from PyInstaller.compat import is_darwin, strict_collect_mode from PyInstaller.building.icon import normalize_icon_type import PyInstaller.utils.misc as miscutils if is_darwin: import PyInstaller.utils.osx as osxutils # Character sequence used to replace dot (`.`) in names of directories that are created in `Contents/MacOS` or # `Contents/Frameworks`, where only .framework bundle directories are allowed to have dot in name. DOT_REPLACEMENT = '__dot__' class BUNDLE(Target): def __init__(self, *args, **kwargs): from PyInstaller.config import CONF # BUNDLE only has a sense under Mac OS, it's a noop on other platforms if not is_darwin: return # Get a path to a .icns icon for the app bundle. self.icon = kwargs.get('icon') if not self.icon: # --icon not specified; use the default in the pyinstaller folder self.icon = os.path.join( os.path.dirname(os.path.dirname(__file__)), 'bootloader', 'images', 'icon-windowed.icns' ) else: # User gave an --icon=path. If it is relative, make it relative to the spec file location. if not os.path.isabs(self.icon): self.icon = os.path.join(CONF['specpath'], self.icon) super().__init__() # .app bundle is created in DISTPATH. self.name = kwargs.get('name', None) base_name = os.path.basename(self.name) self.name = os.path.join(CONF['distpath'], base_name) self.appname = os.path.splitext(base_name)[0] self.version = kwargs.get("version", "0.0.0") self.toc = [] self.strip = False self.upx = False self.console = True self.target_arch = None self.codesign_identity = None self.entitlements_file = None # .app bundle identifier for Code Signing self.bundle_identifier = kwargs.get('bundle_identifier') if not self.bundle_identifier: # Fallback to appname. self.bundle_identifier = self.appname self.info_plist = kwargs.get('info_plist', None) for arg in args: # Valid arguments: EXE object, COLLECT object, and TOC-like iterables if isinstance(arg, EXE): # Add EXE as an entry to the TOC, and merge its dependencies TOC self.toc.append((os.path.basename(arg.name), arg.name, 'EXECUTABLE')) self.toc.extend(arg.dependencies) # Inherit settings self.strip = arg.strip self.upx = arg.upx self.upx_exclude = arg.upx_exclude self.console = arg.console self.target_arch = arg.target_arch self.codesign_identity = arg.codesign_identity self.entitlements_file = arg.entitlements_file elif isinstance(arg, COLLECT): # Merge the TOC self.toc.extend(arg.toc) # Inherit settings self.strip = arg.strip_binaries self.upx = arg.upx_binaries self.upx_exclude = arg.upx_exclude self.console = arg.console self.target_arch = arg.target_arch self.codesign_identity = arg.codesign_identity self.entitlements_file = arg.entitlements_file elif miscutils.is_iterable(arg): # TOC-like iterable self.toc.extend(arg) else: raise TypeError(f"Invalid argument type for BUNDLE: {type(arg)!r}") # Infer the executable name from the first EXECUTABLE entry in the TOC; it might have come from the COLLECT # (as opposed to the stand-alone EXE). for dest_name, src_name, typecode in self.toc: if typecode == "EXECUTABLE": self.exename = src_name break else: raise ValueError("No EXECUTABLE entry found in the TOC!") # Normalize TOC self.toc = normalize_toc(self.toc) self.__postinit__() _GUTS = ( # BUNDLE always builds, just want the toc to be written out ('toc', None), ) def _check_guts(self, data, last_build): # BUNDLE always needs to be executed, in order to clean the output directory. return True # Helper for determining whether the given file belongs to a .framework bundle or not. If it does, it returns # the path to the top-level .framework bundle directory; otherwise, returns None. @staticmethod def _is_framework_file(dest_path): for parent in dest_path.parents: if parent.name.endswith('.framework'): return parent return None # Helper that computes relative cross-link path between link's location and target, assuming they are both # rooted in the `Contents` directory of a macOS .app bundle. @staticmethod def _compute_relative_crosslink(crosslink_location, crosslink_target): # We could take symlink_location and symlink_target as they are (relative to parent of the `Contents` # directory), but that would introduce an unnecessary `../Contents` part. So instead, we take both paths # relative to the `Contents` directory. return os.path.join( *['..' for level in pathlib.PurePath(crosslink_location).relative_to('Contents').parent.parts], pathlib.PurePath(crosslink_target).relative_to('Contents'), ) # This method takes the original (input) TOC and processes it into final TOC, based on which the `assemble` method # performs its file collection. The TOC processing here represents the core of our efforts to generate an .app # bundle that is compatible with Apple's code-signing requirements. # # For in-depth details on the code-signing, see Apple's `Technical Note TN2206: macOS Code Signing In Depth` at # https://developer.apple.com/library/archive/technotes/tn2206/_index.html # # The requirements, framed from PyInstaller's perspective, can be summarized as follows: # # 1. The `Contents/MacOS` directory is expected to contain only the program executable and (binary) code (= dylibs # and nested .framework bundles). Alternatively, the dylibs and .framework bundles can be also placed into # `Contents/Frameworks` directory (where same rules apply as for `Contents/MacOS`, so the remainder of this # text refers to the two inter-changeably, unless explicitly noted otherwise). The code in `Contents/MacOS` # is expected to be signed, and the `codesign` utility will recursively sign all found code when using `--deep` # option to sign the .app bundle. # # 2. All non-code files should be be placed in `Contents/Resources`, so they become sealed (data) resources; # i.e., their signature data is recorded in `Contents/_CodeSignature/CodeResources`. (As a side note, # it seems that signature information for data/resources in `Contents/Resources` is kept nder `file` key in # the `CodeResources` file, while the information for contents in `Contents/MacOS` is kept under `file2` key). # # 3. The directories in `Contents/MacOS` may not contain dots (`.`) in their names, except for the nested # .framework bundle directories. The directories in `Contents/Resources` have no such restrictions. # # 4. There may not be any content in the top level of a bundle. In other words, if a bundle has a `Contents` # or a `Versions` directory at its top level, there may be no other files or directories alongside them. The # sole exception is that alongside ˙Versions˙, there may be symlinks to files and directories in # `Versions/Current`. This rule is important for nested .framework bundles that we collect from python packages. # # Next, let us consider the consequences of violating each of the above requirements: # # 1. Code signing machinery can directly store signature only in Mach-O binaries and nested .framework bundles; if # a data file is placed in `Contents/MacOS`, the signature is stored in the file's extended attributes. If the # extended attributes are lost, the program's signature will be broken. Many file transfer techniques (e.g., a # zip file) do not preserve extended attributes, nor are they preserved when uploading to the Mac App Store. # # 2. Putting code (a dylib or a .framework bundle) into `Contents/Resources` causes it to be treated as a resource; # the outer signature (i.e., of the whole .app bundle) does not know that this nested content is actually a code. # Consequently, signing the bundle with ˙codesign --deep` will NOT sign binaries placed in the # `Contents/Resources`, which may result in missing signatures when .app bundle is verified for notarization. # This might be worked around by signing each binary separately, and then signing the whole bundle (without the # `--deep˙ option), but requires the user to keep track of the offending binaries. # # 3. If a directory in `Contents/MacOS` contains a dot in the name, code-signing the bundle fails with # ˙bundle format unrecognized, invalid, or unsuitable` due to code signing machinery treating directory as a # nested .framework bundle directory. # # 4. If nested .framework bundle is malformed, the signing of the .app bundle might succeed, but subsequent # verification will fail, for example with `embedded framework contains modified or invalid version` (as observed # with .framework bundles shipped by contemporary PyQt/PySide PyPI wheels). # # The above requirements are unfortunately often at odds with the structure of python packages: # # * In general, python packages are mixed-content directories, where binaries and data files may be expected to # be found next to each other. # # For example, `opencv-python` provides a custom loader script that requires the package to be collected in the # source-only form by PyInstaller (i.e., the python modules and scripts collected as source .py files). At the # same time, it expects the .py loader script to be able to find the binary extension next to itself. # # Another example of mixed-mode directories are Qt QML components' sub-directories, which contain both the # component's plugin (a binary) and associated meta files (data files). # # * In python world, the directories often contain dots in their names. # # Dots are often used for private directories containing binaries that are shipped with a package. For example, # `numpy/.dylibs`, `scipy/.dylibs`, etc. # # Qt QML components may also contain a dot in their name; couple of examples from `PySide2` package: # `PySide2/Qt/qml/QtQuick.2`, ˙PySide2/Qt/qml/QtQuick/Controls.2˙, ˙PySide2/Qt/qml/QtQuick/Particles.2˙, etc. # # The packages' metadata directories also invariably contain dots in the name due to version (for example, # `numpy-1.24.3.dist-info`). # # In the light of all above, PyInstaller attempts to strictly place all files to their mandated location # (`Contents/MacOS` or `Contents/Frameworks` vs `Contents/Resources`). To preserve the illusion of mixed-content # directories, the content is cross-linked from one directory to the other. Specifically: # # * All entries with DATA typecode are assumed to be data files, and are always placed in corresponding directory # structure rooted in `Contents/Resources`. # # * All entries with BINARY or EXTENSION typecode are always placed in corresponding directory structure rooted in # `Contents/Frameworks`. # # * All entries with EXECUTABLE are placed in `Contents/MacOS` directory. # # * For the purposes of relocation, nested .framework bundles are treated as a single BINARY entity; i.e., the # whole .bundle directory is placed in corresponding directory structure rooted in `Contents/Frameworks` (even # though some of its contents, such as `Info.plist` file, are actually data files). # # * Top-level data files and binaries are always cross-linked to the other directory. For example, given a data file # `data_file.txt` that was collected into `Contents/Resources`, we create a symbolic link called # `Contents/MacOS/data_file.txt` that points to `../Resources/data_file.txt`. # # * The executable itself, while placed in `Contents/MacOS`, are cross-linked into both `Contents/Framworks` and # `Contents/Resources`. # # * The stand-alone PKG entries (used with onefile builds that side-load the PKG archive) are treated as data files # and collected into `Contents/Resources`, but cross-linked only into `Contents/MacOS` directory (because they # must appear to be next to the program executable). This is the only entry type that is cross-linked into the # `Contents/MacOS` directory and also the only data-like entry type that is not cross-linked into the # `Contents/Frameworks` directory. # # * For files in sub-directories, the cross-linking behavior depends on the type of directory: # # * A data-only directory is created in directory structure rooted in `Contents/Resources`, and cross-linked # into directory structure rooted in `Contents/Frameworks` at directory level (i.e., we link the whole # directory instead of individual files). # # This largely saves us from having to deal with dots in the names of collected metadata directories, which # are examples of data-only directories. # # * A binary-only directory is created in directory structure rooted in `Contents/Frameworks`, and cross-linked # into `Contents/Resources` at directory level. # # * A mixed-content directory is created in both directory structures. Files are placed into corresponding # directory structure based on their type, and cross-linked into other directory structure at file level. # # * This rule is applied recursively; for example, a data-only sub-directory in a mixed-content directory is # cross-linked at directory level, while adjacent binary and data files are cross-linked at file level. # # * To work around the issue with dots in the names of directories in `Contents/Frameworks` (applicable to # binary-only or mixed-content directories), such directories are created with modified name (the dot replaced # with a pre-defined pattern). Next to the modified directory, a symbolic link with original name is created, # pointing to the directory with modified name. With mixed-content directories, this modification is performed # only on the `Contents/Frameworks` side; the corresponding directory in `Contents/Resources` can be created # directly, without name modification and symbolic link. # # * If a symbolic link needs to be created in a mixed-content directory due to a SYMLINK entry from the original # TOC (i.e., a "collected" symlink originating from analysis, as opposed to the cross-linking mechanism described # above), the link is created in both directory structures, each pointing to the resource in its corresponding # directory structure (with one such resource being an actual file, and the other being a cross-link to the file). # # Final remarks: # # NOTE: the relocation mechanism is codified by tests in `tests/functional/test_macos_bundle_structure.py`. # # NOTE: by placing binaries and nested .framework entries into `Contents/Frameworks` instead of `Contents/MacOS`, # we have effectively relocated the `sys._MEIPASS` directory from the `Contents/MacOS` (= the parent directory of # the program executable) into `Contents/Frameworks`. This requires the PyInstaller's bootloader to detect that it # is running in the app-bundle mode (e.g., by checking if program executable's parent directory is `Contents/NacOS`) # and adjust the path accordingly. # # NOTE: the implemented relocation mechanism depends on the input TOC containing properly classified entries # w.r.t. BINARY vs DATA. So hooks and .spec files triggering collection of binaries as datas (and vice versa) will # result in incorrect placement of those files in the generated .app bundle. However, this is *not* the proper place # to address such issues; if necessary, automatic (re)classification should be added to analysis process, to ensure # that BUNDLE (as well as other build targets) receive correctly classified TOC. # # NOTE: similar to the previous note, the relocation mechanism is also not the proper place to enforce compliant # structure of the nested .framework bundles. Instead, this is handled by the analysis process, using the # `PyInstaller.utils.osx.collect_files_from_framework_bundles` helper function. So the input TOC that BUNDLE # receives should already contain entries that reconstruct compliant nested .framework bundles. def _process_bundle_toc(self, toc): bundle_toc = [] # Step 1: inspect the directory layout and classify the directories according to their contents. directory_types = dict() _MIXED_DIR_TYPE = 'MIXED-DIR' _DATA_DIR_TYPE = 'DATA-DIR' _BINARY_DIR_TYPE = 'BINARY-DIR' _FRAMEWORK_DIR_TYPE = 'FRAMEWORK-DIR' _TOP_LEVEL_DIR = pathlib.PurePath('.') for dest_name, src_name, typecode in toc: dest_path = pathlib.PurePath(dest_name) framework_dir = self._is_framework_file(dest_path) if framework_dir: # Mark the framework directory as FRAMEWORK-DIR. directory_types[framework_dir] = _FRAMEWORK_DIR_TYPE # Treat the framework directory as BINARY file when classifying parent directories. typecode = 'BINARY' parent_dirs = framework_dir.parents else: parent_dirs = dest_path.parents # Treat BINARY and EXTENSION as BINARY to simplify further processing. if typecode == 'EXTENSION': typecode = 'BINARY' # (Re)classify parent directories for parent_dir in parent_dirs: # Skip the top-level `.` dir. This is also the only directory that can contain EXECUTABLE and PKG # entries, so we do not have to worry about. if parent_dir == _TOP_LEVEL_DIR: continue directory_type = _BINARY_DIR_TYPE if typecode == 'BINARY' else _DATA_DIR_TYPE # default directory_type = directory_types.get(parent_dir, directory_type) if directory_type == _DATA_DIR_TYPE and typecode == 'BINARY': directory_type = _MIXED_DIR_TYPE if directory_type == _BINARY_DIR_TYPE and typecode == 'DATA': directory_type = _MIXED_DIR_TYPE directory_types[parent_dir] = directory_type logger.debug("Directory classification: %r", directory_types) # Step 2: process the obtained directory structure and create symlink entries for directories that need to be # cross-linked. Such directories are data-only and binary-only directories (and framework directories) that are # located either in the top-level directory (have no parent) or in a mixed-content directory. for directory_path, directory_type in directory_types.items(): # Cross-linking at directory level applies only to data-only and binary-only directories (as well as # framework directories). if directory_type == _MIXED_DIR_TYPE: continue # The parent needs to be either top-level directory or a mixed-content directory. Otherwise, the parent # (or one of its ancestors) will get cross-linked, and we do not need the link here. parent_dir = directory_path.parent requires_crosslink = parent_dir == _TOP_LEVEL_DIR or directory_types.get(parent_dir) == _MIXED_DIR_TYPE if not requires_crosslink: continue logger.debug("Cross-linking directory %r of type %r", directory_path, directory_type) # Data-only directories are created in `Contents/Resources`, needs to be cross-linked into `Contents/MacOS`. # Vice versa for binary-only or framework directories. The directory creation is handled implicitly, when we # create parent directory structure for collected files. if directory_type == _DATA_DIR_TYPE: symlink_src = os.path.join('Contents/Resources', directory_path) symlink_dest = os.path.join('Contents/Frameworks', directory_path) else: symlink_src = os.path.join('Contents/Frameworks', directory_path) symlink_dest = os.path.join('Contents/Resources', directory_path) symlink_ref = self._compute_relative_crosslink(symlink_dest, symlink_src) bundle_toc.append((symlink_dest, symlink_ref, 'SYMLINK')) # Step 3: first part of the work-around for directories that are located in `Contents/Frameworks` but contain a # dot in their name. As per `codesign` rules, the only directories in `Contents/Frameworks` that are allowed to # contain a dot in their name are .framework bundle directories. So we replace the dot with a custom character # sequence (stored in global `DOT_REPLACEMENT` variable), and create a symbolic with original name pointing to # the modified name. This is the best we can do with code-sign requirements vs. python community showing their # packages' dylibs into `.dylib` subdirectories, or Qt storing their Qml components in directories named # `QtQuick.2`, `QtQuick/Controls.2`, `QtQuick/Particles.2`, `QtQuick/Templates.2`, etc. # # In this step, we only prepare symlink entries that link the original directory name (with dot) to the modified # one (with dot replaced). The parent paths for collected files are modified in later step(s). for directory_path, directory_type in directory_types.items(): # .framework bundle directories contain a dot in the name, but are allowed that. if directory_type == _FRAMEWORK_DIR_TYPE: continue # Data-only directories are fully located in `Contents/Resources` and cross-linked to `Contents/Frameworks` # at directory level, so they are also allowed a dot in their name. if directory_type == _DATA_DIR_TYPE: continue # Apply the work-around, if necessary... if '.' not in directory_path.name: continue logger.debug( "Creating symlink to work around the dot in the name of directory %r (%s)...", str(directory_path), directory_type ) # Create a SYMLINK entry, but only for this level. In case of nested directories with dots in names, the # symlinks for ancestors will be created by corresponding loop iteration. bundle_toc.append(( os.path.join('Contents/Frameworks', directory_path), directory_path.name.replace('.', DOT_REPLACEMENT), 'SYMLINK', )) # Step 4: process the entries for collected files, and decide whether they should be placed into # `Contents/MacOS`, `Contents/Frameworks`, or `Contents/Resources`, and whether they should be cross-linked into # other directories. for orig_dest_name, src_name, typecode in toc: orig_dest_path = pathlib.PurePath(orig_dest_name) # Special handling for EXECUTABLE and PKG entries if typecode == 'EXECUTABLE': # Place into `Contents/MacOS`, ... file_dest = os.path.join('Contents/MacOS', orig_dest_name) bundle_toc.append((file_dest, src_name, typecode)) # ... and do nothing else. We explicitly avoid cross-linking the executable to `Contents/Frameworks` and # `Contents/Resources`, because it should be not necessary (the executable's location should be # discovered via `sys.executable`) and to prevent issues when executable name collides with name of a # package from which we collect either binaries or data files (or both); see #7314. continue elif typecode == 'PKG': # Place into `Contents/Resources` ... file_dest = os.path.join('Contents/Resources', orig_dest_name) bundle_toc.append((file_dest, src_name, typecode)) # ... and cross-link only into `Contents/MacOS`. # This is used only in `onefile` mode, where there is actually no other content to distribute among the # `Contents/Resources` and `Contents/Frameworks` directories, so cross-linking into the latter makes # little sense. symlink_dest = os.path.join('Contents/MacOS', orig_dest_name) symlink_ref = self._compute_relative_crosslink(symlink_dest, file_dest) bundle_toc.append((symlink_dest, symlink_ref, 'SYMLINK')) continue # Standard data vs binary processing... # Determine file location based on its type. if self._is_framework_file(orig_dest_path): # File from a framework bundle; put into `Contents/Frameworks`, but never cross-link the file itself. # The whole .framework bundle directory will be linked as necessary by the directory cross-linking # mechanism. file_base_dir = 'Contents/Frameworks' crosslink_base_dir = None elif typecode == 'DATA': # Data file; relocate to `Contents/Resources` and cross-link it back into `Contents/Frameworks`. file_base_dir = 'Contents/Resources' crosslink_base_dir = 'Contents/Frameworks' else: # Binary; put into `Contents/Frameworks` and cross-link it into `Contents/Resources`. file_base_dir = 'Contents/Frameworks' crosslink_base_dir = 'Contents/Resources' # Determine if we need to cross-link the file. We need to do this for top-level files (the ones without # parent directories), and for files whose parent directories are mixed-content directories. requires_crosslink = False if crosslink_base_dir is not None: parent_dir = orig_dest_path.parent requires_crosslink = parent_dir == _TOP_LEVEL_DIR or directory_types.get(parent_dir) == _MIXED_DIR_TYPE # Special handling for SYMLINK entries in original TOC; if we need to cross-link a symlink entry, we create # it in both locations, and have each point to the (relative) resource in the same directory (so one of the # targets will likely be a file, and the other will be a symlink due to cross-linking). if typecode == 'SYMLINK' and requires_crosslink: bundle_toc.append((os.path.join(file_base_dir, orig_dest_name), src_name, typecode)) bundle_toc.append((os.path.join(crosslink_base_dir, orig_dest_name), src_name, typecode)) continue # The file itself. file_dest = os.path.join(file_base_dir, orig_dest_name) bundle_toc.append((file_dest, src_name, typecode)) # Symlink for cross-linking if requires_crosslink: symlink_dest = os.path.join(crosslink_base_dir, orig_dest_name) symlink_ref = self._compute_relative_crosslink(symlink_dest, file_dest) bundle_toc.append((symlink_dest, symlink_ref, 'SYMLINK')) # Step 5: sanitize all destination paths in the new TOC, to ensure that paths that are rooted in # `Contents/Frameworks` do not contain directories with dots in their names. Doing this as a post-processing # step keeps code simple and clean and ensures that this step is applied to files, symlinks that originate from # cross-linking files, and symlinks that originate from cross-linking directories. This in turn ensures that # all directory hierarchies created during the actual file collection have sanitized names, and that collection # outcome does not depend on the order of entries in the TOC. sanitized_toc = [] for dest_name, src_name, typecode in bundle_toc: dest_path = pathlib.PurePath(dest_name) # Paths rooted in Contents/Resources do not require sanitizing. if dest_path.parts[0] == 'Contents' and dest_path.parts[1] == 'Resources': sanitized_toc.append((dest_name, src_name, typecode)) continue # Special handling for files from .framework bundle directories; sanitize only parent path of the .framework # directory. framework_path = self._is_framework_file(dest_path) if framework_path: parent_path = framework_path.parent remaining_path = dest_path.relative_to(parent_path) else: parent_path = dest_path.parent remaining_path = dest_path.name sanitized_dest_path = pathlib.PurePath( *parent_path.parts[:2], # Contents/Frameworks *[part.replace('.', DOT_REPLACEMENT) for part in parent_path.parts[2:]], remaining_path, ) sanitized_dest_name = str(sanitized_dest_path) if sanitized_dest_path != dest_path: logger.debug("Sanitizing dest path: %r -> %r", dest_name, sanitized_dest_name) sanitized_toc.append((sanitized_dest_name, src_name, typecode)) bundle_toc = sanitized_toc # Normalize and sort the TOC for easier inspection bundle_toc = sorted(normalize_toc(bundle_toc)) return bundle_toc def assemble(self): from PyInstaller.config import CONF if _check_path_overlap(self.name) and os.path.isdir(self.name): _rmtree(self.name) logger.info("Building BUNDLE %s", self.tocbasename) # Create a minimal Mac bundle structure. os.makedirs(os.path.join(self.name, "Contents", "MacOS")) os.makedirs(os.path.join(self.name, "Contents", "Resources")) os.makedirs(os.path.join(self.name, "Contents", "Frameworks")) # Makes sure the icon exists and attempts to convert to the proper format if applicable self.icon = normalize_icon_type(self.icon, ("icns",), "icns", CONF["workpath"]) # Ensure icon path is absolute self.icon = os.path.abspath(self.icon) # Copy icns icon to Resources directory. shutil.copyfile(self.icon, os.path.join(self.name, 'Contents', 'Resources', os.path.basename(self.icon))) # Key/values for a minimal Info.plist file info_plist_dict = { "CFBundleDisplayName": self.appname, "CFBundleName": self.appname, # Required by 'codesign' utility. # The value for CFBundleIdentifier is used as the default unique name of your program for Code Signing # purposes. It even identifies the APP for access to restricted OS X areas like Keychain. # # The identifier used for signing must be globally unique. The usual form for this identifier is a # hierarchical name in reverse DNS notation, starting with the toplevel domain, followed by the company # name, followed by the department within the company, and ending with the product name. Usually in the # form: com.mycompany.department.appname # CLI option --osx-bundle-identifier sets this value. "CFBundleIdentifier": self.bundle_identifier, "CFBundleExecutable": os.path.basename(self.exename), "CFBundleIconFile": os.path.basename(self.icon), "CFBundleInfoDictionaryVersion": "6.0", "CFBundlePackageType": "APPL", "CFBundleShortVersionString": self.version, } # Set some default values. But they still can be overwritten by the user. if self.console: # Setting EXE console=True implies LSBackgroundOnly=True. info_plist_dict['LSBackgroundOnly'] = True else: # Let's use high resolution by default. info_plist_dict['NSHighResolutionCapable'] = True # Merge info_plist settings from spec file if isinstance(self.info_plist, dict) and self.info_plist: info_plist_dict.update(self.info_plist) plist_filename = os.path.join(self.name, "Contents", "Info.plist") with open(plist_filename, "wb") as plist_fh: plistlib.dump(info_plist_dict, plist_fh) # Pre-process the TOC into its final BUNDLE-compatible form. bundle_toc = self._process_bundle_toc(self.toc) # Perform the actual collection. CONTENTS_FRAMEWORKS_PATH = pathlib.PurePath('Contents/Frameworks') for dest_name, src_name, typecode in bundle_toc: # Create parent directory structure, if necessary dest_path = os.path.join(self.name, dest_name) # Absolute destination path dest_dir = os.path.dirname(dest_path) try: os.makedirs(dest_dir, exist_ok=True) except FileExistsError: raise SystemExit( f"Pyinstaller needs to create a directory at {dest_dir!r}, " "but there already exists a file at that path!" ) # Copy extensions and binaries from cache. This ensures that these files undergo additional binary # processing - have paths to linked libraries rewritten (relative to `@rpath`) and have rpath set to the # top-level directory (relative to `@loader_path`, i.e., the file's location). The "top-level" directory # in this case corresponds to `Contents/MacOS` (where `sys._MEIPASS` also points), so we need to pass # the cache retrieval function the *original* destination path (which is without preceding # `Contents/MacOS`). if typecode in ('EXTENSION', 'BINARY'): orig_dest_name = str(pathlib.PurePath(dest_name).relative_to(CONTENTS_FRAMEWORKS_PATH)) src_name = process_collected_binary( src_name, orig_dest_name, use_strip=self.strip, use_upx=self.upx, upx_exclude=self.upx_exclude, target_arch=self.target_arch, codesign_identity=self.codesign_identity, entitlements_file=self.entitlements_file, strict_arch_validation=(typecode == 'EXTENSION'), ) if typecode == 'SYMLINK': os.symlink(src_name, dest_path) # Create link at dest_path, pointing at (relative) src_name else: # BUNDLE does not support MERGE-based multipackage assert typecode != 'DEPENDENCY', "MERGE DEPENDENCY entries are not supported in BUNDLE!" # At this point, `src_name` should be a valid file. if not os.path.isfile(src_name): raise ValueError(f"Resource {src_name!r} is not a valid file!") # If strict collection mode is enabled, the destination should not exist yet. if strict_collect_mode and os.path.exists(dest_path): raise ValueError( f"Attempting to collect a duplicated file into BUNDLE: {dest_name} (type: {typecode})" ) # Use `shutil.copyfile` to copy file with default permissions. We do not attempt to preserve original # permissions nor metadata, as they might be too restrictive and cause issues either during subsequent # re-build attempts or when trying to move the application bundle. For binaries (and data files with # executable bit set), we manually set the executable bits after copying the file. shutil.copyfile(src_name, dest_path) if ( typecode in ('EXTENSION', 'BINARY', 'EXECUTABLE') or (typecode == 'DATA' and os.access(src_name, os.X_OK)) ): os.chmod(dest_path, 0o755) # Sign the bundle logger.info('Signing the BUNDLE...') try: osxutils.sign_binary(self.name, self.codesign_identity, self.entitlements_file, deep=True) except Exception as e: # Display a warning or re-raise the error, depending on the environment-variable setting. if os.environ.get("PYINSTALLER_STRICT_BUNDLE_CODESIGN_ERROR", "0") == "0": logger.warning("Error while signing the bundle: %s", e) logger.warning("You will need to sign the bundle manually!") else: raise RuntimeError("Failed to codesign the bundle!") from e logger.info("Building BUNDLE %s completed successfully.", self.tocbasename) # Optionally verify bundle's signature. This is primarily intended for our CI. if os.environ.get("PYINSTALLER_VERIFY_BUNDLE_SIGNATURE", "0") != "0": logger.info("Verifying signature for BUNDLE %s...", self.name) self.verify_bundle_signature(self.name) logger.info("BUNDLE verification complete!") @staticmethod def verify_bundle_signature(bundle_dir): # First, verify the bundle signature using codesign. cmd_args = ['codesign', '--verify', '--all-architectures', '--deep', '--strict', bundle_dir] p = subprocess.run(cmd_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding='utf8') if p.returncode: raise SystemError( f"codesign command ({cmd_args}) failed with error code {p.returncode}!\noutput: {p.stdout}" ) # Ensure that code-signing information is *NOT* embedded in the files' extended attributes. # # This happens when files other than binaries are present in `Contents/MacOS` or `Contents/Frameworks` # directory; as the signature cannot be embedded within the file itself (contrary to binaries with # `LC_CODE_SIGNATURE` section in their header), it ends up stores in the file's extended attributes. However, # if such bundle is transferred using a method that does not support extended attributes (for example, a zip # file), the signatures on these files are lost, and the signature of the bundle as a whole becomes invalid. # This is the primary reason why we need to relocate non-binaries into `Contents/Resources` - the signatures # for files in that directory end up stored in `Contents/_CodeSignature/CodeResources` file. # # This check therefore aims to ensure that all files have been properly relocated to their corresponding # locations w.r.t. the code-signing requirements. try: import xattr except ModuleNotFoundError: logger.info("xattr package not available; skipping verification of extended attributes!") return CODESIGN_ATTRS = ( "com.apple.cs.CodeDirectory", "com.apple.cs.CodeRequirements", "com.apple.cs.CodeRequirements-1", "com.apple.cs.CodeSignature", ) for entry in pathlib.Path(bundle_dir).rglob("*"): if not entry.is_file(): continue file_attrs = xattr.listxattr(entry) if any([codesign_attr in file_attrs for codesign_attr in CODESIGN_ATTRS]): raise ValueError(f"Code-sign attributes found in extended attributes of {str(entry)!r}!")