Module renpy_distribute_tools.apple

The apple module contains important utilities to make signing, notarizing, and building packages for macOS easier.

The functions in this module require a device running macOS with Xcode 10 or higher.

Expand source code
"""The `apple` module contains important utilities to make signing, notarizing, and building
    packages for macOS easier.

The functions in this module require a device running macOS with Xcode 10 or higher.
"""
import os
import subprocess
from sys import platform
from functools import wraps


def darwin_only(call):
    """A decorator for macOS-specific commands.

    This should be used to denote that a function only works on macOS due to reliance on built-in
        tools from macOS or Xcode.
    """
    @wraps(call)
    def darwin_call(*args, **kwargs):
        if platform.lower() != "darwin":
            raise OSError("Function %s only works on macOS." % (call))
        return call(*args, **kwargs)
    return darwin_call


@darwin_only
def package_app_zip(app: str):
    """Create a ZIP file of the app.

    Args:
        app (str): The path to the macOS to make an archive of
    """
    if os.path.isdir(app):
        zip_commands = ["ditto", "-c", "-k", "--rsrc",
                        "--keepParent", app, app + ".zip"]
        subprocess.check_call(zip_commands)
    else:
        raise NotADirectoryError(
            "The .app file is either missing or not present.")


@darwin_only
def build_pkg(app: str, identity: str, package_name: str):
    """Create an installable package from a macOS app.

    By default, it will create an app package that installs to `/Applications/`. This package
        installer can also be used to submit an app to the Mac App Store.

    If the package name isn't a file path, `.pkg` will automatically be appended at the end of the
        name.

    Args:
        app (str): The path to the app to create a package of.
        identity (str): The identity to sign the package with
        package_name (str): The name or path of the resulting package.
    """
    package_file = package_name
    if ".pkg" not in package_name:
        package_file = package_name + ".pkg"
    commands = ["productbuild", "--component", app,
                "/Applications", "--sign", identity, package_file]
    return subprocess.check_call(commands)


@darwin_only
def code_sign(identity: str, app_directory: str, **kwargs):
    """Digitally sign a macOS application with a signing identity and any entitlements.

    Args:
        identity (str): The identity to use during signing, usually a Developer ID.
        app_directory (str): The path to the macOS application for signing.
        **kwargs: Arbitrary keyword arguments.

    Kwargs:
        entitlements (str): (Optional) The path to the entitlements the app should be signed with.
        enable_hardened_runtime (bool): Whether to sign the app with the hardened runtime on.
    """
    commands = ["codesign",
                "--timestamp",
                "--deep",
                "--force",
                "--no-strict",
                "--sign",
                identity,
                app_directory]

    if "entitlements" in kwargs:
        commands.append("--entitlements")
        commands.append(kwargs["entitlements"])

    if "enable_hardened_runtime" in kwargs and kwargs["enable_hardened_runtime"]:
        commands.append("--options=runtime")

    return subprocess.check_call(commands)


@darwin_only
def upload_to_notary(app: str,
                     identifier: str,
                     username: str,
                     password: str,
                     **kwargs) -> str:
    """Upload a macOS application archive to Apple's notary service for notarization.

    Args:
        app (str): The path to the macOS application to send to Apple.
        identifier (str): The bundle identifier of the application.
        username (str): The username (email address) of the Apple ID to notarize under.
        password (str): The password of the Apple ID to notarize under.
        **kwargs: Arbitrary keyword arguments.

    Kwargs:
        provider (str): The App Store Connect or iTunes Connect provider associated with the Apple
            ID used to sign the app.
    Returns:
        uuid_str (str): The request UUID.
    """
    package_app_zip(app)
    commands = ["xcrun", "altool", "-t", "osx", "-f", app + ".zip",
                "--notarize-app", "--primary-bundle-id", identifier,
                "-u", username, "-p", password]

    if "provider" in kwargs:
        commands += ["-itc_provider", kwargs["provider"]]

    result = subprocess.check_output(  # pylint:disable=unexpected-keyword-arg
        commands, text=True)
    os.remove(app + ".zip")

    result = result.split("\n")
    trimmed = result[1:]
    if len(trimmed) < 1:
        return ""
    return trimmed[0].replace("RequestUUID = ", "")


@darwin_only
def check_notary_status(uuid: str, username: str, password: str) -> int:
    """Get the notarization status of a given UUID.

    Arguments:
        uuid (str): The UUID of the app to check the status of.
        username (str): The user that submitted the notarization request.
        password (str): The password to use to sign into Apple.

    Returns:
        status (int): The status code associated with the UUID notarization request. A code of `-1`
            indicates that getting the status code failed, either because the item could not be
            found or because no status code has been given yet.
    """
    result = subprocess.check_output(  # pylint:disable=unexpected-keyword-arg
        ["xcrun", "altool", "--notarization-info", uuid, "-u", username, "-p", password], text=True)
    status = [x for x in result.replace(
        "  ", "").split("\n") if "Status Code" in x]

    if len(status) < 1:
        return -1

    status = status[0].replace("Status Code", "").replace(" ", "").split(":")
    while '' in status:
        status.remove('')

    if len(status) < 1:
        return -1
    return int(status[0])


@darwin_only
def staple(app: str):
    """Staple a notarization ticket to a notarized app.

    Args:
        app (str): The path of the macOS app to staple the ticket to.
    """
    commands = ["xcrun", "stapler", "staple", app]
    return subprocess.check_call(commands)

Functions

def build_pkg(app, identity, package_name)

Create an installable package from a macOS app.

By default, it will create an app package that installs to /Applications/. This package installer can also be used to submit an app to the Mac App Store.

If the package name isn't a file path, .pkg will automatically be appended at the end of the name.

Args

app : str
The path to the app to create a package of.
identity : str
The identity to sign the package with
package_name : str
The name or path of the resulting package.
Expand source code
@darwin_only
def build_pkg(app: str, identity: str, package_name: str):
    """Create an installable package from a macOS app.

    By default, it will create an app package that installs to `/Applications/`. This package
        installer can also be used to submit an app to the Mac App Store.

    If the package name isn't a file path, `.pkg` will automatically be appended at the end of the
        name.

    Args:
        app (str): The path to the app to create a package of.
        identity (str): The identity to sign the package with
        package_name (str): The name or path of the resulting package.
    """
    package_file = package_name
    if ".pkg" not in package_name:
        package_file = package_name + ".pkg"
    commands = ["productbuild", "--component", app,
                "/Applications", "--sign", identity, package_file]
    return subprocess.check_call(commands)
def check_notary_status(uuid, username, password)

Get the notarization status of a given UUID.

Arguments

uuid : str
The UUID of the app to check the status of.
username : str
The user that submitted the notarization request.
password : str
The password to use to sign into Apple.

Returns

status : int
The status code associated with the UUID notarization request. A code of -1 indicates that getting the status code failed, either because the item could not be found or because no status code has been given yet.
Expand source code
@darwin_only
def check_notary_status(uuid: str, username: str, password: str) -> int:
    """Get the notarization status of a given UUID.

    Arguments:
        uuid (str): The UUID of the app to check the status of.
        username (str): The user that submitted the notarization request.
        password (str): The password to use to sign into Apple.

    Returns:
        status (int): The status code associated with the UUID notarization request. A code of `-1`
            indicates that getting the status code failed, either because the item could not be
            found or because no status code has been given yet.
    """
    result = subprocess.check_output(  # pylint:disable=unexpected-keyword-arg
        ["xcrun", "altool", "--notarization-info", uuid, "-u", username, "-p", password], text=True)
    status = [x for x in result.replace(
        "  ", "").split("\n") if "Status Code" in x]

    if len(status) < 1:
        return -1

    status = status[0].replace("Status Code", "").replace(" ", "").split(":")
    while '' in status:
        status.remove('')

    if len(status) < 1:
        return -1
    return int(status[0])
def code_sign(identity, app_directory, **kwargs)

Digitally sign a macOS application with a signing identity and any entitlements.

Args

identity : str
The identity to use during signing, usually a Developer ID.
app_directory : str
The path to the macOS application for signing.
**kwargs
Arbitrary keyword arguments.

Kwargs

entitlements : str
(Optional) The path to the entitlements the app should be signed with.
enable_hardened_runtime : bool
Whether to sign the app with the hardened runtime on.
Expand source code
@darwin_only
def code_sign(identity: str, app_directory: str, **kwargs):
    """Digitally sign a macOS application with a signing identity and any entitlements.

    Args:
        identity (str): The identity to use during signing, usually a Developer ID.
        app_directory (str): The path to the macOS application for signing.
        **kwargs: Arbitrary keyword arguments.

    Kwargs:
        entitlements (str): (Optional) The path to the entitlements the app should be signed with.
        enable_hardened_runtime (bool): Whether to sign the app with the hardened runtime on.
    """
    commands = ["codesign",
                "--timestamp",
                "--deep",
                "--force",
                "--no-strict",
                "--sign",
                identity,
                app_directory]

    if "entitlements" in kwargs:
        commands.append("--entitlements")
        commands.append(kwargs["entitlements"])

    if "enable_hardened_runtime" in kwargs and kwargs["enable_hardened_runtime"]:
        commands.append("--options=runtime")

    return subprocess.check_call(commands)
def darwin_only(call)

A decorator for macOS-specific commands.

This should be used to denote that a function only works on macOS due to reliance on built-in tools from macOS or Xcode.

Expand source code
def darwin_only(call):
    """A decorator for macOS-specific commands.

    This should be used to denote that a function only works on macOS due to reliance on built-in
        tools from macOS or Xcode.
    """
    @wraps(call)
    def darwin_call(*args, **kwargs):
        if platform.lower() != "darwin":
            raise OSError("Function %s only works on macOS." % (call))
        return call(*args, **kwargs)
    return darwin_call
def package_app_zip(app)

Create a ZIP file of the app.

Args

app : str
The path to the macOS to make an archive of
Expand source code
@darwin_only
def package_app_zip(app: str):
    """Create a ZIP file of the app.

    Args:
        app (str): The path to the macOS to make an archive of
    """
    if os.path.isdir(app):
        zip_commands = ["ditto", "-c", "-k", "--rsrc",
                        "--keepParent", app, app + ".zip"]
        subprocess.check_call(zip_commands)
    else:
        raise NotADirectoryError(
            "The .app file is either missing or not present.")
def staple(app)

Staple a notarization ticket to a notarized app.

Args

app : str
The path of the macOS app to staple the ticket to.
Expand source code
@darwin_only
def staple(app: str):
    """Staple a notarization ticket to a notarized app.

    Args:
        app (str): The path of the macOS app to staple the ticket to.
    """
    commands = ["xcrun", "stapler", "staple", app]
    return subprocess.check_call(commands)
def upload_to_notary(app, identifier, username, password, **kwargs)

Upload a macOS application archive to Apple's notary service for notarization.

Args

app : str
The path to the macOS application to send to Apple.
identifier : str
The bundle identifier of the application.
username : str
The username (email address) of the Apple ID to notarize under.
password : str
The password of the Apple ID to notarize under.
**kwargs
Arbitrary keyword arguments.

Kwargs

provider : str
The App Store Connect or iTunes Connect provider associated with the Apple ID used to sign the app.

Returns

uuid_str : str
The request UUID.
Expand source code
@darwin_only
def upload_to_notary(app: str,
                     identifier: str,
                     username: str,
                     password: str,
                     **kwargs) -> str:
    """Upload a macOS application archive to Apple's notary service for notarization.

    Args:
        app (str): The path to the macOS application to send to Apple.
        identifier (str): The bundle identifier of the application.
        username (str): The username (email address) of the Apple ID to notarize under.
        password (str): The password of the Apple ID to notarize under.
        **kwargs: Arbitrary keyword arguments.

    Kwargs:
        provider (str): The App Store Connect or iTunes Connect provider associated with the Apple
            ID used to sign the app.
    Returns:
        uuid_str (str): The request UUID.
    """
    package_app_zip(app)
    commands = ["xcrun", "altool", "-t", "osx", "-f", app + ".zip",
                "--notarize-app", "--primary-bundle-id", identifier,
                "-u", username, "-p", password]

    if "provider" in kwargs:
        commands += ["-itc_provider", kwargs["provider"]]

    result = subprocess.check_output(  # pylint:disable=unexpected-keyword-arg
        commands, text=True)
    os.remove(app + ".zip")

    result = result.split("\n")
    trimmed = result[1:]
    if len(trimmed) < 1:
        return ""
    return trimmed[0].replace("RequestUUID = ", "")