#----------------------------------------------------------------------------- # 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 plistlib import shutil 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, checkCache from PyInstaller.compat import is_darwin from PyInstaller.building.icon import normalize_icon_type import PyInstaller.utils.misc as miscutils if is_darwin: import PyInstaller.utils.osx as osxutils 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 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.copy(self.icon, os.path.join(self.name, 'Contents', 'Resources')) # 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) links = [] _QT_BASE_PATH = {'PySide2', 'PySide6', 'PyQt5', 'PySide6'} for dest_name, src_name, typecode in self.toc: # Copy files from cache. This ensures that are used files with relative paths to dynamic library # dependencies (@executable_path). base_path = dest_name.split('/', 1)[0] if typecode in ('EXTENSION', 'BINARY'): src_name = checkCache( src_name, strip=self.strip, upx=self.upx, upx_exclude=self.upx_exclude, dist_nm=dest_name, target_arch=self.target_arch, codesign_identity=self.codesign_identity, entitlements_file=self.entitlements_file, strict_arch_validation=(typecode == 'EXTENSION'), ) # Add most data files to a list for symlinking later. # Exempt python source files from this relocation, because their real path might need to resolve # to the directory that also contains the extension module. relocate_file = typecode == 'DATA' and base_path not in _QT_BASE_PATH if relocate_file and os.path.splitext(dest_name)[1].lower() in {'.py', '.pyc'}: relocate_file = False if relocate_file: links.append((dest_name, src_name)) else: # 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!") dest_path = os.path.join(self.name, "Contents", "MacOS", dest_name) dest_dir = os.path.dirname(dest_path) if not os.path.exists(dest_dir): os.makedirs(dest_dir) shutil.copy2(src_name, dest_path) # Use copy2 to (attempt to) preserve metadata logger.info('Moving BUNDLE data files to Resource directory') # Mac OS Code Signing does not work when .app bundle contains data files in dir ./Contents/MacOS. # Put all data files in ./Resources and create symlinks in ./MacOS. bin_dir = os.path.join(self.name, 'Contents', 'MacOS') res_dir = os.path.join(self.name, 'Contents', 'Resources') for dest_name, src_name in links: # 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!") dest_path = os.path.join(res_dir, dest_name) dest_dir = os.path.dirname(dest_path) if not os.path.exists(dest_dir): os.makedirs(dest_dir) shutil.copy2(src_name, dest_dir) # Use copy2 to (attempt to) preserve metadata base_path = os.path.split(dest_name)[0] if base_path: if not os.path.exists(os.path.join(bin_dir, dest_name)): path = '' for part in iter(base_path.split(os.path.sep)): # Build path from previous path and the next part of the base path path = os.path.join(path, part) try: relative_source_path = os.path.relpath( os.path.join(res_dir, path), os.path.split(os.path.join(bin_dir, path))[0] ) dest_path = os.path.join(bin_dir, path) os.symlink(relative_source_path, dest_path) break except FileExistsError: pass if not os.path.exists(os.path.join(bin_dir, dest_name)): relative_source_path = os.path.relpath( os.path.join(res_dir, dest_name), os.path.split(os.path.join(bin_dir, dest_name))[0] ) dest_path = os.path.join(bin_dir, dest_name) os.symlink(relative_source_path, dest_path) else: # If path is empty, e.g., a top-level file, try to just symlink the file. relative_source_path = os.path.relpath( os.path.join(res_dir, dest_name), os.path.split(os.path.join(bin_dir, dest_name))[0] ) dest_path = os.path.join(bin_dir, dest_name) os.symlink(relative_source_path, dest_path) # 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)