721 lines
40 KiB
Python
Executable File
721 lines
40 KiB
Python
Executable File
#-----------------------------------------------------------------------------
|
|
# 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}!")
|