530 lines
19 KiB
Python
530 lines
19 KiB
Python
"""Implements the 'bdist_mac' commands (create macOS
|
|
app blundle).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import plistlib
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import ClassVar
|
|
|
|
from setuptools import Command
|
|
|
|
from cx_Freeze.common import normalize_to_list
|
|
from cx_Freeze.darwintools import (
|
|
apply_adhoc_signature,
|
|
change_load_reference,
|
|
isMachOFile,
|
|
)
|
|
from cx_Freeze.exception import OptionError
|
|
|
|
__all__ = ["bdist_mac"]
|
|
|
|
|
|
class bdist_mac(Command):
|
|
"""Create a Mac application bundle."""
|
|
|
|
description = "create a Mac application bundle"
|
|
|
|
plist_items: list[tuple[str, str]]
|
|
include_frameworks: list[str]
|
|
include_resources: list[str]
|
|
|
|
user_options: ClassVar[list[tuple[str, str | None, str]]] = [
|
|
("iconfile=", None, "Path to an icns icon file for the application."),
|
|
(
|
|
"qt-menu-nib=",
|
|
None,
|
|
"Location of qt_menu.nib folder for Qt "
|
|
"applications. Will be auto-detected by default.",
|
|
),
|
|
(
|
|
"bundle-name=",
|
|
None,
|
|
"File name for the bundle application "
|
|
"without the .app extension.",
|
|
),
|
|
(
|
|
"plist-items=",
|
|
None,
|
|
"A list of key-value pairs (type: list[tuple[str, str]]) to "
|
|
"be added to the app bundle Info.plist file.",
|
|
),
|
|
(
|
|
"custom-info-plist=",
|
|
None,
|
|
"File to be used as the Info.plist in "
|
|
"the app bundle. A basic one will be generated by default.",
|
|
),
|
|
(
|
|
"include-frameworks=",
|
|
None,
|
|
"A comma separated list of Framework "
|
|
"directories to include in the app bundle.",
|
|
),
|
|
(
|
|
"include-resources=",
|
|
None,
|
|
"A list of tuples of additional "
|
|
"files to include in the app bundle's resources directory, with "
|
|
"the first element being the source, and second the destination "
|
|
"file or directory name.",
|
|
),
|
|
(
|
|
"codesign-identity=",
|
|
None,
|
|
"The identity of the key to be used to sign the app bundle.",
|
|
),
|
|
(
|
|
"codesign-entitlements=",
|
|
None,
|
|
"The path to an entitlements file "
|
|
"to use for your application's code signature.",
|
|
),
|
|
(
|
|
"codesign-deep=",
|
|
None,
|
|
"Boolean for whether to codesign using the --deep option.",
|
|
),
|
|
(
|
|
"codesign-timestamp",
|
|
None,
|
|
"Boolean for whether to codesign using the --timestamp option.",
|
|
),
|
|
(
|
|
"codesign-resource-rules",
|
|
None,
|
|
"Plist file to be passed to "
|
|
"codesign's --resource-rules option.",
|
|
),
|
|
(
|
|
"absolute-reference-path=",
|
|
None,
|
|
"Path to use for all referenced "
|
|
"libraries instead of @executable_path.",
|
|
),
|
|
(
|
|
"codesign-verify",
|
|
None,
|
|
"Boolean to verify codesign of the .app bundle using the codesign "
|
|
"command",
|
|
),
|
|
(
|
|
"spctl-assess",
|
|
None,
|
|
"Boolean to verify codesign of the .app bundle using the spctl "
|
|
"command",
|
|
),
|
|
(
|
|
"codesign-strict=",
|
|
None,
|
|
"Boolean for whether to codesign using the --strict option.",
|
|
),
|
|
(
|
|
"codesign-options=",
|
|
None,
|
|
"Option flags to be embedded in the code signature",
|
|
),
|
|
]
|
|
|
|
def initialize_options(self) -> None:
|
|
self.list_options = [
|
|
"plist_items",
|
|
"include_frameworks",
|
|
"include_resources",
|
|
]
|
|
for option in self.list_options:
|
|
setattr(self, option, [])
|
|
|
|
self.absolute_reference_path = None
|
|
self.bundle_name = self.distribution.get_fullname()
|
|
self.codesign_deep = None
|
|
self.codesign_entitlements = None
|
|
self.codesign_identity = None
|
|
self.codesign_timestamp = None
|
|
self.codesign_strict = None
|
|
self.codesign_options = None
|
|
self.codesign_resource_rules = None
|
|
self.codesign_verify = None
|
|
self.spctl_assess = None
|
|
self.custom_info_plist = None
|
|
self.iconfile = None
|
|
self.qt_menu_nib = False
|
|
|
|
self.build_base = None
|
|
self.build_dir = None
|
|
|
|
def finalize_options(self) -> None:
|
|
# Make sure all options of multiple values are lists
|
|
for option in self.list_options:
|
|
setattr(self, option, normalize_to_list(getattr(self, option)))
|
|
for item in self.plist_items:
|
|
if not isinstance(item, tuple) or len(item) != 2:
|
|
msg = (
|
|
"Error, plist_items must be a list of key, value pairs "
|
|
"(list[tuple[str, str]]) (bad list item)."
|
|
)
|
|
raise OptionError(msg)
|
|
|
|
# Define the paths within the application bundle
|
|
self.set_undefined_options(
|
|
"build_exe",
|
|
("build_base", "build_base"),
|
|
("build_exe", "build_dir"),
|
|
)
|
|
self.bundle_dir = os.path.join(
|
|
self.build_base, f"{self.bundle_name}.app"
|
|
)
|
|
self.contents_dir = os.path.join(self.bundle_dir, "Contents")
|
|
self.bin_dir = os.path.join(self.contents_dir, "MacOS")
|
|
self.frameworks_dir = os.path.join(self.contents_dir, "Frameworks")
|
|
self.resources_dir = os.path.join(self.contents_dir, "Resources")
|
|
self.helpers_dir = os.path.join(self.contents_dir, "Helpers")
|
|
|
|
def create_plist(self) -> None:
|
|
"""Create the Contents/Info.plist file."""
|
|
# Use custom plist if supplied, otherwise create a simple default.
|
|
if self.custom_info_plist:
|
|
with open(self.custom_info_plist, "rb") as file:
|
|
contents = plistlib.load(file)
|
|
else:
|
|
contents = {
|
|
"CFBundleIconFile": "icon.icns",
|
|
"CFBundleDevelopmentRegion": "English",
|
|
"CFBundleIdentifier": self.bundle_name,
|
|
# Specify that bundle is an application bundle
|
|
"CFBundlePackageType": "APPL",
|
|
# Cause application to run in high-resolution mode by default
|
|
# (Without this, applications run from application bundle may
|
|
# be pixelated)
|
|
"NSHighResolutionCapable": "True",
|
|
}
|
|
|
|
# Ensure CFBundleExecutable is set correctly
|
|
contents["CFBundleExecutable"] = self.bundle_executable
|
|
|
|
# add custom items to the plist file
|
|
for key, value in self.plist_items:
|
|
contents[key] = value
|
|
|
|
with open(os.path.join(self.contents_dir, "Info.plist"), "wb") as file:
|
|
plistlib.dump(contents, file)
|
|
|
|
def set_absolute_reference_paths(self, path=None) -> None:
|
|
"""For all files in Contents/MacOS, set their linked library paths to
|
|
be absolute paths using the given path instead of @executable_path.
|
|
"""
|
|
if not path:
|
|
path = self.absolute_reference_path
|
|
|
|
files = os.listdir(self.bin_dir)
|
|
|
|
for filename in files:
|
|
filepath = os.path.join(self.bin_dir, filename)
|
|
|
|
# Skip some file types
|
|
if filepath[-1:] in ("txt", "zip") or os.path.isdir(filepath):
|
|
continue
|
|
|
|
out = subprocess.check_output(
|
|
("otool", "-L", filepath), encoding="utf_8"
|
|
)
|
|
for line in out.splitlines()[1:]:
|
|
lib = line.lstrip("\t").split(" (compat")[0]
|
|
|
|
if lib.startswith("@executable_path"):
|
|
replacement = lib.replace("@executable_path", path)
|
|
|
|
path, name = os.path.split(replacement)
|
|
|
|
# see if we provide the referenced file;
|
|
# if so, change the reference
|
|
if name in files:
|
|
change_load_reference(filepath, lib, replacement)
|
|
apply_adhoc_signature(filepath)
|
|
|
|
def find_qt_menu_nib(self) -> str | None:
|
|
"""Returns a location of a qt_menu.nib folder, or None if this is not
|
|
a Qt application.
|
|
"""
|
|
if self.qt_menu_nib:
|
|
return self.qt_menu_nib
|
|
if any(n.startswith("PyQt4.QtCore") for n in os.listdir(self.bin_dir)):
|
|
name = "PyQt4"
|
|
elif any(
|
|
n.startswith("PySide.QtCore") for n in os.listdir(self.bin_dir)
|
|
):
|
|
name = "PySide"
|
|
else:
|
|
return None
|
|
|
|
qtcore = __import__(name, fromlist=["QtCore"]).QtCore
|
|
libpath = str(
|
|
qtcore.QLibraryInfo.location(qtcore.QLibraryInfo.LibrariesPath)
|
|
)
|
|
for subpath in [
|
|
"QtGui.framework/Resources/qt_menu.nib",
|
|
"Resources/qt_menu.nib",
|
|
]:
|
|
path = os.path.join(libpath, subpath)
|
|
if os.path.exists(path):
|
|
return path
|
|
|
|
# Last resort: fixed paths (macports)
|
|
for path in [
|
|
"/opt/local/Library/Frameworks/QtGui.framework/Versions/"
|
|
"4/Resources/qt_menu.nib"
|
|
]:
|
|
if os.path.exists(path):
|
|
return path
|
|
|
|
print("Could not find qt_menu.nib")
|
|
msg = "Could not find qt_menu.nib"
|
|
raise OSError(msg)
|
|
|
|
def prepare_qt_app(self) -> None:
|
|
"""Add resource files for a Qt application. Should do nothing if the
|
|
application does not use QtCore.
|
|
"""
|
|
qt_conf = os.path.join(self.resources_dir, "qt.conf")
|
|
qt_conf_2 = os.path.join(self.resources_dir, "qt_bdist_mac.conf")
|
|
if os.path.exists(qt_conf_2):
|
|
self.execute(
|
|
shutil.move,
|
|
(qt_conf_2, qt_conf),
|
|
msg=f"moving {qt_conf_2} -> {qt_conf}",
|
|
)
|
|
|
|
nib_locn = self.find_qt_menu_nib()
|
|
if nib_locn is None:
|
|
return
|
|
|
|
# Copy qt_menu.nib
|
|
self.copy_tree(
|
|
nib_locn, os.path.join(self.resources_dir, "qt_menu.nib")
|
|
)
|
|
|
|
# qt.conf needs to exist, but needn't have any content
|
|
if not os.path.exists(qt_conf):
|
|
with open(qt_conf, "wb"):
|
|
pass
|
|
|
|
def run(self) -> None:
|
|
self.run_command("build_exe")
|
|
|
|
# Remove App if it already exists
|
|
# ( avoids confusing issues where prior builds persist! )
|
|
if os.path.exists(self.bundle_dir):
|
|
self.execute(
|
|
shutil.rmtree,
|
|
(self.bundle_dir,),
|
|
msg=f"staging - removed existing '{self.bundle_dir}'",
|
|
)
|
|
|
|
# Find the executable name
|
|
executable = self.distribution.executables[0].target_name
|
|
_, self.bundle_executable = os.path.split(executable)
|
|
print(f"Executable name: {self.build_dir}/{executable}")
|
|
|
|
# Build the app directory structure
|
|
self.mkpath(self.bin_dir) # /MacOS
|
|
self.mkpath(self.frameworks_dir) # /Frameworks
|
|
self.mkpath(self.resources_dir) # /Resources
|
|
|
|
# Copy the full build_exe to Contents/Resources
|
|
self.copy_tree(self.build_dir, self.resources_dir)
|
|
|
|
# Move only executables in Contents/Resources to Contents/MacOS
|
|
for executable in self.distribution.executables:
|
|
source = os.path.join(self.resources_dir, executable.target_name)
|
|
target = os.path.join(self.bin_dir, executable.target_name)
|
|
self.move_file(source, target)
|
|
|
|
# Make symlink between folders under Resources such as lib and others
|
|
# specified by the user in include_files and Contents/MacOS so we can
|
|
# use non-relative reference paths to pass codesign...
|
|
for filename in os.listdir(self.resources_dir):
|
|
target = os.path.join(self.resources_dir, filename)
|
|
if os.path.isdir(target):
|
|
origin = os.path.join(self.bin_dir, filename)
|
|
relative_reference = os.path.relpath(target, self.bin_dir)
|
|
self.execute(
|
|
os.symlink,
|
|
(relative_reference, origin, True),
|
|
msg=f"linking {origin} -> {relative_reference}",
|
|
)
|
|
|
|
# Copy the icon
|
|
if self.iconfile:
|
|
self.copy_file(
|
|
self.iconfile, os.path.join(self.resources_dir, "icon.icns")
|
|
)
|
|
|
|
# Copy in Frameworks
|
|
for framework in self.include_frameworks:
|
|
self.copy_tree(
|
|
framework,
|
|
os.path.join(self.frameworks_dir, os.path.basename(framework)),
|
|
)
|
|
|
|
# Copy in Resources
|
|
for resource, destination in self.include_resources:
|
|
if os.path.isdir(resource):
|
|
self.copy_tree(
|
|
resource, os.path.join(self.resources_dir, destination)
|
|
)
|
|
else:
|
|
parent_dirs = os.path.dirname(
|
|
os.path.join(self.resources_dir, destination)
|
|
)
|
|
os.makedirs(parent_dirs, exist_ok=True)
|
|
self.copy_file(
|
|
resource, os.path.join(self.resources_dir, destination)
|
|
)
|
|
|
|
# Create the Info.plist file
|
|
self.execute(self.create_plist, (), msg="creating Contents/Info.plist")
|
|
|
|
# Make library references absolute if enabled
|
|
if self.absolute_reference_path:
|
|
self.execute(
|
|
self.set_absolute_reference_paths,
|
|
(),
|
|
msg="set absolute reference path "
|
|
f"'{self.absolute_reference_path}",
|
|
)
|
|
|
|
# For a Qt application, run some tweaks
|
|
self.execute(self.prepare_qt_app, ())
|
|
|
|
# Move Contents/Resources/share/*.app to Contents/Helpers
|
|
share_dir = os.path.join(self.resources_dir, "share")
|
|
if os.path.isdir(share_dir):
|
|
for filename in os.listdir(share_dir):
|
|
if not filename.endswith(".app"):
|
|
continue
|
|
# create /Helpers only if required
|
|
self.mkpath(self.helpers_dir)
|
|
source = os.path.join(share_dir, filename)
|
|
target = os.path.join(self.helpers_dir, filename)
|
|
self.execute(
|
|
shutil.move,
|
|
(source, target),
|
|
msg=f"moving {source} -> {target}",
|
|
)
|
|
if os.path.isdir(target):
|
|
origin = os.path.join(target, "Contents", "MacOS", "lib")
|
|
relative_reference = os.path.relpath(
|
|
os.path.join(self.resources_dir, "lib"),
|
|
os.path.join(target, "Contents", "MacOS"),
|
|
)
|
|
self.execute(
|
|
os.symlink,
|
|
(relative_reference, origin, True),
|
|
msg=f"linking {origin} -> {relative_reference}",
|
|
)
|
|
|
|
# Sign the app bundle if a key is specified
|
|
self.execute(
|
|
self._codesign,
|
|
(self.bundle_dir,),
|
|
msg=f"sign: '{self.bundle_dir}'",
|
|
)
|
|
|
|
def _codesign(self, root_path) -> None:
|
|
"""Run codesign on all .so, .dylib and binary files in reverse order.
|
|
Signing from inside-out.
|
|
"""
|
|
if not self.codesign_identity:
|
|
return
|
|
|
|
binaries_to_sign = []
|
|
|
|
# Identify all binary files
|
|
for dirpath, _, filenames in os.walk(root_path):
|
|
for filename in filenames:
|
|
full_path = Path(os.path.join(dirpath, filename))
|
|
|
|
if isMachOFile(full_path):
|
|
binaries_to_sign.append(full_path)
|
|
|
|
# Sort files by depth, so we sign the deepest files first
|
|
binaries_to_sign.sort(key=lambda x: str(x).count(os.sep), reverse=True)
|
|
|
|
for binary_path in binaries_to_sign:
|
|
self._codesign_file(binary_path, self._get_sign_args())
|
|
|
|
self._verify_signature()
|
|
print("Finished .app signing")
|
|
|
|
def _get_sign_args(self) -> list[str]:
|
|
signargs = ["codesign", "--sign", self.codesign_identity, "--force"]
|
|
|
|
if self.codesign_timestamp:
|
|
signargs.append("--timestamp")
|
|
|
|
if self.codesign_strict:
|
|
signargs.append(f"--strict={self.codesign_strict}")
|
|
|
|
if self.codesign_deep:
|
|
signargs.append("--deep")
|
|
|
|
if self.codesign_options:
|
|
signargs.append("--options")
|
|
signargs.append(self.codesign_options)
|
|
|
|
if self.codesign_entitlements:
|
|
signargs.append("--entitlements")
|
|
signargs.append(self.codesign_entitlements)
|
|
return signargs
|
|
|
|
def _codesign_file(self, file_path, sign_args) -> None:
|
|
print(f"Signing file: {file_path}")
|
|
sign_args.append(file_path)
|
|
subprocess.run(sign_args, check=False)
|
|
|
|
def _verify_signature(self) -> None:
|
|
if self.codesign_verify:
|
|
verify_args = [
|
|
"codesign",
|
|
"-vvv",
|
|
"--deep",
|
|
"--strict",
|
|
self.bundle_dir,
|
|
]
|
|
print("Running codesign verification")
|
|
result = subprocess.run(
|
|
verify_args, capture_output=True, text=True, check=False
|
|
)
|
|
print("ExitCode:", result.returncode)
|
|
print(" stdout:", result.stdout)
|
|
print(" stderr:", result.stderr)
|
|
|
|
if self.spctl_assess:
|
|
spctl_args = [
|
|
"spctl",
|
|
"--assess",
|
|
"--raw",
|
|
"--verbose=10",
|
|
"--type",
|
|
"exec",
|
|
self.bundle_dir,
|
|
]
|
|
try:
|
|
completed_process = subprocess.run(
|
|
spctl_args,
|
|
check=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
)
|
|
print(
|
|
"spctl command's output: "
|
|
f"{completed_process.stdout.decode()}"
|
|
)
|
|
except subprocess.CalledProcessError as error:
|
|
print(f"spctl check got an error: {error.stdout.decode()}")
|
|
raise
|