diff --git a/automation/pyproject.toml b/automation/pyproject.toml index 63c712880..c1c22feaa 100644 --- a/automation/pyproject.toml +++ b/automation/pyproject.toml @@ -17,6 +17,7 @@ dynamic = ["version", "dependencies"] [project.entry-points."briefcase.bootstraps"] "Toga Automation" = "automation.bootstraps.toga:TogaAutomationBootstrap" +"Console Automation" = "automation.bootstraps.console:ConsoleAutomationBootstrap" "PySide6 Automation" = "automation.bootstraps.pyside6:PySide6AutomationBootstrap" "Pygame Automation" = "automation.bootstraps.pygame:PygameAutomationBootstrap" diff --git a/automation/src/automation/bootstraps/console.py b/automation/src/automation/bootstraps/console.py new file mode 100644 index 000000000..3436c4cdf --- /dev/null +++ b/automation/src/automation/bootstraps/console.py @@ -0,0 +1,16 @@ +from automation.bootstraps import BRIEFCASE_EXIT_SUCCESS_SIGNAL, EXIT_SUCCESS_NOTIFY +from briefcase.bootstraps import ConsoleBootstrap + + +class ConsoleAutomationBootstrap(ConsoleBootstrap): + def app_source(self): + return f"""\ +import time + + +def main(): + time.sleep(2) + print("{EXIT_SUCCESS_NOTIFY}") + print("{BRIEFCASE_EXIT_SUCCESS_SIGNAL}") + +""" diff --git a/changes/1184.feature.rst b/changes/1184.feature.rst new file mode 100644 index 000000000..ba256d4c9 --- /dev/null +++ b/changes/1184.feature.rst @@ -0,0 +1 @@ +macOS now supports the generation of ``.pkg`` installers as a packaging format. diff --git a/changes/1729.bugfix.rst b/changes/1729.bugfix.rst new file mode 100644 index 000000000..914c96fc5 --- /dev/null +++ b/changes/1729.bugfix.rst @@ -0,0 +1 @@ +If ``run`` is executed directly after a ``create`` when using an ``app`` template (macOS or Windows), the implied ``build`` step is now correctly identified. diff --git a/changes/1781.removal.rst b/changes/1781.removal.rst new file mode 100644 index 000000000..aa35b1317 --- /dev/null +++ b/changes/1781.removal.rst @@ -0,0 +1 @@ +The macOS ``app`` packaging format has been renamed ``zip`` for consistency with Windows, and to reflect the format of the output artefact. diff --git a/changes/556.feature.rst b/changes/556.feature.rst new file mode 100644 index 000000000..061e1949b --- /dev/null +++ b/changes/556.feature.rst @@ -0,0 +1 @@ +Briefcase can now package command line apps. diff --git a/docs/reference/configuration.rst b/docs/reference/configuration.rst index b193c96e1..4b11b28b1 100644 --- a/docs/reference/configuration.rst +++ b/docs/reference/configuration.rst @@ -237,6 +237,16 @@ on an app with a formal name of "My App" would remove: 3. Any ``.exe`` file in ``path`` or its subdirectories. 4. The file ``My App/content/extra.doc``. +``console_app`` +~~~~~~~~~~~~~~~ + +A Boolean describing if the app is a console app, or a GUI app. Defaults to ``False`` +(producing a GUI app). This setting has no effect on platforms that do not support a +console mode (e.g., web or mobile platforms). On platforms that do support console apps, +the resulting app will write output directly to ``stdout``/``stderr`` (rather than +writing to a system log), creating a terminal window to display this output (if the +platform allows). + ``exit_regex`` ~~~~~~~~~~~~~~ diff --git a/docs/reference/platforms/macOS/app.rst b/docs/reference/platforms/macOS/app.rst index 0dbacde68..ed10ad44b 100644 --- a/docs/reference/platforms/macOS/app.rst +++ b/docs/reference/platforms/macOS/app.rst @@ -24,11 +24,14 @@ By default, apps will be both signed and notarized when they are packaged. Packaging format ================ -Briefcase supports two packaging formats for a macOS ``.app`` bundle: +Briefcase supports three packaging formats for a macOS app: -1. A DMG that contains the ``.app`` bundle (the default output of ``briefcase package - macOS``, or by using ``briefcase package macOS -p dmg``); or -2. A zipped ``.app`` folder (using ``briefcase package macOS -p app``). +1. A DMG that contains the ``.app`` bundle (using ``briefcase package macOS -p dmg``). +2. A zipped ``.app`` folder (using ``briefcase package macOS -p zip``). +3. A ``.pkg`` installer (using ``briefcase package macOS -p pkg``). + +``.pkg`` is the *required* format for console apps. ``.dmg`` is the +default format for GUI apps. Icon format =========== diff --git a/docs/reference/platforms/macOS/xcode.rst b/docs/reference/platforms/macOS/xcode.rst index 6836acc1f..18850b4ec 100644 --- a/docs/reference/platforms/macOS/xcode.rst +++ b/docs/reference/platforms/macOS/xcode.rst @@ -21,11 +21,15 @@ By default, apps will be both signed and notarized when they are packaged. Packaging format ================ -Briefcase supports two packaging formats for a macOS Xcode project: +Briefcase supports three packaging formats for a macOS Xcode project: -1. A DMG that contains the ``.app`` bundle (the default output of ``briefcase package - macOS Xcode``, or by using ``briefcase package macOS Xcode -p dmg``); or -2. A zipped ``.app`` folder (using ``briefcase package macOS Xcode -p app``). +1. A DMG that contains the ``.app`` bundle (using ``briefcase package macOS Xcode -p + dmg``). +2. A zipped ``.app`` folder (using ``briefcase package macOS Xcode -p zip``). +3. A ``.pkg`` installer (using ``briefcase package macOS Xcode -p pkg``). + +``.pkg`` is the *required* format for console apps. ``.dmg`` is the +default format for GUI apps. Icon format =========== diff --git a/pyproject.toml b/pyproject.toml index 9b57e6c20..ef5d476e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -133,6 +133,7 @@ briefcase = "briefcase.__main__:main" [project.entry-points."briefcase.bootstraps"] Toga = "briefcase.bootstraps.toga:TogaGuiBootstrap" +Console = "briefcase.bootstraps.console:ConsoleBootstrap" PySide6 = "briefcase.bootstraps.pyside6:PySide6GuiBootstrap" Pygame = "briefcase.bootstraps.pygame:PygameGuiBootstrap" diff --git a/src/briefcase/bootstraps/__init__.py b/src/briefcase/bootstraps/__init__.py index 239fcf095..18f6ddd76 100644 --- a/src/briefcase/bootstraps/__init__.py +++ b/src/briefcase/bootstraps/__init__.py @@ -1,4 +1,5 @@ from briefcase.bootstraps.base import BaseGuiBootstrap # noqa: F401 +from briefcase.bootstraps.console import ConsoleBootstrap # noqa: F401 from briefcase.bootstraps.pygame import PygameGuiBootstrap # noqa: F401 from briefcase.bootstraps.pyside6 import PySide6GuiBootstrap # noqa: F401 from briefcase.bootstraps.toga import TogaGuiBootstrap # noqa: F401 diff --git a/src/briefcase/bootstraps/console.py b/src/briefcase/bootstraps/console.py new file mode 100644 index 000000000..b9f7568db --- /dev/null +++ b/src/briefcase/bootstraps/console.py @@ -0,0 +1,119 @@ +from briefcase.bootstraps.base import BaseGuiBootstrap + + +class ConsoleBootstrap(BaseGuiBootstrap): + display_name_annotation = "does not support iOS/Android/Web deployment" + + def app_source(self): + return """\ + +def main(): + # Your app logic goes here + print("Hello, World.") + +""" + + def app_start_source(self): + return """\ +from {{ cookiecutter.module_name }}.app import main + +if __name__ == "__main__": + main() +""" + + def pyproject_table_briefcase_app_extra_content(self): + return """ +console_app = true +requires = [ +] +test_requires = [ +{% if cookiecutter.test_framework == "pytest" %} + "pytest", +{% endif %} +] +""" + + def pyproject_table_macOS(self): + return """\ +universal_build = true +requires = [ +] +""" + + def pyproject_table_linux(self): + return """\ +requires = [ +] +""" + + def pyproject_table_linux_system_debian(self): + return """\ +system_requires = [ + # Add any system packages needed at build the app here +] + +system_runtime_requires = [ + # Add any system packages needed at runtime here +] +""" + + def pyproject_table_linux_system_rhel(self): + return """\ +system_requires = [ + # Add any system packages needed at build the app here +] + +system_runtime_requires = [ + # Add any system packages needed at runtime here +] +""" + + def pyproject_table_linux_system_suse(self): + return """\ +system_requires = [ + # Add any system packages needed at build the app here +] + +system_runtime_requires = [ + # Add any system packages needed at runtime here +] +""" + + def pyproject_table_linux_system_arch(self): + return """\ +system_requires = [ + # Add any system packages needed at build the app here +] + +system_runtime_requires = [ + # Add any system packages needed at runtime here +] +""" + + def pyproject_table_linux_flatpak(self): + return """\ +flatpak_runtime = "org.freedesktop.Platform" +flatpak_runtime_version = "23.08" +flatpak_sdk = "org.freedesktop.Sdk" +""" + + def pyproject_table_windows(self): + return """\ +requires = [ +] +""" + + def pyproject_table_iOS(self): + return """\ +supported = false +""" + + def pyproject_table_android(self): + return """\ +supported = false +""" + + def pyproject_table_web(self): + return """\ +supported = false +""" diff --git a/src/briefcase/bootstraps/pygame.py b/src/briefcase/bootstraps/pygame.py index 78c427d2b..eb1c50689 100644 --- a/src/briefcase/bootstraps/pygame.py +++ b/src/briefcase/bootstraps/pygame.py @@ -2,7 +2,7 @@ class PygameGuiBootstrap(BaseGuiBootstrap): - display_name_annotation = "does not support iOS/Android deployment" + display_name_annotation = "does not support iOS/Android/Web deployment" def app_source(self): return """\ diff --git a/src/briefcase/bootstraps/pyside6.py b/src/briefcase/bootstraps/pyside6.py index aa52cd7ba..c76d83402 100644 --- a/src/briefcase/bootstraps/pyside6.py +++ b/src/briefcase/bootstraps/pyside6.py @@ -2,7 +2,7 @@ class PySide6GuiBootstrap(BaseGuiBootstrap): - display_name_annotation = "does not support iOS/Android deployment" + display_name_annotation = "does not support iOS/Android/Web deployment" def app_source(self): return """\ diff --git a/src/briefcase/commands/base.py b/src/briefcase/commands/base.py index 15d2a0274..bdb1b7781 100644 --- a/src/briefcase/commands/base.py +++ b/src/briefcase/commands/base.py @@ -389,6 +389,17 @@ def binary_path(self, app) -> Path: :param app: The app config """ + def binary_executable_path(self, app) -> Path: + """The path to the actual binary object for the app in the output format. + + For most platforms, this will be the same as the binary path. However, for + platforms that use an "executable bundle" (e.g., macOS), this will be actual + binary that is embedded in the bundle. + + :param app: The app config + """ + return self.binary_path(app) + def briefcase_toml(self, app: AppConfig) -> dict[str, ...]: """Load the ``briefcase.toml`` file provided by the app template. diff --git a/src/briefcase/commands/dev.py b/src/briefcase/commands/dev.py index f37e36ec5..066eb8e14 100644 --- a/src/briefcase/commands/dev.py +++ b/src/briefcase/commands/dev.py @@ -129,33 +129,49 @@ def run_dev_app( # Add in the environment settings to get Python in the state we want. env.update(self.DEV_ENVIRONMENT) - app_popen = self.tools.subprocess.Popen( - [ - # Do not add additional switches for sys.executable; see DEV_ENVIRONMENT - sys.executable, - "-c", - ( - "import runpy, sys;" - "sys.path.pop(0);" - f"sys.argv.extend({passthrough!r});" - f'runpy.run_module("{main_module}", run_name="__main__", alter_sys=True)' - ), - ], - env=env, - encoding="UTF-8", - cwd=self.tools.home_path, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=1, - ) + cmdline = [ + # Do not add additional switches for sys.executable; see DEV_ENVIRONMENT + sys.executable, + "-c", + ( + "import runpy, sys;" + "sys.path.pop(0);" + f"sys.argv.extend({passthrough!r});" + f'runpy.run_module("{main_module}", run_name="__main__", alter_sys=True)' + ), + ] - # Start streaming logs for the app. - self._stream_app_logs( - app, - popen=app_popen, - test_mode=test_mode, - clean_output=False, - ) + # Console apps must operate in non-streaming mode so that console input can + # be handled correctly. However, if we're in test mode, we *must* stream so + # that we can see the test exit sentinel + if app.console_app and not test_mode: + self.logger.info("=" * 75) + self.tools.subprocess.run( + cmdline, + env=env, + encoding="UTF-8", + cwd=self.tools.home_path, + bufsize=1, + stream_output=False, + ) + else: + app_popen = self.tools.subprocess.Popen( + cmdline, + env=env, + encoding="UTF-8", + cwd=self.tools.home_path, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + ) + + # Start streaming logs for the app. + self._stream_app_logs( + app, + popen=app_popen, + test_mode=test_mode, + clean_output=False, + ) def get_environment(self, app, test_mode: bool): # Create a shell environment where PYTHONPATH points to the source @@ -172,6 +188,10 @@ def get_environment(self, app, test_mode: bool): if self.platform == "windows": # pragma: no branch env["PYTHONMALLOC"] = "default" # pragma: no-cover-if-not-windows + # If we're in verbose mode, put BRIEFCASE_DEBUG into the environment + if self.logger.is_debug: + env["BRIEFCASE_DEBUG"] = "1" + return env def __call__( diff --git a/src/briefcase/commands/run.py b/src/briefcase/commands/run.py index 4977b3419..5746bec71 100644 --- a/src/briefcase/commands/run.py +++ b/src/briefcase/commands/run.py @@ -220,8 +220,8 @@ def add_options(self, parser): self._add_update_options(parser, context_label=" before running") self._add_test_options(parser, context_label="Run") - def _prepare_app_env(self, app: AppConfig, test_mode: bool): - """Prepare the environment for running an app as a log stream. + def _prepare_app_kwargs(self, app: AppConfig, test_mode: bool): + """Prepare the kwargs for running an app as a log stream. This won't be used by every backend; but it's a sufficiently common default that it's been factored out. @@ -230,18 +230,26 @@ def _prepare_app_env(self, app: AppConfig, test_mode: bool): :param test_mode: Are we launching in test mode? :returns: A dictionary of additional arguments to pass to the Popen """ + args = {} + env = {} + + # If we're in debug mode, put BRIEFCASE_DEBUG into the environment + if self.logger.is_debug: + env["BRIEFCASE_DEBUG"] = "1" + if test_mode: # In test mode, set a BRIEFCASE_MAIN_MODULE environment variable # to override the module at startup + env["BRIEFCASE_MAIN_MODULE"] = app.main_module(test_mode) self.logger.info("Starting test_suite...", prefix=app.app_name) - return { - "env": { - "BRIEFCASE_MAIN_MODULE": app.main_module(test_mode), - } - } else: self.logger.info("Starting app...", prefix=app.app_name) - return {} + + # If we need any environment variables, add them to the arguments. + if env: + args["env"] = env + + return args @abstractmethod def run_app(self, app: AppConfig, **options) -> dict | None: @@ -284,14 +292,14 @@ def __call__( self.finalize(app) template_file = self.bundle_path(app) - binary_file = self.binary_path(app) + exec_file = self.binary_executable_path(app) if ( (not template_file.exists()) # App hasn't been created or update # An explicit update has been requested or update_requirements # An explicit update of requirements has been requested or update_resources # An explicit update of resources has been requested or update_support # An explicit update of support files has been requested - or (not binary_file.exists()) # Binary doesn't exist yet + or (not exec_file.exists()) # Executable binary doesn't exist yet or ( test_mode and not no_update ) # Test mode, but updates have not been disabled diff --git a/src/briefcase/config.py b/src/briefcase/config.py index c1d793642..14cc2ac00 100644 --- a/src/briefcase/config.py +++ b/src/briefcase/config.py @@ -199,6 +199,7 @@ def __init__( test_requires=None, supported=True, long_description=None, + console_app=False, **kwargs, ): super().__init__(**kwargs) @@ -227,6 +228,7 @@ def __init__( self.supported = supported self.long_description = long_description self.license = license + self.console_app = console_app if not is_valid_app_name(self.app_name): raise BriefcaseConfigError( diff --git a/src/briefcase/integrations/flatpak.py b/src/briefcase/integrations/flatpak.py index c7ba438ec..e15d5f567 100644 --- a/src/briefcase/integrations/flatpak.py +++ b/src/briefcase/integrations/flatpak.py @@ -255,6 +255,7 @@ def run( bundle_identifier: str, args: list[SubprocessArgT] | None = None, main_module: str | None = None, + stream_output: bool = True, ) -> subprocess.Popen[str]: """Run a Flatpak in a way that allows for log streaming. @@ -262,7 +263,9 @@ def run( :param args: (Optional) The list of arguments to pass to the app :param main_module: (Optional) The main module to run. Only required if you want to override the default main module for the app. - :returns: A Popen object for the running app. + :param stream_output: Should output be streamed? + :returns: A Popen object for the running app; or ``None`` if the app isn't + streaming """ if main_module: # Set a BRIEFCASE_MAIN_MODULE environment variable @@ -278,17 +281,28 @@ def run( flatpak_run_cmd = ["flatpak", "run", bundle_identifier] flatpak_run_cmd.extend([] if args is None else args) + if self.tools.logger.is_debug: + kwargs.setdefault("env", {})["BRIEFCASE_DEBUG"] = "1" + if self.tools.logger.is_deep_debug: # Must come before bundle identifier; otherwise, it's passed as an arg to app flatpak_run_cmd.insert(2, "--verbose") - return self.tools.subprocess.Popen( - flatpak_run_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=1, - **kwargs, - ) + if stream_output: + return self.tools.subprocess.Popen( + flatpak_run_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + **kwargs, + ) + else: + return self.tools.subprocess.run( + flatpak_run_cmd, + bufsize=1, + stream_output=False, + **kwargs, + ) def bundle( self, diff --git a/src/briefcase/platforms/linux/appimage.py b/src/briefcase/platforms/linux/appimage.py index 939cef370..0a302ec6a 100644 --- a/src/briefcase/platforms/linux/appimage.py +++ b/src/briefcase/platforms/linux/appimage.py @@ -379,25 +379,38 @@ def run_app( :param passthrough: The list of arguments to pass to the app """ # Set up the log stream - kwargs = self._prepare_app_env(app=app, test_mode=test_mode) - - # Start the app in a way that lets us stream the logs - app_popen = self.tools.subprocess.Popen( - [self.binary_path(app)] + passthrough, - cwd=self.tools.home_path, - **kwargs, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=1, - ) + kwargs = self._prepare_app_kwargs(app=app, test_mode=test_mode) + + # Console apps must operate in non-streaming mode so that console input can + # be handled correctly. However, if we're in test mode, we *must* stream so + # that we can see the test exit sentinel + if app.console_app and not test_mode: + self.logger.info("=" * 75) + self.tools.subprocess.run( + [self.binary_path(app)] + passthrough, + cwd=self.tools.home_path, + bufsize=1, + stream_output=False, + **kwargs, + ) + else: + # Start the app in a way that lets us stream the logs + app_popen = self.tools.subprocess.Popen( + [self.binary_path(app)] + passthrough, + cwd=self.tools.home_path, + **kwargs, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + ) - # Start streaming logs for the app. - self._stream_app_logs( - app, - popen=app_popen, - test_mode=test_mode, - clean_output=False, - ) + # Start streaming logs for the app. + self._stream_app_logs( + app, + popen=app_popen, + test_mode=test_mode, + clean_output=False, + ) class LinuxAppImagePackageCommand(LinuxAppImageMixin, PackageCommand): diff --git a/src/briefcase/platforms/linux/flatpak.py b/src/briefcase/platforms/linux/flatpak.py index 94ccc27b3..d996a23c7 100644 --- a/src/briefcase/platforms/linux/flatpak.py +++ b/src/briefcase/platforms/linux/flatpak.py @@ -213,28 +213,43 @@ def run_app( :param passthrough: The list of arguments to pass to the app """ # Set up the log stream - kwargs = self._prepare_app_env(app=app, test_mode=test_mode) + kwargs = self._prepare_app_kwargs(app=app, test_mode=test_mode) # Starting a flatpak has slightly different startup arguments; however, # the rest of the app startup process is the same. Transform the output # of the "default" behavior to be in flatpak format. if test_mode: kwargs = {"main_module": kwargs["env"]["BRIEFCASE_MAIN_MODULE"]} + else: + kwargs = {} + + # Console apps must operate in non-streaming mode so that console input can + # be handled correctly. However, if we're in test mode, we *must* stream so + # that we can see the test exit sentinel + if app.console_app and not test_mode: + self.logger.info("=" * 75) + self.tools.flatpak.run( + bundle_identifier=app.bundle_identifier, + args=passthrough, + stream_output=False, + **kwargs, + ) + else: + # Start the app in a way that lets us stream the logs + app_popen = self.tools.flatpak.run( + bundle_identifier=app.bundle_identifier, + args=passthrough, + stream_output=True, + **kwargs, + ) - # Start the app in a way that lets us stream the logs - app_popen = self.tools.flatpak.run( - bundle_identifier=app.bundle_identifier, - args=passthrough, - **kwargs, - ) - - # Start streaming logs for the app. - self._stream_app_logs( - app, - popen=app_popen, - test_mode=test_mode, - clean_output=False, - ) + # Start streaming logs for the app. + self._stream_app_logs( + app, + popen=app_popen, + test_mode=test_mode, + clean_output=False, + ) class LinuxFlatpakPackageCommand(LinuxFlatpakMixin, PackageCommand): diff --git a/src/briefcase/platforms/linux/system.py b/src/briefcase/platforms/linux/system.py index 8f236905e..b28b25f8d 100644 --- a/src/briefcase/platforms/linux/system.py +++ b/src/briefcase/platforms/linux/system.py @@ -827,26 +827,39 @@ def run_app( :param passthrough: The list of arguments to pass to the app """ # Set up the log stream - kwargs = self._prepare_app_env(app=app, test_mode=test_mode) + kwargs = self._prepare_app_kwargs(app=app, test_mode=test_mode) with self.tools[app].app_context.run_app_context(kwargs) as kwargs: - # Start the app in a way that lets us stream the logs - app_popen = self.tools[app].app_context.Popen( - [self.binary_path(app)] + passthrough, - cwd=self.tools.home_path, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=1, - **kwargs, - ) + # Console apps must operate in non-streaming mode so that console input can + # be handled correctly. However, if we're in test mode, we *must* stream so + # that we can see the test exit sentinel + if app.console_app and not test_mode: + self.logger.info("=" * 75) + self.tools[app].app_context.run( + [self.binary_path(app)] + passthrough, + cwd=self.tools.home_path, + bufsize=1, + stream_output=False, + **kwargs, + ) + else: + # Start the app in a way that lets us stream the logs + app_popen = self.tools[app].app_context.Popen( + [self.binary_path(app)] + passthrough, + cwd=self.tools.home_path, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + **kwargs, + ) - # Start streaming logs for the app. - self._stream_app_logs( - app, - popen=app_popen, - test_mode=test_mode, - clean_output=False, - ) + # Start streaming logs for the app. + self._stream_app_logs( + app, + popen=app_popen, + test_mode=test_mode, + clean_output=False, + ) def debian_multiline_description(description): diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py index 04c29593f..02e828c9c 100644 --- a/src/briefcase/platforms/macOS/__init__.py +++ b/src/briefcase/platforms/macOS/__init__.py @@ -3,6 +3,7 @@ import concurrent.futures import itertools import os +import plistlib import re import subprocess import time @@ -33,6 +34,37 @@ ) +class SigningIdentity: + def __init__(self, id="-", name=None): + """A wrapper around the various forms of an Apple signing identity.""" + self.id = id + if self.id == "-": + self.team_id = None + self.name = ADHOC_IDENTITY_NAME + else: + self.name = name + try: + self.team_id = re.match(r".*\(([\dA-Z]*)\)", name)[1] + except TypeError: + raise BriefcaseCommandError( + f"Couldn't extract Team ID from signing identity {name!r}" + ) + + @property + def is_adhoc(self): + """Is this the adhoc identity?""" + return self.id == "-" + + def __repr__(self): + if self.is_adhoc: + return "" + else: + return f"" + + def __eq__(self, other): + return isinstance(other, SigningIdentity) and self.id == other.id + + class macOSMixin: platform = "macOS" supported_host_os = {"Darwin"} @@ -207,6 +239,66 @@ def run_app( ): """Start the application. + :param app: The config object for the app + :param test_mode: Boolean; Is the app running in test mode? + :param passthrough: The list of arguments to pass to the app + """ + # Console apps must operate in non-streaming mode so that console input can + # be handled correctly. However, if we're in test mode, we *must* stream so + # that we can see the test exit sentinel + if app.console_app and not test_mode: + self.run_console_app( + app, + passthrough=passthrough, + **kwargs, + ) + else: + self.run_gui_app( + app, + test_mode=test_mode, + passthrough=passthrough, + **kwargs, + ) + + def run_console_app( + self, + app: AppConfig, + passthrough: list[str], + **kwargs, + ): + """Start the console application. + + :param app: The config object for the app + :param passthrough: The list of arguments to pass to the app + """ + try: + kwargs = self._prepare_app_kwargs(app=app, test_mode=False) + + # Start the app directly + self.logger.info("=" * 75) + self.tools.subprocess.run( + [self.binary_path(app) / "Contents" / "MacOS" / f"{app.formal_name}"] + + (passthrough if passthrough else []), + cwd=self.tools.home_path, + check=True, + stream_output=False, + **kwargs, + ) + + except subprocess.CalledProcessError: + # The command line app *could* returns an error code, which is entirely legal. + # Ignore any subprocess error here. + pass + + def run_gui_app( + self, + app: AppConfig, + test_mode: bool, + passthrough: list[str], + **kwargs, + ): + """Start the GUI application. + :param app: The config object for the app :param test_mode: Boolean; Is the app running in test mode? :param passthrough: The list of arguments to pass to the app @@ -250,7 +342,7 @@ def run_app( app_pid = None try: # Set up the log stream - kwargs = self._prepare_app_env(app=app, test_mode=test_mode) + kwargs = self._prepare_app_kwargs(app=app, test_mode=test_mode) # Start the app in a way that lets us stream the logs self.tools.subprocess.run( @@ -327,7 +419,7 @@ def __init__(self, *args, **kwargs): def entitlements_path(self, app: AppConfig): return self.bundle_path(app) / self.path_index(app, "entitlements_path") - def select_identity(self, identity=None): + def select_identity(self, identity: str | None = None) -> SigningIdentity: """Get the codesigning identity to use. :param identity: A pre-specified identity (either the 40-digit hex checksum, or @@ -344,13 +436,13 @@ def select_identity(self, identity=None): try: # Try to look up the identity as a hex checksum identity_name = identities[identity] - return identity, identity_name + return SigningIdentity(id=identity, name=identity_name) except KeyError as e: # Try to look up the identity as readable name try: reverse_lookup = {name: ident for ident, name in identities.items()} identity_id = reverse_lookup[identity] - return identity_id, identity + return SigningIdentity(id=identity_id, name=identity) except KeyError: # Not found as an ID or name raise BriefcaseCommandError( @@ -368,6 +460,7 @@ def select_identity(self, identity=None): In the future, you could specify this signing identity by running: $ briefcase {self.command} macOS --adhoc-sign + """ ) else: @@ -380,21 +473,26 @@ def select_identity(self, identity=None): or $ briefcase {self.command} macOS -i "{identity_name}" + """ ) - return identity, identity_name + return SigningIdentity(id=identity, name=identity_name) - def sign_file(self, path, identity, entitlements=None): + def sign_file( + self, + path: Path, + identity: SigningIdentity, + entitlements: Path | None = None, + ): """Code sign a file. :param path: The path to the file to sign. - :param identity: The code signing identity to use. Either the 40-digit hex - checksum, or the string name of the identity. + :param identity: The code signing identity to use. :param entitlements: The path to the entitlements file to use. """ - options = "runtime" if identity != "-" else None - process_command = ["codesign", path, "--sign", identity, "--force"] + options = "runtime" if not identity.is_adhoc else None + process_command = ["codesign", path, "--sign", identity.id, "--force"] if entitlements: process_command.append("--entitlements") @@ -431,7 +529,7 @@ def sign_file(self, path, identity, entitlements=None): else: raise BriefcaseCommandError(f"Unable to code sign {path}.") - def sign_app(self, app, identity): + def sign_app(self, app: AppConfig, identity: SigningIdentity): """Sign an entire app with a specific identity. :param app: The app to sign @@ -511,17 +609,20 @@ class macOSPackageMixin(macOSSigningMixin): @property def packaging_formats(self): - return ["app", "dmg"] + return ["zip", "dmg", "pkg"] @property def default_packaging_format(self): - return "dmg" + # The default changes depending on whether the app is a console app or a GUI app + return None def distribution_path(self, app): - if app.packaging_format == "dmg": - return self.dist_path / f"{app.formal_name}-{app.version}.dmg" - else: + if app.packaging_format == "zip": return self.dist_path / f"{app.formal_name}-{app.version}.app.zip" + elif app.packaging_format == "pkg": + return self.dist_path / f"{app.formal_name}-{app.version}.pkg" + else: + return self.dist_path / f"{app.formal_name}-{app.version}.dmg" def add_options(self, parser): super().add_options(parser) @@ -551,23 +652,20 @@ def __init__(self, *args, **kwargs): # These are abstracted to enable testing without patching. self.dmgbuild = dmgbuild - def team_id_from_identity(self, identity_name): - """Extract the team ID from the full identity name. + def verify_app(self, app): + super().verify_app(app) - The identity name will be in the form: - Some long identifying name (Team ID) - - :param identity_name: The full identity name - :returns: The team ID string. - """ - try: - return re.match(r".*\(([\dA-Z]*)\)", identity_name)[1] - except TypeError: - raise BriefcaseCommandError( - f"Couldn't extract Team ID from signing identity {identity_name!r}" - ) + if app.console_app: + if app.packaging_format is None: + app.packaging_format = "pkg" + elif app.packaging_format != "pkg": + raise BriefcaseCommandError( + "macOS console apps must be distributed in PKG format." + ) + elif app.packaging_format is None: + app.packaging_format = "dmg" - def notarize(self, filename, team_id): + def notarize(self, filename, identity: SigningIdentity): """Notarize a file. Submits the file to Apple for notarization; if successful, staples the @@ -576,7 +674,7 @@ def notarize(self, filename, team_id): If the file is a .app, it will be archived as a .zip for submission purposes. :param filename: The file to notarize. - :param team_id: The team ID to + :param identity: The code signing identity to use """ try: if filename.suffix == ".app": @@ -597,7 +695,7 @@ def notarize(self, filename, team_id): f"Don't know how to notarize a file of type {filename.suffix}" ) - profile = f"briefcase-macOS-{team_id}" + profile = f"briefcase-macOS-{identity.team_id}" submitted = False store_credentials = False while not submitted: @@ -608,7 +706,8 @@ def notarize(self, filename, team_id): The keychain does not contain credentials for the profile {profile}. You can store these credentials by invoking: - $ xcrun notarytool store-credentials --team-id {team_id} profile + $ xcrun notarytool store-credentials --team-id {identity.team_id} profile + """ ) @@ -637,7 +736,7 @@ def notarize(self, filename, team_id): "notarytool", "store-credentials", "--team-id", - team_id, + identity.team_id, profile, ], check=True, @@ -645,7 +744,7 @@ def notarize(self, filename, team_id): ) except subprocess.CalledProcessError as e: raise BriefcaseCommandError( - f"Unable to store credentials for team ID {team_id}." + f"Unable to store credentials for team ID {identity.team_id}." ) from e # Attempt the notarization @@ -729,12 +828,11 @@ def package_app( """ self.logger.info("Signing app...", prefix=app.app_name) if adhoc_sign: - identity = "-" - identity_name = ADHOC_IDENTITY_NAME + identity = SigningIdentity() else: - identity, identity_name = self.select_identity(identity=identity) + identity = self.select_identity(identity=identity) - if identity == "-": + if identity.is_adhoc: if notarize_app: raise BriefcaseCommandError( "Can't notarize an app with an ad-hoc signing identity" @@ -765,93 +863,236 @@ def package_app( if notarize_app is None: notarize_app = True - self.logger.info(f"Signing app with identity {identity_name}...") - - if notarize_app: - team_id = self.team_id_from_identity(identity_name) + self.logger.info(f"Signing app with identity {identity.name}...") self.sign_app(app=app, identity=identity) + if app.packaging_format == "zip": + self.package_zip( + app, + notarize_app=notarize_app, + identity=identity, + ) + + elif app.packaging_format == "pkg": + self.package_pkg( + app, + notarize_app=notarize_app, + identity=identity, + ) + + else: # Default packaging format is DMG + self.package_dmg( + app, + notarize_app=notarize_app, + identity=identity, + ) + + def package_zip( + self, + app: AppConfig, + notarize_app: bool, + identity: SigningIdentity, + ): + """Package an .app bundle in a zip file.""" dist_path: Path = self.distribution_path(app) - if app.packaging_format == "app": - if notarize_app: - self.logger.info( - f"Notarizing app using team ID {team_id}...", - prefix=app.app_name, + if notarize_app: + self.logger.info( + f"Notarizing app using team ID {identity.team_id}...", + prefix=app.app_name, + ) + self.notarize(self.binary_path(app), identity=identity) + + with self.input.wait_bar(f"Archiving {dist_path.name}..."): + self.tools.shutil.make_archive( + dist_path.with_suffix(""), + format="zip", + root_dir=self.binary_path(app).parent, + base_dir=self.binary_path(app).name, + ) + + def package_pkg( + self, + app: AppConfig, + notarize_app: bool, + identity: SigningIdentity, + ): + """Package the app as an installer.""" + dist_path: Path = self.distribution_path(app) + + self.logger.info("Building PKG...", prefix=app.app_name) + + installer_path = self.bundle_path(app) / "installer" + + with self.input.wait_bar("Installing license..."): + license_file = self.base_path / "LICENSE" + if license_file.is_file(): + (installer_path / "resources").mkdir(exist_ok=True) + self.tools.shutil.copy( + license_file, + installer_path / "resources/LICENSE", ) - self.notarize(self.binary_path(app), team_id=team_id) - - with self.input.wait_bar(f"Archiving {dist_path.name}..."): - self.tools.shutil.make_archive( - dist_path.with_suffix(""), - format="zip", - root_dir=self.binary_path(app).parent, - base_dir=self.binary_path(app).name, + else: + raise BriefcaseCommandError( + """\ +Your project does not contain a LICENSE file. + +Create a file named `LICENSE` in the same directory as your `pyproject.toml` +with your app's licensing terms. +""" ) - else: # Default packaging format is DMG - self.logger.info("Building DMG...", prefix=app.app_name) - - with self.input.wait_bar(f"Building {dist_path.name}..."): - dmg_settings = { - "files": [os.fsdecode(self.binary_path(app))], - "symlinks": {"Applications": "/Applications"}, - "icon_locations": { - f"{app.formal_name}.app": (75, 75), - "Applications": (225, 75), - }, - "window_rect": ((600, 600), (350, 150)), - "icon_size": 64, - "text_size": 12, - } + # pkgbuild's default behavior is to make "relocatable" installs, which means + # that if you've ever run the app, the installer will default to updating *that* + # version, rather than putting it in the location that the installer specifies. + # This means if you've ever used `briefcase run`, that will be the install + # location of the "installed" app. To work around this, you have to provide a + # plist file - but that requires providing a "root" folder that *only* contains + # the products you want to install. So - we need to copy the built app to a + # "clean" packaging location. + with self.input.wait_bar("Copying app into products folder..."): + installed_app_path = installer_path / "root" / self.binary_path(app).name + if installed_app_path.exists(): + self.tools.shutil.rmtree(installed_app_path) + self.tools.shutil.copytree(self.binary_path(app), installed_app_path) + + components_plist_path = self.bundle_path(app) / "installer/components.plist" + + with self.input.wait_bar("Writing component manifest..."): + with components_plist_path.open("wb") as components_plist: + plistlib.dump( + [ + { + "BundleHasStrictIdentifier": True, + "BundleIsRelocatable": False, + "BundleIsVersionChecked": True, + "BundleOverwriteAction": "upgrade", + "RootRelativeBundlePath": self.binary_path(app).name, + } + ], + components_plist, + ) - try: - icon_filename = self.base_path / f"{app.installer_icon}.icns" + # Console apps are installed in /Library/Formal Name, and include the + # post-install scripts. Normal apps are installed in /Applications, and don't + # include the scripts. + if app.console_app: + install_args = [ + "--install-location", + f"/Library/{app.formal_name}", + "--scripts", + installer_path / "scripts", + ] + else: + install_args = ["--install-location", "/Applications"] + + with self.input.wait_bar("Building app package..."): + installer_packages_path = installer_path / "packages" + if installer_packages_path.exists(): + self.tools.shutil.rmtree(installer_packages_path) + installer_packages_path.mkdir() + + self.tools.subprocess.run( + [ + "pkgbuild", + "--root", + installer_path / "root", + "--component-plist", + components_plist_path, + ] + + install_args + + [ + installer_packages_path / f"{app.app_name}.pkg", + ], + check=True, + ) + + # Build package + with self.input.wait_bar(f"Building {dist_path.name}..."): + self.tools.subprocess.run( + [ + "productbuild", + "--distribution", + installer_path / "Distribution.xml", + "--package-path", + installer_path / "packages", + "--resources", + installer_path / "resources", + dist_path, + ], + check=True, + ) + + def package_dmg( + self, + app: AppConfig, + notarize_app: bool, + identity: SigningIdentity, + ): + """Package an app as a DMG installer.""" + dist_path: Path = self.distribution_path(app) + self.logger.info("Building DMG...", prefix=app.app_name) + + with self.input.wait_bar(f"Building {dist_path.name}..."): + dmg_settings = { + "files": [os.fsdecode(self.binary_path(app))], + "symlinks": {"Applications": "/Applications"}, + "icon_locations": { + f"{app.formal_name}.app": (75, 75), + "Applications": (225, 75), + }, + "window_rect": ((600, 600), (350, 150)), + "icon_size": 64, + "text_size": 12, + } + + try: + icon_filename = self.base_path / f"{app.installer_icon}.icns" + if not icon_filename.exists(): + self.logger.warning( + f"Can't find {app.installer_icon}.icns to use as DMG installer icon" + ) + raise AttributeError() + except AttributeError: + # No installer icon specified. Fall back to the app icon + if app.icon: + icon_filename = self.base_path / f"{app.icon}.icns" if not icon_filename.exists(): self.logger.warning( - f"Can't find {app.installer_icon}.icns to use as DMG installer icon" + f"Can't find {app.icon}.icns to use as fallback DMG installer icon" ) - raise AttributeError() - except AttributeError: - # No installer icon specified. Fall back to the app icon - if app.icon: - icon_filename = self.base_path / f"{app.icon}.icns" - if not icon_filename.exists(): - self.logger.warning( - f"Can't find {app.icon}.icns to use as fallback DMG installer icon" - ) - icon_filename = None - else: - # No app icon specified either icon_filename = None + else: + # No app icon specified either + icon_filename = None - if icon_filename: - dmg_settings["icon"] = os.fsdecode(icon_filename) + if icon_filename: + dmg_settings["icon"] = os.fsdecode(icon_filename) - try: - image_filename = self.base_path / f"{app.installer_background}.png" - if image_filename.exists(): - dmg_settings["background"] = os.fsdecode(image_filename) - else: - self.logger.warning( - f"Can't find {app.installer_background}.png to use as DMG background" - ) - except AttributeError: - # No installer background image provided - pass - - self.dmgbuild.build_dmg( - filename=os.fsdecode(dist_path), - volume_name=f"{app.formal_name} {app.version}", - settings=dmg_settings, - ) + try: + image_filename = self.base_path / f"{app.installer_background}.png" + if image_filename.exists(): + dmg_settings["background"] = os.fsdecode(image_filename) + else: + self.logger.warning( + f"Can't find {app.installer_background}.png to use as DMG background" + ) + except AttributeError: + # No installer background image provided + pass + + self.dmgbuild.build_dmg( + filename=os.fsdecode(dist_path), + volume_name=f"{app.formal_name} {app.version}", + settings=dmg_settings, + ) - self.sign_file(dist_path, identity=identity) + self.sign_file(dist_path, identity=identity) - if notarize_app: - self.logger.info( - f"Notarizing DMG with team ID {team_id}...", - prefix=app.app_name, - ) - self.notarize(dist_path, team_id=team_id) + if notarize_app: + self.logger.info( + f"Notarizing DMG with team ID {identity.team_id}...", + prefix=app.app_name, + ) + self.notarize(dist_path, identity=identity) diff --git a/src/briefcase/platforms/macOS/app.py b/src/briefcase/platforms/macOS/app.py index 2ff7ae347..de1547cc0 100644 --- a/src/briefcase/platforms/macOS/app.py +++ b/src/briefcase/platforms/macOS/app.py @@ -13,12 +13,14 @@ ) from briefcase.config import AppConfig from briefcase.platforms.macOS import ( + SigningIdentity, macOSCreateMixin, macOSMixin, macOSPackageMixin, macOSRunMixin, macOSSigningMixin, ) +from briefcase.platforms.macOS.utils import AppPackagesMergeMixin class macOSAppMixin(macOSMixin): @@ -30,6 +32,11 @@ def project_path(self, app): def binary_path(self, app): return self.bundle_path(app) / f"{app.formal_name}.app" + def binary_executable_path(self, app) -> Path: + # The actual binary in a macOS app is a known path + # inside the "binary" app bundle that is executed. + return self.binary_path(app) / "Contents/MacOS" / app.formal_name + class macOSAppCreateCommand(macOSAppMixin, macOSCreateMixin, CreateCommand): description = "Create and populate a macOS app." @@ -59,15 +66,6 @@ def install_app_support_package(self, app: AppConfig): runtime_support_path / "python-stdlib", ) - if not getattr(app, "universal_build", True): - with self.input.wait_bar("Ensuring stub binary is thin..."): - # The stub binary is universal by default. If we're building a non-universal app, - # we can strip the binary to remove the unused slice. - self.ensure_thin_binary( - self.binary_path(app) / "Contents/MacOS" / app.formal_name, - arch=self.tools.host_arch, - ) - def install_app_resources(self, app: AppConfig): super().install_app_resources(app) @@ -84,7 +82,12 @@ class macOSAppOpenCommand(macOSAppMixin, OpenCommand): description = "Open the app bundle folder for an existing macOS app." -class macOSAppBuildCommand(macOSAppMixin, macOSSigningMixin, BuildCommand): +class macOSAppBuildCommand( + macOSAppMixin, + macOSSigningMixin, + AppPackagesMergeMixin, + BuildCommand, +): description = "Build a macOS app." def build_app(self, app: AppConfig, **kwargs): @@ -92,12 +95,29 @@ def build_app(self, app: AppConfig, **kwargs): :param app: The application to build """ + self.logger.info("Building App...", prefix=app.app_name) + + # Move the stub binary in to the final executable location + stub_path = self.binary_path(app) / "Contents/MacOS/Stub" + if stub_path.exists(): + with self.input.wait_bar("Renaming stub binary..."): + stub_path.rename(self.binary_executable_path(app)) + + if not getattr(app, "universal_build", True): + with self.input.wait_bar("Ensuring stub binary is thin..."): + # The stub binary is universal by default. If we're building a non-universal app, + # we can strip the binary to remove the unused slice. This occurs before the + self.ensure_thin_binary( + self.binary_executable_path(app), + arch=self.tools.host_arch, + ) + # macOS apps don't have anything to compile, but they do need to be # signed to be able to execute on Apple Silicon hardware - even if it's only an # ad-hoc signing identity. Apply an ad-hoc signing identity to the # app bundle. self.logger.info("Ad-hoc signing app...", prefix=app.app_name) - self.sign_app(app=app, identity="-") + self.sign_app(app=app, identity=SigningIdentity()) class macOSAppRunCommand(macOSRunMixin, macOSAppMixin, RunCommand): diff --git a/src/briefcase/platforms/windows/__init__.py b/src/briefcase/platforms/windows/__init__.py index c4ca1374b..c720b7f1d 100644 --- a/src/briefcase/platforms/windows/__init__.py +++ b/src/briefcase/platforms/windows/__init__.py @@ -20,7 +20,12 @@ class WindowsMixin: supported_host_os_reason = "Windows applications can only be built on Windows." def binary_path(self, app): - return self.bundle_path(app) / self.packaging_root / f"{app.formal_name}.exe" + if app.console_app: + return self.bundle_path(app) / self.packaging_root / f"{app.app_name}.exe" + else: + return ( + self.bundle_path(app) / self.packaging_root / f"{app.formal_name}.exe" + ) def distribution_path(self, app): suffix = "zip" if app.packaging_format == "zip" else "msi" @@ -136,26 +141,40 @@ def run_app( :param passthrough: The list of arguments to pass to the app """ # Set up the log stream - kwargs = self._prepare_app_env(app=app, test_mode=test_mode) - - # Start the app in a way that lets us stream the logs - app_popen = self.tools.subprocess.Popen( - [self.binary_path(app)] + passthrough, - cwd=self.tools.home_path, - encoding="UTF-8", - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=1, - **kwargs, - ) + kwargs = self._prepare_app_kwargs(app=app, test_mode=test_mode) + + # Console apps must operate in non-streaming mode so that console input can + # be handled correctly. However, if we're in test mode, we *must* stream so + # that we can see the test exit sentinel + if app.console_app and not test_mode: + self.logger.info("=" * 75) + self.tools.subprocess.run( + [self.binary_path(app)] + passthrough, + cwd=self.tools.home_path, + encoding="UTF-8", + bufsize=1, + stream_output=False, + **kwargs, + ) + else: + # Start the app in a way that lets us stream the logs + app_popen = self.tools.subprocess.Popen( + [self.binary_path(app)] + passthrough, + cwd=self.tools.home_path, + encoding="UTF-8", + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + **kwargs, + ) - # Start streaming logs for the app. - self._stream_app_logs( - app, - popen=app_popen, - test_mode=test_mode, - clean_output=False, - ) + # Start streaming logs for the app. + self._stream_app_logs( + app, + popen=app_popen, + test_mode=test_mode, + clean_output=False, + ) class WindowsPackageCommand(PackageCommand): diff --git a/src/briefcase/platforms/windows/app.py b/src/briefcase/platforms/windows/app.py index 35613288c..6e5a40c01 100644 --- a/src/briefcase/platforms/windows/app.py +++ b/src/briefcase/platforms/windows/app.py @@ -54,6 +54,12 @@ def build_app(self, app: BaseConfig, **kwargs): """ self.logger.info("Building App...", prefix=app.app_name) + # Move the stub binary in to the final executable location + stub_path = self.binary_path(app).parent / "Stub.exe" + if stub_path.exists(): + with self.input.wait_bar("Renaming stub binary..."): + stub_path.rename(self.binary_path(app)) + if hasattr(self.tools, "windows_sdk"): # If an app has been packaged and code signed previously, then the digital # signature on the app binary needs to be removed before re-building the app. diff --git a/tests/commands/create/test_generate_app_template.py b/tests/commands/create/test_generate_app_template.py index e73fd8323..1e7247712 100644 --- a/tests/commands/create/test_generate_app_template.py +++ b/tests/commands/create/test_generate_app_template.py @@ -37,6 +37,7 @@ def full_context(): "requires": None, "icon": None, "supported": True, + "console_app": False, "permissions": {}, "custom_permissions": {}, "requests": {}, diff --git a/tests/commands/dev/test_get_environment.py b/tests/commands/dev/test_get_environment.py index d8a70c9f0..d9ea9bbb6 100644 --- a/tests/commands/dev/test_get_environment.py +++ b/tests/commands/dev/test_get_environment.py @@ -3,6 +3,8 @@ import pytest +from briefcase.console import LogLevel + PYTHONPATH = "PYTHONPATH" PYTHONMALLOC = "PYTHONMALLOC" @@ -76,3 +78,17 @@ def test_pythonpath_with_two_sources_and_tests_in_linux(dev_command, third_app): == f"{Path.cwd() / 'src'}:{Path.cwd()}:{Path.cwd() / 'path' / 'to'}" ) assert PYTHONMALLOC not in env + + +def test_non_verbose_mode(dev_command, first_app): + """Non-verbose mode doesn't include BRIEFCASE_DEBUG in the dev environment.""" + dev_command.logger.verbosity = LogLevel.INFO + env = dev_command.get_environment(first_app, test_mode=False) + assert "BRIEFCASE_DEBUG" not in env + + +def test_verbose_mode(dev_command, first_app): + """Verbose mode adds BRIEFCASE_DEBUG to the dev environment.""" + dev_command.logger.verbosity = LogLevel.DEBUG + env = dev_command.get_environment(first_app, test_mode=False) + assert env["BRIEFCASE_DEBUG"] == "1" diff --git a/tests/commands/dev/test_run_dev_app.py b/tests/commands/dev/test_run_dev_app.py index 9cec5017c..4f49cf84e 100644 --- a/tests/commands/dev/test_run_dev_app.py +++ b/tests/commands/dev/test_run_dev_app.py @@ -2,6 +2,8 @@ import sys from unittest import mock +import pytest + def test_dev_run(dev_command, first_app, tmp_path): """The app can be run in dev mode.""" @@ -95,8 +97,12 @@ def test_dev_run_with_args(dev_command, first_app, tmp_path): ) -def test_dev_test_mode(dev_command, first_app, tmp_path): +@pytest.mark.parametrize("is_console_app", [True, False]) +def test_dev_test_mode(dev_command, first_app, is_console_app, tmp_path): """The test suite can be run in development mode.""" + # Test mode is the same regardless of whether it's a console app or not. + first_app.console_app = is_console_app + dev_command._stream_app_logs = mock.MagicMock() app_popen = mock.MagicMock() dev_command.tools.subprocess.Popen.return_value = app_popen @@ -141,8 +147,12 @@ def test_dev_test_mode(dev_command, first_app, tmp_path): ) -def test_dev_test_mode_with_args(dev_command, first_app, tmp_path): +@pytest.mark.parametrize("is_console_app", [True, False]) +def test_dev_test_mode_with_args(dev_command, first_app, is_console_app, tmp_path): """The test suite can be run in development mode with args.""" + # Test mode is the same regardless of whether it's a console app or not. + first_app.console_app = is_console_app + dev_command._stream_app_logs = mock.MagicMock() app_popen = mock.MagicMock() dev_command.tools.subprocess.Popen.return_value = app_popen @@ -185,3 +195,89 @@ def test_dev_test_mode_with_args(dev_command, first_app, tmp_path): test_mode=True, clean_output=False, ) + + +def test_dev_run_console(dev_command, first_app, tmp_path): + """A console app can be run in dev mode.""" + # Modify the app to be a console app + first_app.console_app = True + + dev_command._stream_app_logs = mock.MagicMock() + + dev_command.run_dev_app( + first_app, + env={"a": 1, "b": 2, "c": 3}, + test_mode=False, + passthrough=[], + ) + + dev_command.tools.subprocess.run.assert_called_once_with( + [ + sys.executable, + "-c", + ( + "import runpy, sys;" + "sys.path.pop(0);" + "sys.argv.extend([]);" + 'runpy.run_module("first", run_name="__main__", alter_sys=True)' + ), + ], + env={ + "a": 1, + "b": 2, + "c": 3, + "PYTHONUNBUFFERED": "1", + "PYTHONDEVMODE": "1", + "PYTHONUTF8": "1", + }, + cwd=dev_command.tools.home_path, + bufsize=1, + encoding="UTF-8", + stream_output=False, + ) + + # There's no log streaming + dev_command._stream_app_logs.assert_not_called() + + +def test_dev_run_console_with_args(dev_command, first_app, tmp_path): + "The console app can be run in dev mode with arguments" + # Modify the app to be a console app + first_app.console_app = True + + dev_command._stream_app_logs = mock.MagicMock() + + dev_command.run_dev_app( + first_app, + env={"a": 1, "b": 2, "c": 3}, + test_mode=False, + passthrough=["foo", "bar", "--whiz"], + ) + + dev_command.tools.subprocess.run.assert_called_once_with( + [ + sys.executable, + "-c", + ( + "import runpy, sys;" + "sys.path.pop(0);" + "sys.argv.extend(['foo', 'bar', '--whiz']);" + 'runpy.run_module("first", run_name="__main__", alter_sys=True)' + ), + ], + env={ + "a": 1, + "b": 2, + "c": 3, + "PYTHONUNBUFFERED": "1", + "PYTHONDEVMODE": "1", + "PYTHONUTF8": "1", + }, + cwd=dev_command.tools.home_path, + bufsize=1, + encoding="UTF-8", + stream_output=False, + ) + + # No attempt to stream logs + dev_command._stream_app_logs.assert_not_called() diff --git a/tests/commands/new/test_build_context.py b/tests/commands/new/test_build_context.py index dbbe3f5a8..ec86799a3 100644 --- a/tests/commands/new/test_build_context.py +++ b/tests/commands/new/test_build_context.py @@ -4,6 +4,7 @@ import briefcase.commands.new from briefcase.bootstraps import ( + ConsoleBootstrap, PygameGuiBootstrap, PySide6GuiBootstrap, TogaGuiBootstrap, @@ -14,6 +15,7 @@ def mock_builtin_bootstraps(): return { "Toga": TogaGuiBootstrap, + "Console": ConsoleBootstrap, "PySide6": PySide6GuiBootstrap, "Pygame": PygameGuiBootstrap, } @@ -249,6 +251,130 @@ def main(): ) +def test_question_sequence_console(new_command): + """A console app can be constructed.""" + + # Prime answers for all the questions. + new_command.input.values = [ + "My Application", # formal name + "", # app name - accept the default + "org.beeware", # bundle ID + "My Project", # project name + "Cool stuff", # description + "Grace Hopper", # author + "grace@navy.mil", # author email + "https://navy.mil/myapplication", # URL + "4", # license + "4", # Console app + ] + + context = new_command.build_context( + project_overrides={}, + ) + + assert context == dict( + app_name="myapplication", + author="Grace Hopper", + author_email="grace@navy.mil", + bundle="org.beeware", + class_name="MyApplication", + description="Cool stuff", + formal_name="My Application", + license="GNU General Public License v2 (GPLv2)", + module_name="myapplication", + source_dir="src/myapplication", + test_source_dir="tests", + project_name="My Project", + url="https://navy.mil/myapplication", + app_source="""\ + +def main(): + # Your app logic goes here + print("Hello, World.") + +""", + app_start_source="""\ +from {{ cookiecutter.module_name }}.app import main + +if __name__ == "__main__": + main() +""", + pyproject_table_briefcase_app_extra_content=""" +console_app = true +requires = [ +] +test_requires = [ +{% if cookiecutter.test_framework == "pytest" %} + "pytest", +{% endif %} +] +""", + pyproject_table_macOS="""\ +universal_build = true +requires = [ +] +""", + pyproject_table_linux="""\ +requires = [ +] +""", + pyproject_table_linux_system_debian="""\ +system_requires = [ + # Add any system packages needed at build the app here +] + +system_runtime_requires = [ + # Add any system packages needed at runtime here +] +""", + pyproject_table_linux_system_rhel="""\ +system_requires = [ + # Add any system packages needed at build the app here +] + +system_runtime_requires = [ + # Add any system packages needed at runtime here +] +""", + pyproject_table_linux_system_suse="""\ +system_requires = [ + # Add any system packages needed at build the app here +] + +system_runtime_requires = [ + # Add any system packages needed at runtime here +] +""", + pyproject_table_linux_system_arch="""\ +system_requires = [ + # Add any system packages needed at build the app here +] + +system_runtime_requires = [ + # Add any system packages needed at runtime here +] +""", + pyproject_table_linux_flatpak="""\ +flatpak_runtime = "org.freedesktop.Platform" +flatpak_runtime_version = "23.08" +flatpak_sdk = "org.freedesktop.Sdk" +""", + pyproject_table_windows="""\ +requires = [ +] +""", + pyproject_table_iOS="""\ +supported = false +""", + pyproject_table_android="""\ +supported = false +""", + pyproject_table_web="""\ +supported = false +""", + ) + + def test_question_sequence_pyside6(new_command): """Questions are asked, a context is constructed.""" @@ -599,7 +725,7 @@ def test_question_sequence_none(new_command): "grace@navy.mil", # author email "https://navy.mil/myapplication", # URL "4", # license - "4", # None + "5", # None ] context = new_command.build_context( @@ -752,7 +878,7 @@ def test_question_sequence_with_bad_bootstrap_override( # Prime answers for none of the questions. new_command.input.values = [ - "5", # None + "6", # None ] class GuiBootstrap: @@ -1066,7 +1192,7 @@ def platform(self): "grace@navy.mil", # author email "https://navy.mil/myapplication", # URL "4", # license - "4", # Custom GUI bootstrap + "5", # Custom GUI bootstrap ] context = new_command.build_context(project_overrides={}) @@ -1132,7 +1258,7 @@ def platform(self): "grace@navy.mil", # author email "https://navy.mil/myapplication", # URL "4", # license - "4", # Custom GUI bootstrap + "5", # Custom GUI bootstrap ] context = new_command.build_context(project_overrides={}) diff --git a/tests/integrations/flatpak/test_Flatpak__run.py b/tests/integrations/flatpak/test_Flatpak__run.py index 57e031c93..c9d1b8693 100644 --- a/tests/integrations/flatpak/test_Flatpak__run.py +++ b/tests/integrations/flatpak/test_Flatpak__run.py @@ -31,6 +31,7 @@ def test_run(flatpak, tool_debug_mode): stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=1, + **({"env": {"BRIEFCASE_DEBUG": "1"}} if tool_debug_mode else {}), ) # The popen object was returned. @@ -66,14 +67,49 @@ def test_run_with_args(flatpak, tool_debug_mode): stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=1, + **({"env": {"BRIEFCASE_DEBUG": "1"}} if tool_debug_mode else {}), ) # The popen object was returned. assert result == log_popen -def test_main_module_override(flatpak): +@pytest.mark.parametrize("tool_debug_mode", (True, False)) +def test_run_non_streaming(flatpak, tool_debug_mode): + """A Flatpak project can be executed in non-streaming mode.""" + # Enable verbose tool logging + if tool_debug_mode: + flatpak.tools.logger.verbosity = LogLevel.DEEP_DEBUG + + # Call run() + flatpak.run( + bundle_identifier="com.example.my-app", + args=["foo", "bar"], + stream_output=False, + ) + + # The expected call was made + flatpak.tools.subprocess.run.assert_called_once_with( + [ + "flatpak", + "run", + ] + + (["--verbose"] if tool_debug_mode else []) + + ["com.example.my-app"] + + ["foo", "bar"], + bufsize=1, + stream_output=False, + **({"env": {"BRIEFCASE_DEBUG": "1"}} if tool_debug_mode else {}), + ) + + +@pytest.mark.parametrize("tool_debug_mode", (True, False)) +def test_main_module_override(flatpak, tool_debug_mode): """The main module can be overridden.""" + # Enable verbose tool logging + if tool_debug_mode: + flatpak.tools.logger.verbosity = LogLevel.DEEP_DEBUG + # Set up the log streamer to return a known stream log_popen = mock.MagicMock() flatpak.tools.subprocess.Popen.return_value = log_popen @@ -89,14 +125,24 @@ def test_main_module_override(flatpak): [ "flatpak", "run", + ] + + (["--verbose"] if tool_debug_mode else []) + + [ "com.example.my-app", ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=1, - env={ - "BRIEFCASE_MAIN_MODULE": "org.beeware.test-case", - }, + env=( + { + "BRIEFCASE_MAIN_MODULE": "org.beeware.test-case", + "BRIEFCASE_DEBUG": "1", + } + if tool_debug_mode + else { + "BRIEFCASE_MAIN_MODULE": "org.beeware.test-case", + } + ), ) # The popen object was returned. diff --git a/tests/platforms/linux/appimage/test_run.py b/tests/platforms/linux/appimage/test_run.py index a174f8ff5..706d0bb81 100644 --- a/tests/platforms/linux/appimage/test_run.py +++ b/tests/platforms/linux/appimage/test_run.py @@ -3,7 +3,7 @@ import pytest -from briefcase.console import Console, Log +from briefcase.console import Console, Log, LogLevel from briefcase.exceptions import UnsupportedHostError from briefcase.integrations.subprocess import Subprocess from briefcase.platforms.linux.appimage import LinuxAppImageRunCommand @@ -43,8 +43,8 @@ def test_unsupported_host_os(run_command, host_os): run_command() -def test_run_app(run_command, first_app_config, tmp_path): - """A linux App can be started.""" +def test_run_gui_app(run_command, first_app_config, tmp_path): + """A linux GUI App can be started.""" # Set up the log streamer to return a known stream log_popen = mock.MagicMock() run_command.tools.subprocess.Popen.return_value = log_popen @@ -73,8 +73,10 @@ def test_run_app(run_command, first_app_config, tmp_path): ) -def test_run_app_with_passthrough(run_command, first_app_config, tmp_path): - """A linux App can be started with args.""" +def test_run_gui_app_with_passthrough(run_command, first_app_config, tmp_path): + """A linux GUI App can be started in debug mode with args.""" + run_command.logger.verbosity = LogLevel.DEBUG + # Set up the log streamer to return a known stream log_popen = mock.MagicMock() run_command.tools.subprocess.Popen.return_value = log_popen @@ -98,6 +100,7 @@ def test_run_app_with_passthrough(run_command, first_app_config, tmp_path): stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=1, + env={"BRIEFCASE_DEBUG": "1"}, ) # The streamer was started @@ -109,8 +112,8 @@ def test_run_app_with_passthrough(run_command, first_app_config, tmp_path): ) -def test_run_app_failed(run_command, first_app_config, tmp_path): - """If there's a problem starting the app, an exception is raised.""" +def test_run_gui_app_failed(run_command, first_app_config, tmp_path): + """If there's a problem starting the GUI app, an exception is raised.""" run_command.tools.subprocess.Popen.side_effect = OSError with pytest.raises(OSError): @@ -132,8 +135,89 @@ def test_run_app_failed(run_command, first_app_config, tmp_path): run_command._stream_app_logs.assert_not_called() -def test_run_app_test_mode(run_command, first_app_config, tmp_path): +def test_run_console_app(run_command, first_app_config, tmp_path): + """A linux console App can be started.""" + first_app_config.console_app = True + + # Run the app + run_command.run_app(first_app_config, test_mode=False, passthrough=[]) + + # The process was started + run_command.tools.subprocess.run.assert_called_with( + [ + tmp_path + / "base_path/build/first-app/linux/appimage/First_App-0.0.1-x86_64.AppImage" + ], + cwd=tmp_path / "home", + bufsize=1, + stream_output=False, + ) + + # No attempt to stream was made + run_command._stream_app_logs.assert_not_called() + + +def test_run_console_app_with_passthrough(run_command, first_app_config, tmp_path): + """A linux console App can be started in debug mode with args.""" + run_command.logger.verbosity = LogLevel.DEBUG + + first_app_config.console_app = True + + # Run the app with args + run_command.run_app( + first_app_config, + test_mode=False, + passthrough=["foo", "--bar"], + ) + + # The process was started + run_command.tools.subprocess.run.assert_called_with( + [ + tmp_path + / "base_path/build/first-app/linux/appimage/First_App-0.0.1-x86_64.AppImage", + "foo", + "--bar", + ], + cwd=tmp_path / "home", + bufsize=1, + stream_output=False, + env={"BRIEFCASE_DEBUG": "1"}, + ) + + # No attempt to stream was made + run_command._stream_app_logs.assert_not_called() + + +def test_run_console_app_failed(run_command, first_app_config, tmp_path): + """If there's a problem starting the console app, an exception is raised.""" + first_app_config.console_app = True + + run_command.tools.subprocess.run.side_effect = OSError + + with pytest.raises(OSError): + run_command.run_app(first_app_config, test_mode=False, passthrough=[]) + + # The run command was still invoked + run_command.tools.subprocess.run.assert_called_with( + [ + tmp_path + / "base_path/build/first-app/linux/appimage/First_App-0.0.1-x86_64.AppImage" + ], + cwd=tmp_path / "home", + bufsize=1, + stream_output=False, + ) + + # No attempt to stream was made + run_command._stream_app_logs.assert_not_called() + + +@pytest.mark.parametrize("is_console_app", [True, False]) +def test_run_app_test_mode(run_command, first_app_config, is_console_app, tmp_path): """A linux App can be started in test mode.""" + # Test mode apps are always streamed + first_app_config.console_app = is_console_app + # Set up the log streamer to return a known stream log_popen = mock.MagicMock() run_command.tools.subprocess.Popen.return_value = log_popen @@ -163,8 +247,17 @@ def test_run_app_test_mode(run_command, first_app_config, tmp_path): ) -def test_run_app_test_mode_with_args(run_command, first_app_config, tmp_path): +@pytest.mark.parametrize("is_console_app", [True, False]) +def test_run_app_test_mode_with_args( + run_command, + first_app_config, + is_console_app, + tmp_path, +): """A linux App can be started in test mode with args.""" + # Test mode apps are always streamed + first_app_config.console_app = is_console_app + # Set up the log streamer to return a known stream log_popen = mock.MagicMock() run_command.tools.subprocess.Popen.return_value = log_popen diff --git a/tests/platforms/linux/flatpak/test_run.py b/tests/platforms/linux/flatpak/test_run.py index 9afa2e214..71f3b943c 100644 --- a/tests/platforms/linux/flatpak/test_run.py +++ b/tests/platforms/linux/flatpak/test_run.py @@ -2,7 +2,7 @@ import pytest -from briefcase.console import Console, Log +from briefcase.console import Console, Log, LogLevel from briefcase.integrations.flatpak import Flatpak from briefcase.integrations.subprocess import Subprocess from briefcase.platforms.linux.flatpak import LinuxFlatpakRunCommand @@ -24,8 +24,8 @@ def run_command(tmp_path): return command -def test_run(run_command, first_app_config): - """A flatpak can be executed.""" +def test_run_gui_app(run_command, first_app_config): + """A GUI flatpak can be executed.""" # Set up the log streamer to return a known stream and a good return code log_popen = mock.MagicMock() run_command.tools.flatpak.run.return_value = log_popen @@ -37,6 +37,7 @@ def test_run(run_command, first_app_config): run_command.tools.flatpak.run.assert_called_once_with( bundle_identifier="com.example.first-app", args=[], + stream_output=True, ) # The streamer was started @@ -48,8 +49,10 @@ def test_run(run_command, first_app_config): ) -def test_run_with_passthrough(run_command, first_app_config): - """A flatpak can be executed with args.""" +def test_run_gui_app_with_passthrough(run_command, first_app_config): + """A GUI flatpak can be executed in debug mode with args.""" + run_command.logger.verbosity = LogLevel.DEBUG + # Set up the log streamer to return a known stream and a good return code log_popen = mock.MagicMock() run_command.tools.flatpak.run.return_value = log_popen @@ -65,6 +68,7 @@ def test_run_with_passthrough(run_command, first_app_config): run_command.tools.flatpak.run.assert_called_once_with( bundle_identifier="com.example.first-app", args=["foo", "--bar"], + stream_output=True, ) # The streamer was started @@ -76,8 +80,8 @@ def test_run_with_passthrough(run_command, first_app_config): ) -def test_run_app_failed(run_command, first_app_config, tmp_path): - """If there's a problem starting the app, an exception is raised.""" +def test_run_gui_app_failed(run_command, first_app_config, tmp_path): + """If there's a problem starting the GUI app, an exception is raised.""" run_command.tools.flatpak.run.side_effect = OSError with pytest.raises(OSError): @@ -87,14 +91,80 @@ def test_run_app_failed(run_command, first_app_config, tmp_path): run_command.tools.flatpak.run.assert_called_once_with( bundle_identifier="com.example.first-app", args=[], + stream_output=True, + ) + + # No attempt to stream was made + run_command._stream_app_logs.assert_not_called() + + +def test_run_console_app(run_command, first_app_config): + """A console flatpak can be executed.""" + first_app_config.console_app = True + + # Run the app + run_command.run_app(first_app_config, test_mode=False, passthrough=[]) + + # App is executed + run_command.tools.flatpak.run.assert_called_once_with( + bundle_identifier="com.example.first-app", + args=[], + stream_output=False, ) # No attempt to stream was made run_command._stream_app_logs.assert_not_called() -def test_run_test_mode(run_command, first_app_config): +def test_run_console_app_with_passthrough(run_command, first_app_config): + """A console flatpak can be executed in debug mode with args.""" + run_command.logger.verbosity = LogLevel.DEBUG + first_app_config.console_app = True + + # Run the app with args + run_command.run_app( + first_app_config, + test_mode=False, + passthrough=["foo", "--bar"], + ) + + # App is executed with args + run_command.tools.flatpak.run.assert_called_once_with( + bundle_identifier="com.example.first-app", + args=["foo", "--bar"], + stream_output=False, + ) + + # No attempt to stream was made + run_command._stream_app_logs.assert_not_called() + + +def test_run_console_app_failed(run_command, first_app_config, tmp_path): + """If there's a problem starting the console app, an exception is raised.""" + first_app_config.console_app = True + + run_command.tools.flatpak.run.side_effect = OSError + + with pytest.raises(OSError): + run_command.run_app(first_app_config, test_mode=False, passthrough=[]) + + # The run command was still invoked + run_command.tools.flatpak.run.assert_called_once_with( + bundle_identifier="com.example.first-app", + args=[], + stream_output=False, + ) + + # No attempt to stream was made + run_command._stream_app_logs.assert_not_called() + + +@pytest.mark.parametrize("is_console_app", [True, False]) +def test_run_test_mode(run_command, first_app_config, is_console_app): """A flatpak can be executed in test mode.""" + # Test mode apps are always streamed + first_app_config.console_app = is_console_app + # Set up the log streamer to return a known stream and a good return code log_popen = mock.MagicMock() run_command.tools.flatpak.run.return_value = log_popen @@ -107,6 +177,7 @@ def test_run_test_mode(run_command, first_app_config): bundle_identifier="com.example.first-app", args=[], main_module="tests.first_app", + stream_output=True, ) # The streamer was started @@ -118,8 +189,12 @@ def test_run_test_mode(run_command, first_app_config): ) -def test_run_test_mode_with_args(run_command, first_app_config): +@pytest.mark.parametrize("is_console_app", [True, False]) +def test_run_test_mode_with_args(run_command, first_app_config, is_console_app): """A flatpak can be executed in test mode with args.""" + # Test mode apps are always streamed + first_app_config.console_app = is_console_app + # Set up the log streamer to return a known stream and a good return code log_popen = mock.MagicMock() run_command.tools.flatpak.run.return_value = log_popen @@ -136,6 +211,7 @@ def test_run_test_mode_with_args(run_command, first_app_config): bundle_identifier="com.example.first-app", args=["foo", "--bar"], main_module="tests.first_app", + stream_output=True, ) # The streamer was started diff --git a/tests/platforms/linux/system/test_run.py b/tests/platforms/linux/system/test_run.py index 1439a803a..033d03078 100644 --- a/tests/platforms/linux/system/test_run.py +++ b/tests/platforms/linux/system/test_run.py @@ -6,7 +6,7 @@ import pytest -from briefcase.console import Console, Log +from briefcase.console import Console, Log, LogLevel from briefcase.exceptions import UnsupportedHostError from briefcase.integrations.docker import Docker from briefcase.integrations.subprocess import Subprocess @@ -160,7 +160,11 @@ def test_supported_host_os(run_command, first_app, sub_kw, tmp_path): @pytest.mark.skipif(sys.platform == "win32", reason="Windows paths can't be dockerized") def test_supported_host_os_docker( - run_command, first_app, sub_kw, tmp_path, monkeypatch + run_command, + first_app, + sub_kw, + tmp_path, + monkeypatch, ): """A supported OS (linux) can invoke run in Docker.""" # This also verifies that Run will call Create and Build commands @@ -229,8 +233,8 @@ def test_supported_host_os_docker( ) -def test_run_app(run_command, first_app, sub_kw, tmp_path): - """A bootstrap binary can be started.""" +def test_run_gui_app(run_command, first_app, sub_kw, tmp_path): + """A bootstrap binary for a GUI app can be started.""" # Set up tool cache run_command.verify_app_tools(app=first_app) @@ -268,6 +272,171 @@ def test_run_app(run_command, first_app, sub_kw, tmp_path): ) +def test_run_gui_app_passthrough(run_command, first_app, sub_kw, tmp_path): + """A bootstrap binary for a GUI app can be started in debug mode with arguments.""" + run_command.logger.verbosity = LogLevel.DEBUG + + # Set up tool cache + run_command.verify_app_tools(app=first_app) + + # Set up the log streamer to return a known stream + log_popen = mock.MagicMock() + run_command.tools.subprocess._subprocess.Popen = mock.MagicMock( + return_value=log_popen + ) + + # Run the app + run_command.run_app(first_app, test_mode=False, passthrough=["foo", "--bar"]) + + # The process was started + run_command.tools.subprocess._subprocess.Popen.assert_called_with( + [ + os.fsdecode( + tmp_path + / "base_path/build/first-app/somevendor/surprising/first-app-0.0.1/usr/bin/first-app" + ), + "foo", + "--bar", + ], + cwd=os.fsdecode(tmp_path / "home"), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + env=mock.ANY, + **sub_kw, + ) + # As we're adding to the environment, all the local values will be present. + # Check that we've definitely set the values we care about + env = run_command.tools.subprocess._subprocess.Popen.call_args.kwargs["env"] + assert env["BRIEFCASE_DEBUG"] == "1" + + # The streamer was started + run_command._stream_app_logs.assert_called_once_with( + first_app, + popen=log_popen, + test_mode=False, + clean_output=False, + ) + + +def test_run_gui_app_failed(run_command, first_app, sub_kw, tmp_path): + """If there's a problem starting the GUI app, an exception is raised.""" + + # Set up tool cache + run_command.verify_app_tools(app=first_app) + + run_command.tools.subprocess._subprocess.Popen.side_effect = OSError + + with pytest.raises(OSError): + run_command.run_app(first_app, test_mode=False, passthrough=[]) + + # The run command was still invoked + run_command.tools.subprocess._subprocess.Popen.assert_called_with( + [ + os.fsdecode( + tmp_path + / "base_path/build/first-app/somevendor/surprising/first-app-0.0.1/usr/bin/first-app" + ) + ], + cwd=os.fsdecode(tmp_path / "home"), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + **sub_kw, + ) + + # No attempt to stream was made + run_command._stream_app_logs.assert_not_called() + + +def test_run_console_app(run_command, first_app, tmp_path): + """A bootstrap binary for a console app can be started.""" + first_app.console_app = True + + # Set up tool cache + run_command.verify_app_tools(app=first_app) + + # Run the app + run_command.run_app(first_app, test_mode=False, passthrough=[]) + + # The process was started + run_command.tools.subprocess.run.mock_calls == [ + mock.call( + [ + tmp_path + / "base_path/build/first-app/somevendor/surprising/first-app-0.0.1/usr/bin/first-app" + ], + cwd=tmp_path / "home", + bufsize=1, + stream_output=False, + ) + ] + + # No attempt to stream was made + run_command._stream_app_logs.assert_not_called() + + +def test_run_console_app_passthrough(run_command, first_app, tmp_path): + """A console app can be started in debug mode with command line arguments.""" + run_command.logger.verbosity = LogLevel.DEBUG + + first_app.console_app = True + + # Set up tool cache + run_command.verify_app_tools(app=first_app) + + # Run the app + run_command.run_app(first_app, test_mode=False, passthrough=["foo", "--bar"]) + + # The process was started + run_command.tools.subprocess.run.mock_calls == [ + mock.call( + [ + tmp_path + / "base_path/build/first-app/somevendor/surprising/first-app-0.0.1/usr/bin/first-app", + "foo", + "--bar", + ], + cwd=tmp_path / "home", + bufsize=1, + stream_output=False, + env={"BRIEFCASE_DEBUG": "1"}, + ) + ] + + # No attempt to stream was made + run_command._stream_app_logs.assert_not_called() + + +def test_run_console_app_failed(run_command, first_app, sub_kw, tmp_path): + """If there's a problem starting the console app, an exception is raised.""" + first_app.console_app = True + + # Set up tool cache + run_command.verify_app_tools(app=first_app) + + run_command.tools.subprocess.run.side_effect = OSError + + with pytest.raises(OSError): + run_command.run_app(first_app, test_mode=False, passthrough=[]) + + # The run command was still invoked + run_command.tools.subprocess.run.mock_calls == [ + mock.call( + [ + tmp_path + / "base_path/build/first-app/somevendor/surprising/first-app-0.0.1/usr/bin/first-app" + ], + cwd=tmp_path / "home", + bufsize=1, + stream_output=False, + ) + ] + + # No attempt to stream was made + run_command._stream_app_logs.assert_not_called() + + @pytest.mark.skipif(sys.platform == "win32", reason="Windows paths can't be dockerized") def test_run_app_docker(run_command, first_app, sub_kw, tmp_path, monkeypatch): """A bootstrap binary can be started in Docker.""" @@ -332,36 +501,6 @@ def test_run_app_docker(run_command, first_app, sub_kw, tmp_path, monkeypatch): ) -def test_run_app_failed(run_command, first_app, sub_kw, tmp_path): - """If there's a problem starting the app, an exception is raised.""" - - # Set up tool cache - run_command.verify_app_tools(app=first_app) - - run_command.tools.subprocess._subprocess.Popen.side_effect = OSError - - with pytest.raises(OSError): - run_command.run_app(first_app, test_mode=False, passthrough=[]) - - # The run command was still invoked - run_command.tools.subprocess._subprocess.Popen.assert_called_with( - [ - os.fsdecode( - tmp_path - / "base_path/build/first-app/somevendor/surprising/first-app-0.0.1/usr/bin/first-app" - ) - ], - cwd=os.fsdecode(tmp_path / "home"), - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=1, - **sub_kw, - ) - - # No attempt to stream was made - run_command._stream_app_logs.assert_not_called() - - @pytest.mark.skipif(sys.platform == "win32", reason="Windows paths can't be dockerized") def test_run_app_failed_docker(run_command, first_app, sub_kw, tmp_path, monkeypatch): """If there's a problem starting the app in Docker, an exception is raised.""" @@ -418,8 +557,18 @@ def test_run_app_failed_docker(run_command, first_app, sub_kw, tmp_path, monkeyp run_command._stream_app_logs.assert_not_called() -def test_run_app_test_mode(run_command, first_app, sub_kw, tmp_path, monkeypatch): +@pytest.mark.parametrize("is_console_app", [True, False]) +def test_run_app_test_mode( + run_command, + first_app, + is_console_app, + sub_kw, + tmp_path, + monkeypatch, +): """A linux App can be started in test mode.""" + # Test mode apps are always streamed + first_app.console_app = is_console_app # Set up tool cache run_command.verify_app_tools(app=first_app) @@ -460,14 +609,18 @@ def test_run_app_test_mode(run_command, first_app, sub_kw, tmp_path, monkeypatch @pytest.mark.skipif(sys.platform == "win32", reason="Windows paths can't be dockerized") +@pytest.mark.parametrize("is_console_app", [True, False]) def test_run_app_test_mode_docker( run_command, first_app, + is_console_app, sub_kw, tmp_path, monkeypatch, ): """A linux App can be started in Docker in test mode.""" + # Test mode apps are always streamed + first_app.console_app = is_console_app # Trigger to run in Docker run_command.target_image = first_app.target_image = "best/distro" @@ -530,14 +683,18 @@ def test_run_app_test_mode_docker( ) +@pytest.mark.parametrize("is_console_app", [True, False]) def test_run_app_test_mode_with_args( run_command, first_app, + is_console_app, sub_kw, tmp_path, monkeypatch, ): """A linux App can be started in test mode with args.""" + # Test mode apps are always streamed + first_app.console_app = is_console_app # Set up tool cache run_command.verify_app_tools(app=first_app) @@ -584,14 +741,18 @@ def test_run_app_test_mode_with_args( @pytest.mark.skipif(sys.platform == "win32", reason="Windows paths can't be dockerized") +@pytest.mark.parametrize("is_console_app", [True, False]) def test_run_app_test_mode_with_args_docker( run_command, first_app, + is_console_app, sub_kw, tmp_path, monkeypatch, ): """A linux App can be started in Docker in test mode with args.""" + # Test mode apps are always streamed + first_app.console_app = is_console_app # Trigger to run in Docker run_command.target_image = first_app.target_image = "best/distro" diff --git a/tests/platforms/macOS/app/conftest.py b/tests/platforms/macOS/app/conftest.py index 94139c227..48b5ca6c5 100644 --- a/tests/platforms/macOS/app/conftest.py +++ b/tests/platforms/macOS/app/conftest.py @@ -3,30 +3,31 @@ import pytest +from briefcase.platforms.macOS import SigningIdentity + from ....utils import create_file, create_plist_file +@pytest.fixture +def sekrit_identity(): + return SigningIdentity(id="CAFEBEEF", name="Sekrit identity (DEADBEEF)") + + +@pytest.fixture +def adhoc_identity(): + return SigningIdentity() + + @pytest.fixture def first_app_templated(first_app_config, tmp_path): - app_path = ( - tmp_path - / "base_path" - / "build" - / "first-app" - / "macos" - / "app" - / "First App.app" - ) + app_path = tmp_path / "base_path/build/first-app/macos/app/First App.app" + + # Create the stub binary + create_file(app_path / "Contents/MacOS/Stub", "Stub binary") # Create the briefcase.toml file create_file( - tmp_path - / "base_path" - / "build" - / "first-app" - / "macos" - / "app" - / "briefcase.toml", + tmp_path / "base_path/build/first-app/macos/app/briefcase.toml", """ [paths] app_packages_path="First App.app/Contents/Resources/app_packages" @@ -46,13 +47,7 @@ def first_app_templated(first_app_config, tmp_path): # Create the entitlements file for the app create_plist_file( - tmp_path - / "base_path" - / "build" - / "first-app" - / "macos" - / "app" - / "Entitlements.plist", + tmp_path / "base_path/build/first-app/macos/app/Entitlements.plist", { "com.apple.security.cs.allow-unsigned-executable-memory": True, "com.apple.security.cs.disable-library-validation": True, @@ -63,6 +58,12 @@ def first_app_templated(first_app_config, tmp_path): (app_path / "Contents/Resources/app_packages").mkdir(parents=True) (app_path / "Contents/Frameworks").mkdir(parents=True) + # Create an installer Distribution.xml + create_file( + tmp_path / "base_path/build/first-app/macos/app/installer/Distribution.xml", + """\n""", + ) + # Select dmg packaging by default first_app_config.packaging_format = "dmg" @@ -71,15 +72,10 @@ def first_app_templated(first_app_config, tmp_path): @pytest.fixture def first_app_with_binaries(first_app_templated, first_app_config, tmp_path): - app_path = ( - tmp_path - / "base_path" - / "build" - / "first-app" - / "macos" - / "app" - / "First App.app" - ) + app_path = tmp_path / "base_path/build/first-app/macos/app/First App.app" + + # Move the stub binary to the final location + (app_path / "Contents/MacOS/Stub").rename(app_path / "Contents/MacOS/First App") # Create some libraries that need to be signed. lib_path = app_path / "Contents/Resources/app_packages" diff --git a/tests/platforms/macOS/app/package/__init__.py b/tests/platforms/macOS/app/package/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/platforms/macOS/app/package/conftest.py b/tests/platforms/macOS/app/package/conftest.py new file mode 100644 index 000000000..905178fcf --- /dev/null +++ b/tests/platforms/macOS/app/package/conftest.py @@ -0,0 +1,26 @@ +import subprocess +from unittest import mock + +import pytest + +from briefcase.console import Console, Log +from briefcase.platforms.macOS.app import macOSAppPackageCommand + + +@pytest.fixture +def package_command(tmp_path): + command = macOSAppPackageCommand( + logger=Log(), + console=Console(), + base_path=tmp_path / "base_path", + data_path=tmp_path / "briefcase", + ) + + command.select_identity = mock.MagicMock() + command.sign_app = mock.MagicMock() + command.sign_file = mock.MagicMock() + command.notarize = mock.MagicMock() + command.dmgbuild = mock.MagicMock() + command.tools.subprocess = mock.MagicMock(spec=subprocess) + + return command diff --git a/tests/platforms/macOS/app/test_package__notarize.py b/tests/platforms/macOS/app/package/test_notarize.py similarity index 91% rename from tests/platforms/macOS/app/test_package__notarize.py rename to tests/platforms/macOS/app/package/test_notarize.py index 794b83e6c..989f31a9c 100644 --- a/tests/platforms/macOS/app/test_package__notarize.py +++ b/tests/platforms/macOS/app/package/test_notarize.py @@ -37,7 +37,12 @@ def first_app_dmg(tmp_path): return dmg_path -def test_notarize_app(package_command, first_app_with_binaries, tmp_path): +def test_notarize_app( + package_command, + first_app_with_binaries, + sekrit_identity, + tmp_path, +): """An app can be notarized.""" app_path = ( tmp_path @@ -49,7 +54,7 @@ def test_notarize_app(package_command, first_app_with_binaries, tmp_path): / "First App.app" ) archive_path = tmp_path / "base_path/build/first-app/macos/app/archive.zip" - package_command.notarize(app_path, team_id="DEADBEEF") + package_command.notarize(app_path, identity=sekrit_identity) # As a result of mocking os.unlink, the zip archive won't be # cleaned up, so we can test for its existence, but also @@ -66,6 +71,8 @@ def test_notarize_app(package_command, first_app_with_binaries, tmp_path): "First App.app/Contents/Frameworks/Extras.framework/Resources/", "First App.app/Contents/Frameworks/Extras.framework/Resources/extras.dylib", "First App.app/Contents/Info.plist", + "First App.app/Contents/MacOS/", + "First App.app/Contents/MacOS/First App", "First App.app/Contents/Resources/", "First App.app/Contents/Resources/app_packages/", "First App.app/Contents/Resources/app_packages/Extras.app/", @@ -114,10 +121,14 @@ def test_notarize_app(package_command, first_app_with_binaries, tmp_path): ) -def test_notarize_dmg(package_command, first_app_dmg): +def test_notarize_dmg( + package_command, + first_app_dmg, + sekrit_identity, +): """A DMG can be notarized.""" - package_command.notarize(first_app_dmg, team_id="DEADBEEF") + package_command.notarize(first_app_dmg, identity=sekrit_identity) # The DMG didn't require an archive file, so unlink wasn't invoked. package_command.tools.os.unlink.assert_not_called() @@ -152,7 +163,7 @@ def test_notarize_dmg(package_command, first_app_dmg): ) -def test_notarize_unknown_format(package_command, tmp_path): +def test_notarize_unknown_format(package_command, sekrit_identity, tmp_path): """Attempting to notarize a file of unknown format raises an error.""" pkg_path = tmp_path / "base_path/dist/First App.pkg" @@ -161,10 +172,14 @@ def test_notarize_unknown_format(package_command, tmp_path): RuntimeError, match=r"Don't know how to notarize a file of type .pkg", ): - package_command.notarize(pkg_path, team_id="DEADBEEF") + package_command.notarize(pkg_path, identity=sekrit_identity) -def test_notarize_dmg_unknown_credentials(package_command, first_app_dmg): +def test_notarize_dmg_unknown_credentials( + package_command, + first_app_dmg, + sekrit_identity, +): """When notarizing a DMG, if credentials haven't been stored, the user will be prompted to store them.""" # Set up subprocess to fail on the first notarization attempt @@ -178,7 +193,7 @@ def test_notarize_dmg_unknown_credentials(package_command, first_app_dmg): None, # Successful stapling ] - package_command.notarize(first_app_dmg, team_id="DEADBEEF") + package_command.notarize(first_app_dmg, identity=sekrit_identity) # The DMG didn't require an archive file, so unlink wasn't invoked. package_command.tools.os.unlink.assert_not_called() @@ -242,6 +257,7 @@ def test_notarize_dmg_unknown_credentials(package_command, first_app_dmg): def test_credential_storage_failure_app( package_command, first_app_with_binaries, + sekrit_identity, tmp_path, ): """When submitting an app, if credentials haven't been stored, and storage fails, an @@ -275,7 +291,7 @@ def test_credential_storage_failure_app( BriefcaseCommandError, match=r"Unable to store credentials for team ID DEADBEEF.", ): - package_command.notarize(app_path, team_id="DEADBEEF") + package_command.notarize(app_path, identity=sekrit_identity) # As a result of mocking os.unlink, the zip archive won't be # cleaned up, so we can test for its existence, but also @@ -316,7 +332,11 @@ def test_credential_storage_failure_app( ) -def test_credential_storage_failure_dmg(package_command, first_app_dmg): +def test_credential_storage_failure_dmg( + package_command, + first_app_dmg, + sekrit_identity, +): """If credentials haven't been stored, and storage fails, an error is raised.""" # Set up subprocess to fail on the first notarization attempt, # then fail on the storage of credentials @@ -336,7 +356,7 @@ def test_credential_storage_failure_dmg(package_command, first_app_dmg): BriefcaseCommandError, match=r"Unable to store credentials for team ID DEADBEEF.", ): - package_command.notarize(first_app_dmg, team_id="DEADBEEF") + package_command.notarize(first_app_dmg, identity=sekrit_identity) # The DMG didn't require an archive file, so unlink wasn't invoked. package_command.tools.os.unlink.assert_not_called() @@ -375,7 +395,10 @@ def test_credential_storage_failure_dmg(package_command, first_app_dmg): def test_credential_storage_disabled_input_app( - package_command, first_app_with_binaries, tmp_path + package_command, + first_app_with_binaries, + sekrit_identity, + tmp_path, ): """When packaging an app, if credentials haven't been stored, and input is disabled, an error is raised.""" @@ -405,7 +428,7 @@ def test_credential_storage_disabled_input_app( BriefcaseCommandError, match=r"The keychain does not contain credentials for the profile briefcase-macOS-DEADBEEF.", ): - package_command.notarize(app_path, team_id="DEADBEEF") + package_command.notarize(app_path, identity=sekrit_identity) # As a result of mocking os.unlink, the zip archive won't be # cleaned up, so we can test for its existence, but also @@ -433,7 +456,11 @@ def test_credential_storage_disabled_input_app( ) -def test_credential_storage_disabled_input_dmg(package_command, first_app_dmg): +def test_credential_storage_disabled_input_dmg( + package_command, + first_app_dmg, + sekrit_identity, +): """When packaging a DMG, if credentials haven't been stored, and input is disabled, an error is raised.""" # Set up subprocess to fail on the first notarization attempt. @@ -451,7 +478,7 @@ def test_credential_storage_disabled_input_dmg(package_command, first_app_dmg): BriefcaseCommandError, match=r"The keychain does not contain credentials for the profile briefcase-macOS-DEADBEEF.", ): - package_command.notarize(first_app_dmg, team_id="DEADBEEF") + package_command.notarize(first_app_dmg, identity=sekrit_identity) # The DMG didn't require an archive file, so unlink wasn't invoked. package_command.tools.os.unlink.assert_not_called() @@ -476,7 +503,11 @@ def test_credential_storage_disabled_input_dmg(package_command, first_app_dmg): ) -def test_notarize_unknown_credentials_after_storage(package_command, first_app_dmg): +def test_notarize_unknown_credentials_after_storage( + package_command, + first_app_dmg, + sekrit_identity, +): """If we get a credential failure after an attempt to store, an error is raised.""" # Set up subprocess to fail on the second notarization attempt package_command.tools.subprocess.run.side_effect = [ @@ -497,7 +528,7 @@ def test_notarize_unknown_credentials_after_storage(package_command, first_app_d BriefcaseCommandError, match=r"Unable to submit dist[/\\]First App.dmg for notarization.", ): - package_command.notarize(first_app_dmg, team_id="DEADBEEF") + package_command.notarize(first_app_dmg, identity=sekrit_identity) # The DMG didn't require an archive file, so unlink wasn't invoked. package_command.tools.os.unlink.assert_not_called() @@ -551,6 +582,7 @@ def test_notarize_unknown_credentials_after_storage(package_command, first_app_d def test_app_notarization_failure_with_credentials( package_command, first_app_with_binaries, + sekrit_identity, tmp_path, ): """If the notarization process for an app fails for a reason other than credentials, @@ -580,7 +612,7 @@ def test_app_notarization_failure_with_credentials( BriefcaseCommandError, match=r"Unable to submit build[/\\]first-app[/\\]macos[/\\]app[/\\]First App.app for notarization.", ): - package_command.notarize(app_path, team_id="DEADBEEF") + package_command.notarize(app_path, identity=sekrit_identity) # As a result of mocking os.unlink, the zip archive won't be # cleaned up, so we can test for its existence, but also @@ -608,7 +640,11 @@ def test_app_notarization_failure_with_credentials( ) -def test_dmg_notarization_failure_with_credentials(package_command, first_app_dmg): +def test_dmg_notarization_failure_with_credentials( + package_command, + first_app_dmg, + sekrit_identity, +): """If the notarization process for a DMG fails for a reason other than credentials, an error is raised.""" # Set up subprocess to fail on the first notarization attempt @@ -625,7 +661,7 @@ def test_dmg_notarization_failure_with_credentials(package_command, first_app_dm BriefcaseCommandError, match=r"Unable to submit dist[/\\]First App.dmg for notarization.", ): - package_command.notarize(first_app_dmg, team_id="DEADBEEF") + package_command.notarize(first_app_dmg, identity=sekrit_identity) # The DMG didn't require an archive file, so unlink wasn't invoked. package_command.tools.os.unlink.assert_not_called() @@ -650,7 +686,11 @@ def test_dmg_notarization_failure_with_credentials(package_command, first_app_dm ) -def test_stapling_failure(package_command, first_app_dmg): +def test_stapling_failure( + package_command, + first_app_dmg, + sekrit_identity, +): """If the stapling process fails, an error is raised.""" # Set up a failure in the stapling process package_command.tools.subprocess.run.side_effect = [ @@ -665,7 +705,7 @@ def test_stapling_failure(package_command, first_app_dmg): BriefcaseCommandError, match=r"Unable to staple notarization onto dist[/\\]First App.dmg", ): - package_command.notarize(first_app_dmg, team_id="DEADBEEF") + package_command.notarize(first_app_dmg, identity=sekrit_identity) # The DMG didn't require an archive file, so unlink wasn't invoked. package_command.tools.os.unlink.assert_not_called() diff --git a/tests/platforms/macOS/app/package/test_package.py b/tests/platforms/macOS/app/package/test_package.py new file mode 100644 index 000000000..19935d038 --- /dev/null +++ b/tests/platforms/macOS/app/package/test_package.py @@ -0,0 +1,430 @@ +import os +from unittest import mock + +import pytest + +import briefcase.integrations.xcode +from briefcase.exceptions import BriefcaseCommandError + + +def test_package_formats(package_command): + """Packaging formats are as expected.""" + assert package_command.packaging_formats == ["zip", "dmg", "pkg"] + # The default format is encoded as None, and then updated + # as part of app verification. + assert package_command.default_packaging_format is None + + +@pytest.mark.parametrize( + "is_console_app, packaging_format, actual_format", + [ + (False, None, "dmg"), # default for GUI app is DMG + (False, "dmg", "dmg"), + (False, "app", "app"), + (False, "pkg", "pkg"), + (True, None, "pkg"), # default for console app is PKG + (True, "pkg", "pkg"), + ], +) +def test_effective_format( + package_command, + first_app_with_binaries, + is_console_app, + packaging_format, + actual_format, +): + """The packaging format varies depending on the app type.""" + + first_app_with_binaries.packaging_format = packaging_format + first_app_with_binaries.console_app = is_console_app + package_command.verify_app(first_app_with_binaries) + + assert first_app_with_binaries.packaging_format == actual_format + + +@pytest.mark.parametrize("packaging_format", ["zip", "dmg"]) +def test_console_invalid_formats( + package_command, + first_app_with_binaries, + packaging_format, +): + """Some packaging formats are not valid for console apps.""" + + first_app_with_binaries.packaging_format = packaging_format + first_app_with_binaries.console_app = True + with pytest.raises( + BriefcaseCommandError, + match=r"macOS console apps must be distributed in PKG format\.", + ): + package_command.verify_app(first_app_with_binaries) + + +def test_no_notarize_option(package_command): + """The --no-notarize option can be parsed.""" + options, overrides = package_command.parse_options(["--no-notarize"]) + + assert options == { + "adhoc_sign": False, + "identity": None, + "notarize_app": False, + "packaging_format": None, + "update": False, + } + assert overrides == {} + + +def test_verify(package_command, monkeypatch): + """If you're on macOS, you can verify tools.""" + package_command.tools.host_os = "Darwin" + + # Mock the existence of the command line tools + mock_ensure_command_line_tools_are_installed = mock.MagicMock() + monkeypatch.setattr( + briefcase.integrations.xcode.XcodeCliTools, + "ensure_command_line_tools_are_installed", + mock_ensure_command_line_tools_are_installed, + ) + mock_confirm_xcode_license_accepted = mock.MagicMock() + monkeypatch.setattr( + briefcase.integrations.xcode.XcodeCliTools, + "confirm_xcode_license_accepted", + mock_confirm_xcode_license_accepted, + ) + + package_command.verify_tools() + + assert package_command.tools.xcode_cli is not None + mock_ensure_command_line_tools_are_installed.assert_called_once_with( + tools=package_command.tools + ) + mock_confirm_xcode_license_accepted.assert_called_once_with( + tools=package_command.tools + ) + + +def test_package_app( + package_command, + first_app_with_binaries, + sekrit_identity, + tmp_path, + capsys, +): + """A macOS App is packaged as a signed, notarized DMG by default.""" + # Select a codesigning identity + package_command.select_identity.return_value = sekrit_identity + + # Package the app. Sign and notarize by default + package_command.package_app(first_app_with_binaries) + + # A request has been made to sign the app + package_command.sign_app.assert_called_once_with( + app=first_app_with_binaries, + identity=sekrit_identity, + ) + + # The DMG has been built as expected + package_command.dmgbuild.build_dmg.assert_called_once_with( + filename=os.fsdecode(tmp_path / "base_path/dist/First App-0.0.1.dmg"), + volume_name="First App 0.0.1", + settings={ + "files": [ + os.fsdecode( + tmp_path + / "base_path" + / "build" + / "first-app" + / "macos" + / "app" + / "First App.app" + ) + ], + "symlinks": {"Applications": "/Applications"}, + "icon_locations": { + "First App.app": (75, 75), + "Applications": (225, 75), + }, + "window_rect": ((600, 600), (350, 150)), + "icon_size": 64, + "text_size": 12, + }, + ) + + # A request was made to sign the DMG as well. + # This ignores the calls that would have been made transitively + # by calling sign_app() + package_command.sign_file.assert_called_once_with( + tmp_path / "base_path/dist/First App-0.0.1.dmg", + identity=sekrit_identity, + ) + + # A request was made to notarize the DMG + package_command.notarize.assert_called_once_with( + tmp_path / "base_path/dist/First App-0.0.1.dmg", + identity=sekrit_identity, + ) + + # The app doesn't specify an app icon or installer icon, so there's no + # mention about the DMG installer icon in the console log. + assert "DMG installer icon" not in capsys.readouterr().out + + +def test_no_notarization( + package_command, + first_app_with_binaries, + sekrit_identity, + tmp_path, + capsys, +): + """A macOS App can be packaged as a signed DMG without notarization.""" + # Select a codesigning identity + package_command.select_identity.return_value = sekrit_identity + + # Package the app; sign by default, but disable notarization + package_command.package_app(first_app_with_binaries, notarize_app=False) + + # A request has been made to sign the app + package_command.sign_app.assert_called_once_with( + app=first_app_with_binaries, + identity=sekrit_identity, + ) + + # The DMG has been built as expected + package_command.dmgbuild.build_dmg.assert_called_once_with( + filename=os.fsdecode(tmp_path / "base_path/dist/First App-0.0.1.dmg"), + volume_name="First App 0.0.1", + settings={ + "files": [ + os.fsdecode( + tmp_path + / "base_path" + / "build" + / "first-app" + / "macos" + / "app" + / "First App.app" + ) + ], + "symlinks": {"Applications": "/Applications"}, + "icon_locations": { + "First App.app": (75, 75), + "Applications": (225, 75), + }, + "window_rect": ((600, 600), (350, 150)), + "icon_size": 64, + "text_size": 12, + }, + ) + + # A request was made to sign the DMG as well. + # This ignores the calls that would have been made transitively + # by calling sign_app() + package_command.sign_file.assert_called_once_with( + tmp_path / "base_path/dist/First App-0.0.1.dmg", + identity=sekrit_identity, + ) + + # A request was made to notarize the DMG + package_command.notarize.assert_not_called() + + # The app doesn't specify an app icon or installer icon, so there's no + # mention about the DMG installer icon in the console log. + assert "DMG installer icon" not in capsys.readouterr().out + + +def test_adhoc_sign( + package_command, + first_app_with_binaries, + adhoc_identity, + tmp_path, +): + """A macOS App can be packaged and signed with ad-hoc identity.""" + # Package the app with an ad-hoc identity. + # Explicitly disable notarization (can't ad-hoc notarize an app) + package_command.package_app( + first_app_with_binaries, + adhoc_sign=True, + notarize_app=False, + ) + + # A request has been made to sign the app + package_command.sign_app.assert_called_once_with( + app=first_app_with_binaries, + identity=adhoc_identity, + ) + + # The DMG has been built as expected + package_command.dmgbuild.build_dmg.assert_called_once_with( + filename=os.fsdecode(tmp_path / "base_path/dist/First App-0.0.1.dmg"), + volume_name="First App 0.0.1", + settings={ + "files": [ + os.fsdecode( + tmp_path + / "base_path" + / "build" + / "first-app" + / "macos" + / "app" + / "First App.app" + ) + ], + "symlinks": {"Applications": "/Applications"}, + "icon_locations": { + "First App.app": (75, 75), + "Applications": (225, 75), + }, + "window_rect": ((600, 600), (350, 150)), + "icon_size": 64, + "text_size": 12, + }, + ) + + # A request was made to sign the DMG as well. + # This ignores the calls that would have been made transitively + # by calling sign_app() + package_command.sign_file.assert_called_once_with( + tmp_path / "base_path/dist/First App-0.0.1.dmg", + identity=adhoc_identity, + ) + + # No request was made to notarize + package_command.notarize.assert_not_called() + + +def test_notarize_adhoc_signed(package_command, first_app_with_binaries): + """A macOS App cannot be notarized if ad-hoc signing is requested.""" + + # Package the app without code signing + with pytest.raises( + BriefcaseCommandError, + match=r"Can't notarize an app with an ad-hoc signing identity", + ): + package_command.package_app( + first_app_with_binaries, + notarize_app=True, + adhoc_sign=True, + ) + + # No code signing or notarization has been performed. + assert package_command.select_identity.call_count == 0 + assert package_command.sign_app.call_count == 0 + assert package_command.sign_file.call_count == 0 + assert package_command.notarize.call_count == 0 + + +def test_notarize_adhoc_signed_via_prompt( + package_command, + first_app_with_binaries, + adhoc_identity, +): + """Notarization is rejected if the user selects the adhoc identity.""" + + package_command.select_identity.return_value = adhoc_identity + + # Package the app without code signing + with pytest.raises( + BriefcaseCommandError, + match=r"Can't notarize an app with an ad-hoc signing identity", + ): + package_command.package_app( + first_app_with_binaries, + notarize_app=True, + ) + + # No code signing or notarization has been performed. + assert package_command.select_identity.call_count == 1 + assert package_command.sign_app.call_count == 0 + assert package_command.sign_file.call_count == 0 + assert package_command.notarize.call_count == 0 + + +def test_adhoc_sign_default_no_notarization( + package_command, + first_app_with_binaries, + adhoc_identity, + tmp_path, +): + """An ad-hoc signed app is not notarized by default.""" + # Package the app with an ad-hoc identity; notarization will + # be disabled as a default + package_command.package_app( + first_app_with_binaries, + adhoc_sign=True, + ) + + # A request has been made to sign the app + package_command.sign_app.assert_called_once_with( + app=first_app_with_binaries, + identity=adhoc_identity, + ) + + # The DMG has been built as expected + package_command.dmgbuild.build_dmg.assert_called_once_with( + filename=os.fsdecode(tmp_path / "base_path/dist/First App-0.0.1.dmg"), + volume_name="First App 0.0.1", + settings={ + "files": [ + os.fsdecode( + tmp_path + / "base_path" + / "build" + / "first-app" + / "macos" + / "app" + / "First App.app" + ) + ], + "symlinks": {"Applications": "/Applications"}, + "icon_locations": { + "First App.app": (75, 75), + "Applications": (225, 75), + }, + "window_rect": ((600, 600), (350, 150)), + "icon_size": 64, + "text_size": 12, + }, + ) + + # A request was made to sign the DMG as well. + # This ignores the calls that would have been made transitively + # by calling sign_app() + package_command.sign_file.assert_called_once_with( + tmp_path / "base_path/dist/First App-0.0.1.dmg", + identity=adhoc_identity, + ) + + # No request was made to notarize + package_command.notarize.assert_not_called() + + +def test_sign_failure( + package_command, + first_app_with_binaries, + sekrit_identity, + tmp_path, +): + """If the signing process can't be completed, an error is raised.""" + + # Select a codesigning identity + package_command.select_identity.return_value = sekrit_identity + + # Raise an error when attempting to sign the app + package_command.sign_app.side_effect = BriefcaseCommandError("Unable to code sign") + + # Attempt to package the app; it should raise an error + with pytest.raises(BriefcaseCommandError, match=r"Unable to code sign"): + package_command.package_app(first_app_with_binaries) + + # A request has been made to sign the app + package_command.sign_app.assert_called_once_with( + app=first_app_with_binaries, + identity=sekrit_identity, + ) + + # dmgbuild has not been called + package_command.dmgbuild.build_dmg.assert_not_called() + + # No attempt was made to sign the dmg either + # This ignores the calls that would have been made transitively + # by calling sign_app() + package_command.sign_file.assert_not_called() diff --git a/tests/platforms/macOS/app/package/test_package_dmg.py b/tests/platforms/macOS/app/package/test_package_dmg.py new file mode 100644 index 000000000..e2debcb6e --- /dev/null +++ b/tests/platforms/macOS/app/package/test_package_dmg.py @@ -0,0 +1,296 @@ +import os + + +def test_dmg_with_installer_icon(package_command, first_app_with_binaries, tmp_path): + """An installer icon can be specified for a DMG.""" + # Specify an installer icon, and create the matching file. + first_app_with_binaries.installer_icon = "pretty" + with open(tmp_path / "base_path/pretty.icns", "wb") as f: + f.write(b"A pretty installer icon") + + # Package the app without signing or notarization + package_command.package_app( + first_app_with_binaries, + notarize_app=False, + adhoc_sign=True, + ) + + # The DMG has been built as expected + package_command.dmgbuild.build_dmg.assert_called_once_with( + filename=os.fsdecode(tmp_path / "base_path/dist/First App-0.0.1.dmg"), + volume_name="First App 0.0.1", + settings={ + "files": [ + os.fsdecode( + tmp_path + / "base_path" + / "build" + / "first-app" + / "macos" + / "app" + / "First App.app" + ) + ], + "symlinks": {"Applications": "/Applications"}, + "icon_locations": { + "First App.app": (75, 75), + "Applications": (225, 75), + }, + "window_rect": ((600, 600), (350, 150)), + "icon_size": 64, + "text_size": 12, + "icon": os.fsdecode(tmp_path / "base_path/pretty.icns"), + }, + ) + + +def test_dmg_with_missing_installer_icon( + package_command, + first_app_with_binaries, + tmp_path, + capsys, +): + """If an installer icon is specified, but the specific file is missing, there is a + warning.""" + # Specify an installer icon, but don't create the matching file. + first_app_with_binaries.installer_icon = "pretty" + first_app_with_binaries.packaging_format = "dmg" + + # Package the app without signing or notarization + package_command.package_app( + first_app_with_binaries, + notarize_app=False, + adhoc_sign=True, + ) + + # The DMG has been built as expected + package_command.dmgbuild.build_dmg.assert_called_once_with( + filename=os.fsdecode(tmp_path / "base_path/dist/First App-0.0.1.dmg"), + volume_name="First App 0.0.1", + settings={ + "files": [ + os.fsdecode( + tmp_path + / "base_path" + / "build" + / "first-app" + / "macos" + / "app" + / "First App.app" + ) + ], + "symlinks": {"Applications": "/Applications"}, + "icon_locations": { + "First App.app": (75, 75), + "Applications": (225, 75), + }, + "window_rect": ((600, 600), (350, 150)), + "icon_size": 64, + "text_size": 12, + }, + ) + + # The warning about a missing icon was output + assert ( + "Can't find pretty.icns to use as DMG installer icon\n" + in capsys.readouterr().out + ) + + +def test_dmg_with_app_installer_icon( + package_command, + first_app_with_binaries, + tmp_path, +): + """An installer will fall back to an app icon for a DMG.""" + # Specify an app icon, and create the matching file. + first_app_with_binaries.icon = "pretty_app" + with open(tmp_path / "base_path/pretty_app.icns", "wb") as f: + f.write(b"A pretty app icon") + + # Package the app without signing or notarization + package_command.package_app( + first_app_with_binaries, + notarize_app=False, + adhoc_sign=True, + ) + + # The DMG has been built as expected + package_command.dmgbuild.build_dmg.assert_called_once_with( + filename=os.fsdecode(tmp_path / "base_path/dist/First App-0.0.1.dmg"), + volume_name="First App 0.0.1", + settings={ + "files": [ + os.fsdecode( + tmp_path + / "base_path" + / "build" + / "first-app" + / "macos" + / "app" + / "First App.app" + ) + ], + "symlinks": {"Applications": "/Applications"}, + "icon_locations": { + "First App.app": (75, 75), + "Applications": (225, 75), + }, + "window_rect": ((600, 600), (350, 150)), + "icon_size": 64, + "text_size": 12, + "icon": os.fsdecode(tmp_path / "base_path/pretty_app.icns"), + }, + ) + + +def test_dmg_with_missing_app_installer_icon( + package_command, + first_app_with_binaries, + tmp_path, + capsys, +): + """If an app icon is specified, but the specific file is missing, there is a + warning.""" + # Specify an app icon, but don't create the matching file. + first_app_with_binaries.icon = "pretty_app" + + # Package the app without signing or notarization + package_command.package_app( + first_app_with_binaries, + notarize_app=False, + adhoc_sign=True, + ) + + # The DMG has been built as expected + package_command.dmgbuild.build_dmg.assert_called_once_with( + filename=os.fsdecode(tmp_path / "base_path/dist/First App-0.0.1.dmg"), + volume_name="First App 0.0.1", + settings={ + "files": [ + os.fsdecode( + tmp_path + / "base_path" + / "build" + / "first-app" + / "macos" + / "app" + / "First App.app" + ) + ], + "symlinks": {"Applications": "/Applications"}, + "icon_locations": { + "First App.app": (75, 75), + "Applications": (225, 75), + }, + "window_rect": ((600, 600), (350, 150)), + "icon_size": 64, + "text_size": 12, + }, + ) + + # The warning about a missing icon was output + assert ( + "Can't find pretty_app.icns to use as fallback DMG installer icon\n" + in capsys.readouterr().out + ) + + +def test_dmg_with_installer_background( + package_command, + first_app_with_binaries, + tmp_path, +): + """An installer can be built with an installer background.""" + # Specify an installer background, and create the matching file. + first_app_with_binaries.installer_background = "pretty_background" + with open(tmp_path / "base_path/pretty_background.png", "wb") as f: + f.write(b"A pretty background") + + # Package the app without signing or notarization + package_command.package_app( + first_app_with_binaries, + notarize_app=False, + adhoc_sign=True, + ) + + # The DMG has been built as expected + package_command.dmgbuild.build_dmg.assert_called_once_with( + filename=os.fsdecode(tmp_path / "base_path/dist/First App-0.0.1.dmg"), + volume_name="First App 0.0.1", + settings={ + "files": [ + os.fsdecode( + tmp_path + / "base_path" + / "build" + / "first-app" + / "macos" + / "app" + / "First App.app" + ) + ], + "symlinks": {"Applications": "/Applications"}, + "icon_locations": { + "First App.app": (75, 75), + "Applications": (225, 75), + }, + "window_rect": ((600, 600), (350, 150)), + "icon_size": 64, + "text_size": 12, + "background": os.fsdecode(tmp_path / "base_path/pretty_background.png"), + }, + ) + + +def test_dmg_with_missing_installer_background( + package_command, + first_app_with_binaries, + tmp_path, + capsys, +): + """If an installer image is specified, but the specific file is missing, there is a + warning.""" + # Specify an installer background, but don't create the matching file. + first_app_with_binaries.installer_background = "pretty_background" + first_app_with_binaries.packaging_format = "dmg" + + # Package the app without signing or notarization + package_command.package_app( + first_app_with_binaries, + notarize_app=False, + adhoc_sign=True, + ) + + # The DMG has been built as expected + package_command.dmgbuild.build_dmg.assert_called_once_with( + filename=os.fsdecode(tmp_path / "base_path/dist/First App-0.0.1.dmg"), + volume_name="First App 0.0.1", + settings={ + "files": [ + os.fsdecode( + tmp_path + / "base_path" + / "build" + / "first-app" + / "macos" + / "app" + / "First App.app" + ) + ], + "symlinks": {"Applications": "/Applications"}, + "icon_locations": { + "First App.app": (75, 75), + "Applications": (225, 75), + }, + "window_rect": ((600, 600), (350, 150)), + "icon_size": 64, + "text_size": 12, + }, + ) + + # The warning about a missing background was output + assert ( + "Can't find pretty_background.png to use as DMG background\n" + in capsys.readouterr().out + ) diff --git a/tests/platforms/macOS/app/package/test_package_pkg.py b/tests/platforms/macOS/app/package/test_package_pkg.py new file mode 100644 index 000000000..5f8e400ed --- /dev/null +++ b/tests/platforms/macOS/app/package/test_package_pkg.py @@ -0,0 +1,493 @@ +import plistlib +from unittest import mock + +import pytest + +from briefcase.exceptions import BriefcaseCommandError + +from .....utils import create_file + + +@pytest.fixture +def license_file(tmp_path): + path = tmp_path / "base_path/LICENSE" + create_file(path, "You can take license with this.") + return path + + +def test_gui_app( + package_command, + first_app_with_binaries, + license_file, + sekrit_identity, + tmp_path, +): + """A macOS GUI app can be packaged as a .pkg installer.""" + first_app_with_binaries.packaging_format = "pkg" + + # Select a codesigning identity + package_command.select_identity.return_value = sekrit_identity + + bundle_path = tmp_path / "base_path/build/first-app/macos/app" + + # Create a pre-existing app bundle. + create_file( + bundle_path / "installer/root/First App.app/original", + "Original app", + ) + + # Create a pre-existing package bundle. + create_file( + bundle_path / "installer/packages/first-app.pkg", + "Original package", + ) + + # Create a pre-existing LICENSE + create_file( + bundle_path / "installer/resources/LICENSE", + "Original License", + ) + + # Re-package the app + package_command.package_app(first_app_with_binaries) + + # The app has been signed + package_command.sign_app.assert_called_once_with( + app=first_app_with_binaries, + identity=sekrit_identity, + ) + + # App content has been copied into place. + assert (bundle_path / "installer/root/First App.app/Contents/Info.plist").is_file() + + # The license has been updated. + assert (bundle_path / "installer/resources/LICENSE").read_text( + encoding="utf-8" + ) == "You can take license with this." + + # The component list has been updated. + with (bundle_path / "installer/components.plist").open("rb") as f: + components = plistlib.load(f) + + assert components == [ + { + "BundleHasStrictIdentifier": True, + "BundleIsRelocatable": False, + "BundleIsVersionChecked": True, + "BundleOverwriteAction": "upgrade", + "RootRelativeBundlePath": "First App.app", + } + ] + + assert package_command.tools.subprocess.run.mock_calls == [ + mock.call( + [ + "pkgbuild", + "--root", + bundle_path / "installer/root", + "--component-plist", + bundle_path / "installer/components.plist", + "--install-location", + "/Applications", + bundle_path / "installer/packages/first-app.pkg", + ], + check=True, + ), + mock.call( + [ + "productbuild", + "--distribution", + bundle_path / "installer/Distribution.xml", + "--package-path", + bundle_path / "installer/packages", + "--resources", + bundle_path / "installer/resources", + tmp_path / "base_path/dist/First App-0.0.1.pkg", + ], + check=True, + ), + ] + + +def test_gui_app_adhoc_identity( + package_command, + first_app_with_binaries, + license_file, + adhoc_identity, + tmp_path, +): + """A macOS GUI app can be packaged as a .pkg installer.""" + first_app_with_binaries.packaging_format = "pkg" + + bundle_path = tmp_path / "base_path/build/first-app/macos/app" + + # Create a pre-existing app bundle. + create_file( + bundle_path / "installer/root/First App.app/original", + "Original app", + ) + + # Create a pre-existing package bundle. + create_file( + bundle_path / "installer/packages/first-app.pkg", + "Original package", + ) + + # Create a pre-existing LICENSE + create_file( + bundle_path / "installer/resources/LICENSE", + "Original License", + ) + + # Re-package the app + package_command.package_app( + first_app_with_binaries, + notarize_app=False, + adhoc_sign=True, + ) + + # The app has been signed + package_command.sign_app.assert_called_once_with( + app=first_app_with_binaries, + identity=adhoc_identity, + ) + + # App content has been copied into place. + assert (bundle_path / "installer/root/First App.app/Contents/Info.plist").is_file() + + # The license has been updated. + assert (bundle_path / "installer/resources/LICENSE").read_text( + encoding="utf-8" + ) == "You can take license with this." + + # The component list has been updated. + with (bundle_path / "installer/components.plist").open("rb") as f: + components = plistlib.load(f) + + assert components == [ + { + "BundleHasStrictIdentifier": True, + "BundleIsRelocatable": False, + "BundleIsVersionChecked": True, + "BundleOverwriteAction": "upgrade", + "RootRelativeBundlePath": "First App.app", + } + ] + + assert package_command.tools.subprocess.run.mock_calls == [ + mock.call( + [ + "pkgbuild", + "--root", + bundle_path / "installer/root", + "--component-plist", + bundle_path / "installer/components.plist", + "--install-location", + "/Applications", + bundle_path / "installer/packages/first-app.pkg", + ], + check=True, + ), + mock.call( + [ + "productbuild", + "--distribution", + bundle_path / "installer/Distribution.xml", + "--package-path", + bundle_path / "installer/packages", + "--resources", + bundle_path / "installer/resources", + tmp_path / "base_path/dist/First App-0.0.1.pkg", + ], + check=True, + ), + ] + + +def test_console_app( + package_command, + first_app_with_binaries, + license_file, + sekrit_identity, + tmp_path, +): + """A macOS console app can be packaged as a .pkg installer.""" + first_app_with_binaries.packaging_format = "pkg" + first_app_with_binaries.console_app = True + + # Select a codesigning identity + package_command.select_identity.return_value = sekrit_identity + + bundle_path = tmp_path / "base_path/build/first-app/macos/app" + + # Package the app + package_command.package_app(first_app_with_binaries) + + # The app has been signed + package_command.sign_app.assert_called_once_with( + app=first_app_with_binaries, + identity=sekrit_identity, + ) + + # App content has been copied into place. + assert (bundle_path / "installer/root/First App.app/Contents/Info.plist").is_file() + + # The license has been installed + assert (bundle_path / "installer/resources/LICENSE").read_text( + encoding="utf-8" + ) == "You can take license with this." + + # The component list has been updated. + with (bundle_path / "installer/components.plist").open("rb") as f: + components = plistlib.load(f) + + assert components == [ + { + "BundleHasStrictIdentifier": True, + "BundleIsRelocatable": False, + "BundleIsVersionChecked": True, + "BundleOverwriteAction": "upgrade", + "RootRelativeBundlePath": "First App.app", + } + ] + + assert package_command.tools.subprocess.run.mock_calls == [ + mock.call( + [ + "pkgbuild", + "--root", + bundle_path / "installer/root", + "--component-plist", + bundle_path / "installer/components.plist", + "--install-location", + "/Library/First App", + "--scripts", + bundle_path / "installer/scripts", + bundle_path / "installer/packages/first-app.pkg", + ], + check=True, + ), + mock.call( + [ + "productbuild", + "--distribution", + bundle_path / "installer/Distribution.xml", + "--package-path", + bundle_path / "installer/packages", + "--resources", + bundle_path / "installer/resources", + tmp_path / "base_path/dist/First App-0.0.1.pkg", + ], + check=True, + ), + ] + + +def test_console_app_adhoc_signed( + package_command, + first_app_with_binaries, + license_file, + adhoc_identity, + tmp_path, +): + """A macOS console app can be packaged as a .pkg installer.""" + first_app_with_binaries.packaging_format = "pkg" + first_app_with_binaries.console_app = True + + bundle_path = tmp_path / "base_path/build/first-app/macos/app" + + # Package the app + package_command.package_app( + first_app_with_binaries, + notarize_app=False, + adhoc_sign=True, + ) + + # The app has been signed + package_command.sign_app.assert_called_once_with( + app=first_app_with_binaries, + identity=adhoc_identity, + ) + + # App content has been copied into place. + assert (bundle_path / "installer/root/First App.app/Contents/Info.plist").is_file() + + # The license has been installed + assert (bundle_path / "installer/resources/LICENSE").read_text( + encoding="utf-8" + ) == "You can take license with this." + + # The component list has been updated. + with (bundle_path / "installer/components.plist").open("rb") as f: + components = plistlib.load(f) + + assert components == [ + { + "BundleHasStrictIdentifier": True, + "BundleIsRelocatable": False, + "BundleIsVersionChecked": True, + "BundleOverwriteAction": "upgrade", + "RootRelativeBundlePath": "First App.app", + } + ] + + assert package_command.tools.subprocess.run.mock_calls == [ + mock.call( + [ + "pkgbuild", + "--root", + bundle_path / "installer/root", + "--component-plist", + bundle_path / "installer/components.plist", + "--install-location", + "/Library/First App", + "--scripts", + bundle_path / "installer/scripts", + bundle_path / "installer/packages/first-app.pkg", + ], + check=True, + ), + mock.call( + [ + "productbuild", + "--distribution", + bundle_path / "installer/Distribution.xml", + "--package-path", + bundle_path / "installer/packages", + "--resources", + bundle_path / "installer/resources", + tmp_path / "base_path/dist/First App-0.0.1.pkg", + ], + check=True, + ), + ] + + +def test_no_license(package_command, first_app_with_binaries, adhoc_identity, tmp_path): + """If the project has no license file, an error is raised.""" + first_app_with_binaries.packaging_format = "pkg" + + with pytest.raises( + BriefcaseCommandError, + match=r"Your project does not contain a LICENSE file", + ): + package_command.package_app( + first_app_with_binaries, + notarize_app=False, + adhoc_sign=True, + ) + + # The app will be signed + package_command.sign_app.assert_called_once_with( + app=first_app_with_binaries, + identity=adhoc_identity, + ) + + # Component manifest hasn't been written + assert not ( + tmp_path / "base_path/build/first-app/macos/app/installer/components.plist" + ).exists() + + # No calls made to pkgbuild/productbuild + package_command.tools.subprocess.run.assert_not_called() + + +def test_package_pkg_previously_built( + package_command, + first_app_with_binaries, + license_file, + adhoc_identity, + tmp_path, +): + """If a previous installer was built, the package folder is recreated.""" + first_app_with_binaries.packaging_format = "pkg" + + bundle_path = tmp_path / "base_path/build/first-app/macos/app" + + # Create a pre-existing app bundle. + create_file( + bundle_path / "installer/root/First App.app/original", + "Original app", + ) + + # Create a pre-existing package bundle. + create_file( + bundle_path / "installer/packages/first-app.pkg", + "Original package", + ) + + # Create a pre-existing LICENSE + create_file( + bundle_path / "installer/resources/LICENSE", + "Original License", + ) + + # Re-package the app + package_command.package_app( + first_app_with_binaries, + notarize_app=False, + adhoc_sign=True, + ) + + # The app has been signed + package_command.sign_app.assert_called_once_with( + app=first_app_with_binaries, + identity=adhoc_identity, + ) + + # App content has been copied into place. + assert (bundle_path / "installer/root/First App.app/Contents/Info.plist").is_file() + + # ... but the old file doesn't exist + assert not (bundle_path / "installer/root/First App.app/original").exists() + + # The old package data doesn't exist either + assert not (bundle_path / "installer/packages/first-app.pkg").exists() + + # The license has been updated. + assert (bundle_path / "installer/resources/LICENSE").read_text( + encoding="utf-8" + ) == "You can take license with this." + + # The component list has been updated. + with (bundle_path / "installer/components.plist").open("rb") as f: + components = plistlib.load(f) + + assert components == [ + { + "BundleHasStrictIdentifier": True, + "BundleIsRelocatable": False, + "BundleIsVersionChecked": True, + "BundleOverwriteAction": "upgrade", + "RootRelativeBundlePath": "First App.app", + } + ] + + assert package_command.tools.subprocess.run.mock_calls == [ + mock.call( + [ + "pkgbuild", + "--root", + bundle_path / "installer/root", + "--component-plist", + bundle_path / "installer/components.plist", + "--install-location", + "/Applications", + bundle_path / "installer/packages/first-app.pkg", + ], + check=True, + ), + mock.call( + [ + "productbuild", + "--distribution", + bundle_path / "installer/Distribution.xml", + "--package-path", + bundle_path / "installer/packages", + "--resources", + bundle_path / "installer/resources", + tmp_path / "base_path/dist/First App-0.0.1.pkg", + ], + check=True, + ), + ] diff --git a/tests/platforms/macOS/app/package/test_package_zip.py b/tests/platforms/macOS/app/package/test_package_zip.py new file mode 100644 index 000000000..c02372725 --- /dev/null +++ b/tests/platforms/macOS/app/package/test_package_zip.py @@ -0,0 +1,109 @@ +from zipfile import ZipFile + + +def test_package_zip( + package_command, + first_app_with_binaries, + sekrit_identity, + tmp_path, +): + """A macOS App can be packaged as a zip.""" + # Select zip packaging + first_app_with_binaries.packaging_format = "zip" + + # Select a code signing identity + package_command.select_identity.return_value = sekrit_identity + + # Package the app in zip (not DMG) format + package_command.package_app(first_app_with_binaries) + + # A request has been made to sign the app + package_command.sign_app.assert_called_once_with( + app=first_app_with_binaries, + identity=sekrit_identity, + ) + + # A request has been made to notarize the app + package_command.notarize.assert_called_once_with( + tmp_path + / "base_path" + / "build" + / "first-app" + / "macos" + / "app" + / "First App.app", + identity=sekrit_identity, + ) + + # No dmg was built. + assert package_command.dmgbuild.build_dmg.call_count == 0 + + # If the DMG doesn't exist, it can't be signed either. + # This ignores the calls that would have been made transitively + # by calling sign_app() + assert package_command.sign_file.call_count == 0 + + # The packaged archive exists, and contains all the files, + # contained in the `.app` bundle. + archive_file = tmp_path / "base_path/dist/First App-0.0.1.app.zip" + assert archive_file.exists() + with ZipFile(archive_file) as archive: + assert sorted(archive.namelist()) == [ + "First App.app/", + "First App.app/Contents/", + "First App.app/Contents/Frameworks/", + "First App.app/Contents/Frameworks/Extras.framework/", + "First App.app/Contents/Frameworks/Extras.framework/Resources/", + "First App.app/Contents/Frameworks/Extras.framework/Resources/extras.dylib", + "First App.app/Contents/Info.plist", + "First App.app/Contents/MacOS/", + "First App.app/Contents/MacOS/First App", + "First App.app/Contents/Resources/", + "First App.app/Contents/Resources/app_packages/", + "First App.app/Contents/Resources/app_packages/Extras.app/", + "First App.app/Contents/Resources/app_packages/Extras.app/Contents/", + "First App.app/Contents/Resources/app_packages/Extras.app/Contents/MacOS/", + "First App.app/Contents/Resources/app_packages/Extras.app/Contents/MacOS/Extras", + "First App.app/Contents/Resources/app_packages/first.other", + "First App.app/Contents/Resources/app_packages/first_dylib.dylib", + "First App.app/Contents/Resources/app_packages/first_so.so", + "First App.app/Contents/Resources/app_packages/other_binary", + "First App.app/Contents/Resources/app_packages/second.other", + "First App.app/Contents/Resources/app_packages/special.binary", + "First App.app/Contents/Resources/app_packages/subfolder/", + "First App.app/Contents/Resources/app_packages/subfolder/second_dylib.dylib", + "First App.app/Contents/Resources/app_packages/subfolder/second_so.so", + "First App.app/Contents/Resources/app_packages/unknown.binary", + ] + + +def test_zip_no_notarization(package_command, sekrit_identity, first_app_with_binaries): + """A macOS App can be packaged as a zip, without notarization.""" + # Select zip packaging + first_app_with_binaries.packaging_format = "zip" + + # Select a code signing identity + package_command.select_identity.return_value = sekrit_identity + + # Package the app in zip (not DMG) format, disabling notarization + package_command.package_app( + first_app_with_binaries, + notarize_app=False, + ) + + # A request has been made to sign the app + package_command.sign_app.assert_called_once_with( + app=first_app_with_binaries, + identity=sekrit_identity, + ) + + # No request has been made to notarize the app + package_command.notarize.assert_not_called() + + # No dmg was built. + assert package_command.dmgbuild.build_dmg.call_count == 0 + + # If the DMG doesn't exist, it can't be signed either. + # This ignores the calls that would have been made transitively + # by calling sign_app() + assert package_command.sign_file.call_count == 0 diff --git a/tests/platforms/macOS/app/test_build.py b/tests/platforms/macOS/app/test_build.py index dfd7db053..364012653 100644 --- a/tests/platforms/macOS/app/test_build.py +++ b/tests/platforms/macOS/app/test_build.py @@ -3,6 +3,7 @@ import pytest from briefcase.console import Console, Log +from briefcase.platforms.macOS import SigningIdentity from briefcase.platforms.macOS.app import macOSAppBuildCommand @@ -22,15 +23,49 @@ def build_command(tmp_path): return command -def test_build_app(build_command, first_app_with_binaries): +@pytest.mark.parametrize("universal_build", [True, False]) +@pytest.mark.parametrize("pre_existing", [True, False]) +def test_build_app( + build_command, + first_app_with_binaries, + universal_build, + pre_existing, + tmp_path, +): """A macOS App is ad-hoc signed as part of the build process.""" + bundle_path = tmp_path / "base_path/build/first-app/macos/app" + + first_app_with_binaries.universal_build = universal_build + build_command.tools.host_arch = "gothic" + + exec_path = bundle_path / "First App.app/Contents/MacOS" + if not pre_existing: + # If this is a pre-existing app, the stub has already been renamed + (exec_path / "First App").rename(exec_path / "Stub") + + # Mock the thin command so we can confirm if it was invoked. + build_command.ensure_thin_binary = mock.Mock() + # Build the app build_command.build_app(first_app_with_binaries, test_mode=False) + # The stub binary has been renamed + assert not (exec_path / "Stub").is_file() + assert (exec_path / "First App").is_file() + + # Only thin if this is a non-universal app + if universal_build: + build_command.ensure_thin_binary.assert_not_called() + else: + build_command.ensure_thin_binary.assert_called_once_with( + exec_path / "First App", + arch="gothic", + ) + # A request has been made to sign the app build_command.sign_app.assert_called_once_with( app=first_app_with_binaries, - identity="-", + identity=SigningIdentity(), ) # No request to select a signing identity was made diff --git a/tests/platforms/macOS/app/test_create.py b/tests/platforms/macOS/app/test_create.py index 91ab045e1..fb29f237a 100644 --- a/tests/platforms/macOS/app/test_create.py +++ b/tests/platforms/macOS/app/test_create.py @@ -709,26 +709,17 @@ def test_install_app_packages_non_universal( create_command.merge_app_packages.assert_not_called() -@pytest.mark.parametrize("universal_build", [True, False]) @pytest.mark.parametrize("pre_existing", [True, False]) def test_install_support_package( create_command, first_app_templated, tmp_path, pre_existing, - universal_build, ): """The standard library is copied out of the support package into the app bundle.""" # Hard code the support revision first_app_templated.support_revision = "37" - first_app_templated.universal_build = universal_build - - create_command.tools.host_arch = "gothic" - - # Mock the thin command so we can confirm if it was invoked. - create_command.ensure_thin_binary = mock.Mock() - bundle_path = tmp_path / "base_path/build/first-app/macos/app" runtime_support_path = bundle_path / "First App.app/Contents/Resources/support" @@ -783,12 +774,3 @@ def test_install_support_package( # The legacy content has been purged assert not (runtime_support_path / "python-stdlib/old-stdlib").exists() - - # Only thin if this is a non-universal app - if universal_build: - create_command.ensure_thin_binary.assert_not_called() - else: - create_command.ensure_thin_binary.assert_called_once_with( - bundle_path / "First App.app/Contents/MacOS/First App", - arch="gothic", - ) diff --git a/tests/platforms/macOS/app/test_mixin.py b/tests/platforms/macOS/app/test_mixin.py index 3fbfb17de..f2ca94575 100644 --- a/tests/platforms/macOS/app/test_mixin.py +++ b/tests/platforms/macOS/app/test_mixin.py @@ -69,8 +69,8 @@ def test_project_path(create_command, first_app_config, tmp_path): assert expected_path == project_path -def test_distribution_path_app(package_command, first_app_config, tmp_path): - first_app_config.packaging_format = "app" +def test_distribution_path_zip(package_command, first_app_config, tmp_path): + first_app_config.packaging_format = "zip" distribution_path = package_command.distribution_path(first_app_config) expected_path = tmp_path / "base_path/dist/First App-0.0.1.app.zip" @@ -83,3 +83,11 @@ def test_distribution_path_dmg(package_command, first_app_config, tmp_path): expected_path = tmp_path / "base_path/dist/First App-0.0.1.dmg" assert distribution_path == expected_path + + +def test_distribution_path_pkg(package_command, first_app_config, tmp_path): + first_app_config.packaging_format = "pkg" + distribution_path = package_command.distribution_path(first_app_config) + + expected_path = tmp_path / "base_path/dist/First App-0.0.1.pkg" + assert distribution_path == expected_path diff --git a/tests/platforms/macOS/app/test_package.py b/tests/platforms/macOS/app/test_package.py deleted file mode 100644 index 8900bc059..000000000 --- a/tests/platforms/macOS/app/test_package.py +++ /dev/null @@ -1,864 +0,0 @@ -import os -import subprocess -from unittest import mock -from zipfile import ZipFile - -import pytest - -import briefcase.integrations.xcode -from briefcase.console import Console, Log -from briefcase.exceptions import BriefcaseCommandError -from briefcase.platforms.macOS.app import macOSAppPackageCommand - - -@pytest.fixture -def package_command(tmp_path): - command = macOSAppPackageCommand( - logger=Log(), - console=Console(), - base_path=tmp_path / "base_path", - data_path=tmp_path / "briefcase", - ) - - command.select_identity = mock.MagicMock() - command.sign_app = mock.MagicMock() - command.sign_file = mock.MagicMock() - command.notarize = mock.MagicMock() - command.dmgbuild = mock.MagicMock() - command.tools.subprocess = mock.MagicMock(spec=subprocess) - - return command - - -def test_package_formats(package_command): - """Packaging formats are as expected.""" - assert package_command.packaging_formats == ["app", "dmg"] - assert package_command.default_packaging_format == "dmg" - - -def test_device_option(package_command): - """The -d option can be parsed.""" - options, overrides = package_command.parse_options(["--no-notarize"]) - - assert options == { - "adhoc_sign": False, - "identity": None, - "notarize_app": False, - "packaging_format": "dmg", - "update": False, - } - assert overrides == {} - - -def test_package_app(package_command, first_app_with_binaries, tmp_path, capsys): - """A macOS App can be packaged.""" - # Select a codesigning identity - package_command.select_identity.return_value = ( - "CAFEBEEF", - "Sekrit identity (DEADBEEF)", - ) - - # Package the app. Sign and notarize by default - package_command.package_app(first_app_with_binaries) - - # A request has been made to sign the app - package_command.sign_app.assert_called_once_with( - app=first_app_with_binaries, identity="CAFEBEEF" - ) - - # The DMG has been built as expected - package_command.dmgbuild.build_dmg.assert_called_once_with( - filename=os.fsdecode(tmp_path / "base_path/dist/First App-0.0.1.dmg"), - volume_name="First App 0.0.1", - settings={ - "files": [ - os.fsdecode( - tmp_path - / "base_path" - / "build" - / "first-app" - / "macos" - / "app" - / "First App.app" - ) - ], - "symlinks": {"Applications": "/Applications"}, - "icon_locations": { - "First App.app": (75, 75), - "Applications": (225, 75), - }, - "window_rect": ((600, 600), (350, 150)), - "icon_size": 64, - "text_size": 12, - }, - ) - - # A request was made to sign the DMG as well. - # This ignores the calls that would have been made transitively - # by calling sign_app() - package_command.sign_file.assert_called_once_with( - tmp_path / "base_path/dist/First App-0.0.1.dmg", - identity="CAFEBEEF", - ) - - # A request was made to notarize the DMG - package_command.notarize.assert_called_once_with( - tmp_path / "base_path/dist/First App-0.0.1.dmg", - team_id="DEADBEEF", - ) - - # The app doesn't specify an app icon or installer icon, so there's no - # mention about the DMG installer icon in the console log. - assert "DMG installer icon" not in capsys.readouterr().out - - -def test_package_app_no_notarization( - package_command, - first_app_with_binaries, - tmp_path, - capsys, -): - """A macOS App can be packaged without notarization.""" - # Select a codesigning identity - package_command.select_identity.return_value = ( - "CAFEBEEF", - "Sekrit identity (DEADBEEF)", - ) - - # Package the app; sign by default, but disable notarization - package_command.package_app(first_app_with_binaries, notarize_app=False) - - # A request has been made to sign the app - package_command.sign_app.assert_called_once_with( - app=first_app_with_binaries, identity="CAFEBEEF" - ) - - # The DMG has been built as expected - package_command.dmgbuild.build_dmg.assert_called_once_with( - filename=os.fsdecode(tmp_path / "base_path/dist/First App-0.0.1.dmg"), - volume_name="First App 0.0.1", - settings={ - "files": [ - os.fsdecode( - tmp_path - / "base_path" - / "build" - / "first-app" - / "macos" - / "app" - / "First App.app" - ) - ], - "symlinks": {"Applications": "/Applications"}, - "icon_locations": { - "First App.app": (75, 75), - "Applications": (225, 75), - }, - "window_rect": ((600, 600), (350, 150)), - "icon_size": 64, - "text_size": 12, - }, - ) - - # A request was made to sign the DMG as well. - # This ignores the calls that would have been made transitively - # by calling sign_app() - package_command.sign_file.assert_called_once_with( - tmp_path / "base_path/dist/First App-0.0.1.dmg", - identity="CAFEBEEF", - ) - - # A request was made to notarize the DMG - package_command.notarize.assert_not_called() - - # The app doesn't specify an app icon or installer icon, so there's no - # mention about the DMG installer icon in the console log. - assert "DMG installer icon" not in capsys.readouterr().out - - -def test_package_app_sign_failure(package_command, first_app_with_binaries, tmp_path): - """If the signing process can't be completed, an error is raised.""" - - # Select a codesigning identity - package_command.select_identity.return_value = ( - "CAFEBEEF", - "Sekrit identity (DEADBEEF)", - ) - - # Raise an error when attempting to sign the app - package_command.sign_app.side_effect = BriefcaseCommandError("Unable to code sign") - - # Attempt to package the app; it should raise an error - with pytest.raises(BriefcaseCommandError, match=r"Unable to code sign"): - package_command.package_app(first_app_with_binaries) - - # A request has been made to sign the app - package_command.sign_app.assert_called_once_with( - app=first_app_with_binaries, - identity="CAFEBEEF", - ) - - # dmgbuild has not been called - package_command.dmgbuild.build_dmg.assert_not_called() - - # No attempt was made to sign the dmg either - # This ignores the calls that would have been made transitively - # by calling sign_app() - package_command.sign_file.assert_not_called() - - -def test_package_app_notarize_adhoc_signed(package_command, first_app_with_binaries): - """A macOS App cannot be notarized if ad-hoc signing is requested.""" - - # Package the app without code signing - with pytest.raises( - BriefcaseCommandError, - match=r"Can't notarize an app with an ad-hoc signing identity", - ): - package_command.package_app( - first_app_with_binaries, - notarize_app=True, - adhoc_sign=True, - ) - - # No code signing or notarization has been performed. - assert package_command.select_identity.call_count == 0 - assert package_command.sign_app.call_count == 0 - assert package_command.sign_file.call_count == 0 - assert package_command.notarize.call_count == 0 - - -def test_package_app_notarize_adhoc_signed_via_prompt( - package_command, first_app_with_binaries -): - """A macOS App cannot be notarized if ad-hoc signing is requested.""" - - package_command.select_identity.return_value = ( - "-", - ( - "Ad-hoc identity. The resulting package will run but cannot be " - "re-distributed." - ), - ) - # Package the app without code signing - with pytest.raises( - BriefcaseCommandError, - match=r"Can't notarize an app with an ad-hoc signing identity", - ): - package_command.package_app( - first_app_with_binaries, - notarize_app=True, - ) - - # No code signing or notarization has been performed. - assert package_command.select_identity.call_count == 1 - assert package_command.sign_app.call_count == 0 - assert package_command.sign_file.call_count == 0 - assert package_command.notarize.call_count == 0 - - -def test_package_app_adhoc_signed_via_prompt( - package_command, first_app_with_binaries, tmp_path -): - """A macOS App cannot be notarized if ad-hoc signing is requested.""" - - package_command.select_identity.return_value = ( - "-", - ( - "Ad-hoc identity. The resulting package will run but cannot be " - "re-distributed." - ), - ) - package_command.package_app( - first_app_with_binaries, - notarize_app=False, - ) - - # A request has been made to sign the app - package_command.sign_app.assert_called_once_with( - app=first_app_with_binaries, - identity="-", - ) - - # The DMG has been built as expected - package_command.dmgbuild.build_dmg.assert_called_once_with( - filename=os.fsdecode(tmp_path / "base_path/dist/First App-0.0.1.dmg"), - volume_name="First App 0.0.1", - settings={ - "files": [ - os.fsdecode( - tmp_path - / "base_path" - / "build" - / "first-app" - / "macos" - / "app" - / "First App.app" - ) - ], - "symlinks": {"Applications": "/Applications"}, - "icon_locations": { - "First App.app": (75, 75), - "Applications": (225, 75), - }, - "window_rect": ((600, 600), (350, 150)), - "icon_size": 64, - "text_size": 12, - }, - ) - - # A request was made to sign the DMG as well. - # This ignores the calls that would have been made transitively - # by calling sign_app() - package_command.sign_file.assert_called_once_with( - tmp_path / "base_path/dist/First App-0.0.1.dmg", - identity="-", - ) - - # No request was made to notarize - package_command.notarize.assert_not_called() - - -def test_package_app_adhoc_sign(package_command, first_app_with_binaries, tmp_path): - """A macOS App can be packaged and signed with ad-hoc identity.""" - - # Package the app with an ad-hoc identity. - # Explicitly disable notarization (can't ad-hoc notarize an app) - package_command.package_app( - first_app_with_binaries, - adhoc_sign=True, - notarize_app=False, - ) - - # A request has been made to sign the app - package_command.sign_app.assert_called_once_with( - app=first_app_with_binaries, - identity="-", - ) - - # The DMG has been built as expected - package_command.dmgbuild.build_dmg.assert_called_once_with( - filename=os.fsdecode(tmp_path / "base_path/dist/First App-0.0.1.dmg"), - volume_name="First App 0.0.1", - settings={ - "files": [ - os.fsdecode( - tmp_path - / "base_path" - / "build" - / "first-app" - / "macos" - / "app" - / "First App.app" - ) - ], - "symlinks": {"Applications": "/Applications"}, - "icon_locations": { - "First App.app": (75, 75), - "Applications": (225, 75), - }, - "window_rect": ((600, 600), (350, 150)), - "icon_size": 64, - "text_size": 12, - }, - ) - - # A request was made to sign the DMG as well. - # This ignores the calls that would have been made transitively - # by calling sign_app() - package_command.sign_file.assert_called_once_with( - tmp_path / "base_path/dist/First App-0.0.1.dmg", - identity="-", - ) - - # No request was made to notarize - package_command.notarize.assert_not_called() - - -def test_package_app_adhoc_sign_default_notarization( - package_command, first_app_with_binaries, tmp_path -): - """An ad-hoc signed app is not notarized by default.""" - - # Package the app with an ad-hoc identity; notarization will - # be disabled as a default - package_command.package_app( - first_app_with_binaries, - adhoc_sign=True, - ) - - # A request has been made to sign the app - package_command.sign_app.assert_called_once_with( - app=first_app_with_binaries, - identity="-", - ) - - # The DMG has been built as expected - package_command.dmgbuild.build_dmg.assert_called_once_with( - filename=os.fsdecode(tmp_path / "base_path/dist/First App-0.0.1.dmg"), - volume_name="First App 0.0.1", - settings={ - "files": [ - os.fsdecode( - tmp_path - / "base_path" - / "build" - / "first-app" - / "macos" - / "app" - / "First App.app" - ) - ], - "symlinks": {"Applications": "/Applications"}, - "icon_locations": { - "First App.app": (75, 75), - "Applications": (225, 75), - }, - "window_rect": ((600, 600), (350, 150)), - "icon_size": 64, - "text_size": 12, - }, - ) - - # A request was made to sign the DMG as well. - # This ignores the calls that would have been made transitively - # by calling sign_app() - package_command.sign_file.assert_called_once_with( - tmp_path / "base_path/dist/First App-0.0.1.dmg", - identity="-", - ) - - # No request was made to notarize - package_command.notarize.assert_not_called() - - -def test_package_bare_app(package_command, first_app_with_binaries, tmp_path): - """A macOS App can be packaged without building dmg.""" - # Select app packaging - first_app_with_binaries.packaging_format = "app" - - # Select a code signing identity - package_command.select_identity.return_value = ( - "CAFEBEEF", - "Sekrit identity (DEADBEEF)", - ) - - # Package the app in app (not DMG) format - first_app_with_binaries.packaging_format = "app" - package_command.package_app(first_app_with_binaries) - - # A request has been made to sign the app - package_command.sign_app.assert_called_once_with( - app=first_app_with_binaries, identity="CAFEBEEF" - ) - - # A request has been made to notarize the app - package_command.notarize.assert_called_once_with( - tmp_path - / "base_path" - / "build" - / "first-app" - / "macos" - / "app" - / "First App.app", - team_id="DEADBEEF", - ) - - # No dmg was built. - assert package_command.dmgbuild.build_dmg.call_count == 0 - - # If the DMG doesn't exist, it can't be signed either. - # This ignores the calls that would have been made transitively - # by calling sign_app() - assert package_command.sign_file.call_count == 0 - - # The packaged archive exists, and contains all the files, - # contained in the `.app` bundle. - archive_file = tmp_path / "base_path/dist/First App-0.0.1.app.zip" - assert archive_file.exists() - with ZipFile(archive_file) as archive: - assert sorted(archive.namelist()) == [ - "First App.app/", - "First App.app/Contents/", - "First App.app/Contents/Frameworks/", - "First App.app/Contents/Frameworks/Extras.framework/", - "First App.app/Contents/Frameworks/Extras.framework/Resources/", - "First App.app/Contents/Frameworks/Extras.framework/Resources/extras.dylib", - "First App.app/Contents/Info.plist", - "First App.app/Contents/Resources/", - "First App.app/Contents/Resources/app_packages/", - "First App.app/Contents/Resources/app_packages/Extras.app/", - "First App.app/Contents/Resources/app_packages/Extras.app/Contents/", - "First App.app/Contents/Resources/app_packages/Extras.app/Contents/MacOS/", - "First App.app/Contents/Resources/app_packages/Extras.app/Contents/MacOS/Extras", - "First App.app/Contents/Resources/app_packages/first.other", - "First App.app/Contents/Resources/app_packages/first_dylib.dylib", - "First App.app/Contents/Resources/app_packages/first_so.so", - "First App.app/Contents/Resources/app_packages/other_binary", - "First App.app/Contents/Resources/app_packages/second.other", - "First App.app/Contents/Resources/app_packages/special.binary", - "First App.app/Contents/Resources/app_packages/subfolder/", - "First App.app/Contents/Resources/app_packages/subfolder/second_dylib.dylib", - "First App.app/Contents/Resources/app_packages/subfolder/second_so.so", - "First App.app/Contents/Resources/app_packages/unknown.binary", - ] - - -def test_package_bare_app_no_notarization(package_command, first_app_with_binaries): - """A macOS App can be packaged without building dmg, and without notarization.""" - # Select app packaging - first_app_with_binaries.packaging_format = "app" - - # Select a code signing identity - package_command.select_identity.return_value = ( - "CAFEBEEF", - "Sekrit identity (DEADBEEF)", - ) - - # Package the app in app (not DMG) format, disabling notarization - first_app_with_binaries.packaging_format = "app" - package_command.package_app( - first_app_with_binaries, - notarize_app=False, - ) - - # A request has been made to sign the app - package_command.sign_app.assert_called_once_with( - app=first_app_with_binaries, - identity="CAFEBEEF", - ) - - # No request has been made to notarize the app - package_command.notarize.assert_not_called() - - # No dmg was built. - assert package_command.dmgbuild.build_dmg.call_count == 0 - - # If the DMG doesn't exist, it can't be signed either. - # This ignores the calls that would have been made transitively - # by calling sign_app() - assert package_command.sign_file.call_count == 0 - - -def test_dmg_with_installer_icon(package_command, first_app_with_binaries, tmp_path): - """An installer icon can be specified for a DMG.""" - # Specify an installer icon, and create the matching file. - first_app_with_binaries.installer_icon = "pretty" - with open(tmp_path / "base_path/pretty.icns", "wb") as f: - f.write(b"A pretty installer icon") - - # Package the app without signing or notarization - package_command.package_app( - first_app_with_binaries, - notarize_app=False, - adhoc_sign=True, - ) - - # The DMG has been built as expected - package_command.dmgbuild.build_dmg.assert_called_once_with( - filename=os.fsdecode(tmp_path / "base_path/dist/First App-0.0.1.dmg"), - volume_name="First App 0.0.1", - settings={ - "files": [ - os.fsdecode( - tmp_path - / "base_path" - / "build" - / "first-app" - / "macos" - / "app" - / "First App.app" - ) - ], - "symlinks": {"Applications": "/Applications"}, - "icon_locations": { - "First App.app": (75, 75), - "Applications": (225, 75), - }, - "window_rect": ((600, 600), (350, 150)), - "icon_size": 64, - "text_size": 12, - "icon": os.fsdecode(tmp_path / "base_path/pretty.icns"), - }, - ) - - -def test_dmg_with_missing_installer_icon( - package_command, - first_app_with_binaries, - tmp_path, - capsys, -): - """If an installer icon is specified, but the specific file is missing, there is a - warning.""" - # Specify an installer icon, but don't create the matching file. - first_app_with_binaries.installer_icon = "pretty" - first_app_with_binaries.packaging_format = "dmg" - - # Package the app without signing or notarization - package_command.package_app( - first_app_with_binaries, - notarize_app=False, - adhoc_sign=True, - ) - - # The DMG has been built as expected - package_command.dmgbuild.build_dmg.assert_called_once_with( - filename=os.fsdecode(tmp_path / "base_path/dist/First App-0.0.1.dmg"), - volume_name="First App 0.0.1", - settings={ - "files": [ - os.fsdecode( - tmp_path - / "base_path" - / "build" - / "first-app" - / "macos" - / "app" - / "First App.app" - ) - ], - "symlinks": {"Applications": "/Applications"}, - "icon_locations": { - "First App.app": (75, 75), - "Applications": (225, 75), - }, - "window_rect": ((600, 600), (350, 150)), - "icon_size": 64, - "text_size": 12, - }, - ) - - # The warning about a missing icon was output - assert ( - "Can't find pretty.icns to use as DMG installer icon\n" - in capsys.readouterr().out - ) - - -def test_dmg_with_app_installer_icon( - package_command, - first_app_with_binaries, - tmp_path, -): - """An installer will fall back to an app icon for a DMG.""" - # Specify an app icon, and create the matching file. - first_app_with_binaries.icon = "pretty_app" - with open(tmp_path / "base_path/pretty_app.icns", "wb") as f: - f.write(b"A pretty app icon") - - # Package the app without signing or notarization - package_command.package_app( - first_app_with_binaries, - notarize_app=False, - adhoc_sign=True, - ) - - # The DMG has been built as expected - package_command.dmgbuild.build_dmg.assert_called_once_with( - filename=os.fsdecode(tmp_path / "base_path/dist/First App-0.0.1.dmg"), - volume_name="First App 0.0.1", - settings={ - "files": [ - os.fsdecode( - tmp_path - / "base_path" - / "build" - / "first-app" - / "macos" - / "app" - / "First App.app" - ) - ], - "symlinks": {"Applications": "/Applications"}, - "icon_locations": { - "First App.app": (75, 75), - "Applications": (225, 75), - }, - "window_rect": ((600, 600), (350, 150)), - "icon_size": 64, - "text_size": 12, - "icon": os.fsdecode(tmp_path / "base_path/pretty_app.icns"), - }, - ) - - -def test_dmg_with_missing_app_installer_icon( - package_command, - first_app_with_binaries, - tmp_path, - capsys, -): - """If an app icon is specified, but the specific file is missing, there is a - warning.""" - # Specify an app icon, but don't create the matching file. - first_app_with_binaries.icon = "pretty_app" - - # Package the app without signing or notarization - package_command.package_app( - first_app_with_binaries, - notarize_app=False, - adhoc_sign=True, - ) - - # The DMG has been built as expected - package_command.dmgbuild.build_dmg.assert_called_once_with( - filename=os.fsdecode(tmp_path / "base_path/dist/First App-0.0.1.dmg"), - volume_name="First App 0.0.1", - settings={ - "files": [ - os.fsdecode( - tmp_path - / "base_path" - / "build" - / "first-app" - / "macos" - / "app" - / "First App.app" - ) - ], - "symlinks": {"Applications": "/Applications"}, - "icon_locations": { - "First App.app": (75, 75), - "Applications": (225, 75), - }, - "window_rect": ((600, 600), (350, 150)), - "icon_size": 64, - "text_size": 12, - }, - ) - - # The warning about a missing icon was output - assert ( - "Can't find pretty_app.icns to use as fallback DMG installer icon\n" - in capsys.readouterr().out - ) - - -def test_dmg_with_installer_background( - package_command, - first_app_with_binaries, - tmp_path, -): - """An installer can be built with an installer background.""" - # Specify an installer background, and create the matching file. - first_app_with_binaries.installer_background = "pretty_background" - with open(tmp_path / "base_path/pretty_background.png", "wb") as f: - f.write(b"A pretty background") - - # Package the app without signing or notarization - package_command.package_app( - first_app_with_binaries, - notarize_app=False, - adhoc_sign=True, - ) - - # The DMG has been built as expected - package_command.dmgbuild.build_dmg.assert_called_once_with( - filename=os.fsdecode(tmp_path / "base_path/dist/First App-0.0.1.dmg"), - volume_name="First App 0.0.1", - settings={ - "files": [ - os.fsdecode( - tmp_path - / "base_path" - / "build" - / "first-app" - / "macos" - / "app" - / "First App.app" - ) - ], - "symlinks": {"Applications": "/Applications"}, - "icon_locations": { - "First App.app": (75, 75), - "Applications": (225, 75), - }, - "window_rect": ((600, 600), (350, 150)), - "icon_size": 64, - "text_size": 12, - "background": os.fsdecode(tmp_path / "base_path/pretty_background.png"), - }, - ) - - -def test_dmg_with_missing_installer_background( - package_command, - first_app_with_binaries, - tmp_path, - capsys, -): - """If an installer image is specified, but the specific file is missing, there is a - warning.""" - # Specify an installer background, but don't create the matching file. - first_app_with_binaries.installer_background = "pretty_background" - first_app_with_binaries.packaging_format = "dmg" - - # Package the app without signing or notarization - package_command.package_app( - first_app_with_binaries, - notarize_app=False, - adhoc_sign=True, - ) - - # The DMG has been built as expected - package_command.dmgbuild.build_dmg.assert_called_once_with( - filename=os.fsdecode(tmp_path / "base_path/dist/First App-0.0.1.dmg"), - volume_name="First App 0.0.1", - settings={ - "files": [ - os.fsdecode( - tmp_path - / "base_path" - / "build" - / "first-app" - / "macos" - / "app" - / "First App.app" - ) - ], - "symlinks": {"Applications": "/Applications"}, - "icon_locations": { - "First App.app": (75, 75), - "Applications": (225, 75), - }, - "window_rect": ((600, 600), (350, 150)), - "icon_size": 64, - "text_size": 12, - }, - ) - - # The warning about a missing background was output - assert ( - "Can't find pretty_background.png to use as DMG background\n" - in capsys.readouterr().out - ) - - -def test_verify(package_command, monkeypatch): - """If you're on macOS, you can verify tools.""" - package_command.tools.host_os = "Darwin" - - # Mock the existence of the command line tools - mock_ensure_command_line_tools_are_installed = mock.MagicMock() - monkeypatch.setattr( - briefcase.integrations.xcode.XcodeCliTools, - "ensure_command_line_tools_are_installed", - mock_ensure_command_line_tools_are_installed, - ) - mock_confirm_xcode_license_accepted = mock.MagicMock() - monkeypatch.setattr( - briefcase.integrations.xcode.XcodeCliTools, - "confirm_xcode_license_accepted", - mock_confirm_xcode_license_accepted, - ) - - package_command.verify_tools() - - assert package_command.tools.xcode_cli is not None - mock_ensure_command_line_tools_are_installed.assert_called_once_with( - tools=package_command.tools - ) - mock_confirm_xcode_license_accepted.assert_called_once_with( - tools=package_command.tools - ) diff --git a/tests/platforms/macOS/app/test_package__team_id_from_identity.py b/tests/platforms/macOS/app/test_package__team_id_from_identity.py deleted file mode 100644 index 2ff127152..000000000 --- a/tests/platforms/macOS/app/test_package__team_id_from_identity.py +++ /dev/null @@ -1,42 +0,0 @@ -import pytest - -from briefcase.console import Console, Log -from briefcase.exceptions import BriefcaseCommandError -from briefcase.platforms.macOS.app import macOSAppPackageCommand - - -@pytest.fixture -def package_command(tmp_path): - command = macOSAppPackageCommand( - logger=Log(), - console=Console(), - base_path=tmp_path / "base_path", - data_path=tmp_path / "briefcase", - ) - return command - - -@pytest.mark.parametrize( - "identity_name, team_id", - [ - ("Developer ID Application: Jane Developer (DEADBEEF)", "DEADBEEF"), - ("Developer ID Application: Edwin (Buzz) Aldrin (DEADBEEF)", "DEADBEEF"), - ], -) -def test_team_id_from_identity(package_command, identity_name, team_id): - assert package_command.team_id_from_identity(identity_name) == team_id - - -@pytest.mark.parametrize( - "identity_name", - [ - "Developer ID Application: Jane Developer", - "DEADBEEF", - ], -) -def test_bad_identity(package_command, identity_name): - with pytest.raises( - BriefcaseCommandError, - match=r"Couldn't extract Team ID from signing identity", - ): - package_command.team_id_from_identity(identity_name) diff --git a/tests/platforms/macOS/app/test_run.py b/tests/platforms/macOS/app/test_run.py index 0fe776116..d82c0dee2 100644 --- a/tests/platforms/macOS/app/test_run.py +++ b/tests/platforms/macOS/app/test_run.py @@ -4,7 +4,7 @@ import pytest -from briefcase.console import Console, Log +from briefcase.console import Console, Log, LogLevel from briefcase.exceptions import BriefcaseCommandError from briefcase.integrations.subprocess import Subprocess from briefcase.platforms.macOS import macOS_log_clean_filter @@ -34,8 +34,8 @@ def mock_stream_app_logs(app, stop_func, **kwargs): return command -def test_run_app(run_command, first_app_config, sleep_zero, tmp_path, monkeypatch): - """A macOS app can be started.""" +def test_run_gui_app(run_command, first_app_config, sleep_zero, tmp_path, monkeypatch): + """A macOS GUI app can be started.""" # Mock a popen object that represents the log stream log_stream_process = mock.MagicMock(spec_set=subprocess.Popen) run_command.tools.subprocess.Popen.return_value = log_stream_process @@ -86,14 +86,16 @@ def test_run_app(run_command, first_app_config, sleep_zero, tmp_path, monkeypatc run_command.tools.os.kill.assert_called_with(100, SIGTERM) -def test_run_app_with_passthrough( +def test_run_gui_app_with_passthrough( run_command, first_app_config, sleep_zero, tmp_path, monkeypatch, ): - """A macOS app can be started with args.""" + """A macOS app can be started in debug mode with args.""" + run_command.logger.verbosity = LogLevel.DEBUG + # Mock a popen object that represents the log stream log_stream_process = mock.MagicMock(spec_set=subprocess.Popen) run_command.tools.subprocess.Popen.return_value = log_stream_process @@ -132,6 +134,7 @@ def test_run_app_with_passthrough( ["open", "-n", bin_path, "--args", "foo", "--bar"], cwd=tmp_path / "home", check=True, + env={"BRIEFCASE_DEBUG": "1"}, ) # The log stream was started @@ -149,7 +152,7 @@ def test_run_app_with_passthrough( run_command.tools.os.kill.assert_called_with(100, SIGTERM) -def test_run_app_failed(run_command, first_app_config, sleep_zero, tmp_path): +def test_run_gui_app_failed(run_command, first_app_config, sleep_zero, tmp_path): """If there's a problem started the app, an exception is raised.""" # Mock a failure opening the app run_command.tools.subprocess.run.side_effect = subprocess.CalledProcessError( @@ -189,7 +192,7 @@ def test_run_app_failed(run_command, first_app_config, sleep_zero, tmp_path): run_command.tools.os.kill.assert_not_called() -def test_run_app_find_pid_failed( +def test_run_gui_app_find_pid_failed( run_command, first_app_config, sleep_zero, @@ -239,14 +242,19 @@ def test_run_app_find_pid_failed( run_command.tools.os.kill.assert_not_called() +@pytest.mark.parametrize("is_console_app", [True, False]) def test_run_app_test_mode( run_command, first_app_config, + is_console_app, sleep_zero, tmp_path, monkeypatch, ): """A macOS app can be started in test mode.""" + # Test mode is the same regardless of whether it's test mode or not. + first_app_config.console_app = is_console_app + # Mock a popen object that represents the log stream log_stream_process = mock.MagicMock(spec_set=subprocess.Popen) run_command.tools.subprocess.Popen.return_value = log_stream_process @@ -296,3 +304,83 @@ def test_run_app_test_mode( # The app process was killed on exit. run_command.tools.os.kill.assert_called_with(100, SIGTERM) + + +def test_run_console_app(run_command, first_app_config, tmp_path): + """A macOS console app can be started.""" + # Set the app to be a console app + first_app_config.console_app = True + + run_command.run_app(first_app_config, test_mode=False, passthrough=[]) + + # Calls were made to start the app and to start a log stream. + bin_path = run_command.binary_path(first_app_config) + run_command.tools.subprocess.run.assert_called_with( + [bin_path / "Contents/MacOS/First App"], + cwd=tmp_path / "home", + check=True, + stream_output=False, + ) + + # The log stream was not started + run_command._stream_app_logs.assert_not_called() + + +def test_run_console_app_with_passthrough( + run_command, + first_app_config, + tmp_path, +): + """A macOS console app can be started in debug mode with args.""" + run_command.logger.verbosity = LogLevel.DEBUG + + # Set the app to be a console app + first_app_config.console_app = True + + # Run the app with args + run_command.run_app( + first_app_config, + test_mode=False, + passthrough=["foo", "--bar"], + ) + + # Calls were made to start the app and to start a log stream. + bin_path = run_command.binary_path(first_app_config) + run_command.tools.subprocess.run.assert_called_with( + [bin_path / "Contents/MacOS/First App", "foo", "--bar"], + cwd=tmp_path / "home", + check=True, + stream_output=False, + env={"BRIEFCASE_DEBUG": "1"}, + ) + + # The log stream was not started + run_command._stream_app_logs.assert_not_called() + + +def test_run_console_app_failed(run_command, first_app_config, sleep_zero, tmp_path): + """If there's a problem started a console app, an exception is raised.""" + # Set the app to be a console app + first_app_config.console_app = True + + # Mock a failure opening the app + run_command.tools.subprocess.run.side_effect = subprocess.CalledProcessError( + cmd=[run_command.binary_path(first_app_config) / "Contents/MacOS/First App"], + returncode=1, + ) + + # Although the command raises an error, this could be because the script itself + # raised an error. + run_command.run_app(first_app_config, test_mode=False, passthrough=[]) + + # Calls were made to start the app and to start a log stream. + bin_path = run_command.binary_path(first_app_config) + run_command.tools.subprocess.run.assert_called_with( + [bin_path / "Contents/MacOS/First App"], + cwd=tmp_path / "home", + stream_output=False, + check=True, + ) + + # No attempt was made to stream the log or cleanup + run_command._stream_app_logs.assert_not_called() diff --git a/tests/platforms/macOS/app/test_signing.py b/tests/platforms/macOS/app/test_signing.py index 8f8c4c1ee..0a4d5bca8 100644 --- a/tests/platforms/macOS/app/test_signing.py +++ b/tests/platforms/macOS/app/test_signing.py @@ -9,7 +9,7 @@ from briefcase.console import Console, Log, LogLevel from briefcase.exceptions import BriefcaseCommandError from briefcase.integrations.subprocess import Subprocess -from briefcase.platforms.macOS import macOSSigningMixin +from briefcase.platforms.macOS import SigningIdentity, macOSSigningMixin from briefcase.platforms.macOS.app import macOSAppMixin from tests.utils import DummyConsole @@ -46,7 +46,7 @@ def dummy_command(tmp_path): def sign_call( tmp_path, filepath, - identity="Sekrit identity (DEADBEEF)", + identity, entitlements=True, runtime=True, ): @@ -56,7 +56,7 @@ def sign_call( "codesign", filepath, "--sign", - identity, + identity.id, "--force", ] if entitlements: @@ -110,9 +110,9 @@ def test_explicit_identity_checksum(dummy_command): # The identity will be the one the user specified as an option. result = dummy_command.select_identity("11E77FB58F13F6108B38110D5D92233C58ED38C5") - assert result == ( - "11E77FB58F13F6108B38110D5D92233C58ED38C5", - "iPhone Developer: Jane Smith (BXAH5H869S)", + assert result == SigningIdentity( + id="11E77FB58F13F6108B38110D5D92233C58ED38C5", + name="iPhone Developer: Jane Smith (BXAH5H869S)", ) # User input was not solicited @@ -130,9 +130,9 @@ def test_explicit_identity_name(dummy_command): # The identity will be the one the user specified as an option. result = dummy_command.select_identity("iPhone Developer: Jane Smith (BXAH5H869S)") - assert result == ( - "11E77FB58F13F6108B38110D5D92233C58ED38C5", - "iPhone Developer: Jane Smith (BXAH5H869S)", + assert result == SigningIdentity( + id="11E77FB58F13F6108B38110D5D92233C58ED38C5", + name="iPhone Developer: Jane Smith (BXAH5H869S)", ) # User input was not solicited @@ -168,9 +168,9 @@ def test_implied_identity(dummy_command): result = dummy_command.select_identity() - assert result == ( - "11E77FB58F13F6108B38110D5D92233C58ED38C5", - "iPhone Developer: Jane Smith (BXAH5H869S)", + assert result == SigningIdentity( + id="11E77FB58F13F6108B38110D5D92233C58ED38C5", + name="iPhone Developer: Jane Smith (BXAH5H869S)", ) # User input was solicited @@ -188,13 +188,8 @@ def test_no_identities(dummy_command): result = dummy_command.select_identity() - assert result == ( - "-", - ( - "Ad-hoc identity. The resulting package will run but cannot be " - "re-distributed." - ), - ) + # Result is the adhoc identity + assert result == SigningIdentity() # User input was solicited assert dummy_command.input.prompts @@ -214,9 +209,9 @@ def test_selected_identity(dummy_command): result = dummy_command.select_identity() # The identity will be the only option available. - assert result == ( - "11E77FB58F13F6108B38110D5D92233C58ED38C5", - "iPhone Developer: Jane Smith (BXAH5H869S)", + assert result == SigningIdentity( + id="11E77FB58F13F6108B38110D5D92233C58ED38C5", + name="iPhone Developer: Jane Smith (BXAH5H869S)", ) # User input was solicited once @@ -224,13 +219,19 @@ def test_selected_identity(dummy_command): @pytest.mark.parametrize("verbose", [True, False]) -def test_sign_file_adhoc_identity(dummy_command, verbose, tmp_path, capsys): +def test_sign_file_adhoc_identity( + dummy_command, + adhoc_identity, + verbose, + tmp_path, + capsys, +): """If an ad-hoc identity is used, the runtime option isn't used.""" if verbose: dummy_command.logger.verbosity = LogLevel.VERBOSE # Sign the file with an ad-hoc identity - dummy_command.sign_file(tmp_path / "base_path/random.file", identity="-") + dummy_command.sign_file(tmp_path / "base_path/random.file", identity=adhoc_identity) # An attempt to codesign was made without the runtime option dummy_command.tools.subprocess.run.assert_has_calls( @@ -238,7 +239,7 @@ def test_sign_file_adhoc_identity(dummy_command, verbose, tmp_path, capsys): sign_call( tmp_path, tmp_path / "base_path/random.file", - identity="-", + identity=adhoc_identity, entitlements=False, runtime=False, ), @@ -252,7 +253,13 @@ def test_sign_file_adhoc_identity(dummy_command, verbose, tmp_path, capsys): @pytest.mark.parametrize("verbose", [True, False]) -def test_sign_file_entitlements(dummy_command, verbose, tmp_path, capsys): +def test_sign_file_entitlements( + dummy_command, + sekrit_identity, + verbose, + tmp_path, + capsys, +): """Entitlements can be included in a signing call.""" if verbose: dummy_command.logger.verbosity = LogLevel.VERBOSE @@ -260,7 +267,7 @@ def test_sign_file_entitlements(dummy_command, verbose, tmp_path, capsys): # Sign the file with an ad-hoc identity dummy_command.sign_file( tmp_path / "base_path/random.file", - identity="Sekrit identity (DEADBEEF)", + identity=sekrit_identity, entitlements=tmp_path / "base_path" / "build" @@ -273,7 +280,11 @@ def test_sign_file_entitlements(dummy_command, verbose, tmp_path, capsys): # An attempt to codesign was made without the runtime option dummy_command.tools.subprocess.run.assert_has_calls( [ - sign_call(tmp_path, tmp_path / "base_path/random.file"), + sign_call( + tmp_path, + tmp_path / "base_path/random.file", + identity=sekrit_identity, + ), ], any_order=False, ) @@ -284,7 +295,13 @@ def test_sign_file_entitlements(dummy_command, verbose, tmp_path, capsys): @pytest.mark.parametrize("verbose", [True, False]) -def test_sign_file_unsupported_format(dummy_command, verbose, tmp_path, capsys): +def test_sign_file_unsupported_format( + dummy_command, + sekrit_identity, + verbose, + tmp_path, + capsys, +): """If codesign reports an unsupported format, the signing attempt is ignored with a warning.""" if verbose: @@ -298,7 +315,7 @@ def test_sign_file_unsupported_format(dummy_command, verbose, tmp_path, capsys): # Sign the file dummy_command.sign_file( tmp_path / "base_path/random.file", - identity="Sekrit identity (DEADBEEF)", + identity=sekrit_identity, ) # An attempt to codesign was made @@ -307,6 +324,7 @@ def test_sign_file_unsupported_format(dummy_command, verbose, tmp_path, capsys): sign_call( tmp_path, tmp_path / "base_path/random.file", + identity=sekrit_identity, entitlements=False, ), ], @@ -323,7 +341,13 @@ def test_sign_file_unsupported_format(dummy_command, verbose, tmp_path, capsys): @pytest.mark.parametrize("verbose", [True, False]) -def test_sign_file_unknown_bundle_format(dummy_command, verbose, tmp_path, capsys): +def test_sign_file_unknown_bundle_format( + dummy_command, + sekrit_identity, + verbose, + tmp_path, + capsys, +): """If a folder happens to have a .framework extension, the signing attempt is ignored with a warning.""" if verbose: @@ -337,7 +361,7 @@ def test_sign_file_unknown_bundle_format(dummy_command, verbose, tmp_path, capsy # Sign the file dummy_command.sign_file( tmp_path / "base_path/random.file", - identity="Sekrit identity (DEADBEEF)", + identity=sekrit_identity, ) # An attempt to codesign was made @@ -346,6 +370,7 @@ def test_sign_file_unknown_bundle_format(dummy_command, verbose, tmp_path, capsy sign_call( tmp_path, tmp_path / "base_path/random.file", + identity=sekrit_identity, entitlements=False, ), ], @@ -362,7 +387,13 @@ def test_sign_file_unknown_bundle_format(dummy_command, verbose, tmp_path, capsy @pytest.mark.parametrize("verbose", [True, False]) -def test_sign_file_unknown_error(dummy_command, verbose, tmp_path, capsys): +def test_sign_file_unknown_error( + dummy_command, + sekrit_identity, + verbose, + tmp_path, + capsys, +): """Any other codesigning error raises an error.""" if verbose: dummy_command.logger.verbosity = LogLevel.VERBOSE @@ -373,7 +404,7 @@ def test_sign_file_unknown_error(dummy_command, verbose, tmp_path, capsys): with pytest.raises(BriefcaseCommandError, match="Unable to code sign "): dummy_command.sign_file( tmp_path / "base_path/random.file", - identity="Sekrit identity (DEADBEEF)", + identity=sekrit_identity, ) # An attempt to codesign was made @@ -382,6 +413,7 @@ def test_sign_file_unknown_error(dummy_command, verbose, tmp_path, capsys): sign_call( tmp_path, tmp_path / "base_path/random.file", + identity=sekrit_identity, entitlements=False, ), ], @@ -394,14 +426,22 @@ def test_sign_file_unknown_error(dummy_command, verbose, tmp_path, capsys): @pytest.mark.parametrize("verbose", [True, False]) -def test_sign_app(dummy_command, first_app_with_binaries, verbose, tmp_path, capsys): +def test_sign_app( + dummy_command, + sekrit_identity, + first_app_with_binaries, + verbose, + tmp_path, + capsys, +): """An app bundle can be signed.""" if verbose: dummy_command.logger.verbosity = LogLevel.VERBOSE # Sign the app dummy_command.sign_app( - first_app_with_binaries, identity="Sekrit identity (DEADBEEF)" + first_app_with_binaries, + identity=sekrit_identity, ) # A request has been made to sign all the so and dylib files @@ -425,20 +465,61 @@ def test_sign_app(dummy_command, first_app_with_binaries, verbose, tmp_path, cap frameworks_path = app_path / "Contents/Frameworks" dummy_command.tools.subprocess.run.assert_has_calls( [ - sign_call(tmp_path, lib_path / "subfolder/second_so.so"), - sign_call(tmp_path, lib_path / "subfolder/second_dylib.dylib"), - sign_call(tmp_path, lib_path / "special.binary"), - sign_call(tmp_path, lib_path / "other_binary"), - sign_call(tmp_path, lib_path / "first_so.so"), - sign_call(tmp_path, lib_path / "first_dylib.dylib"), - sign_call(tmp_path, lib_path / "Extras.app/Contents/MacOS/Extras"), - sign_call(tmp_path, lib_path / "Extras.app"), + sign_call( + tmp_path, + lib_path / "subfolder/second_so.so", + identity=sekrit_identity, + ), + sign_call( + tmp_path, + lib_path / "subfolder/second_dylib.dylib", + identity=sekrit_identity, + ), + sign_call( + tmp_path, + lib_path / "special.binary", + identity=sekrit_identity, + ), + sign_call( + tmp_path, + lib_path / "other_binary", + identity=sekrit_identity, + ), + sign_call( + tmp_path, + lib_path / "first_so.so", + identity=sekrit_identity, + ), + sign_call( + tmp_path, + lib_path / "first_dylib.dylib", + identity=sekrit_identity, + ), + sign_call( + tmp_path, + lib_path / "Extras.app/Contents/MacOS/Extras", + identity=sekrit_identity, + ), + sign_call( + tmp_path, + lib_path / "Extras.app", + identity=sekrit_identity, + ), sign_call( tmp_path, frameworks_path / "Extras.framework/Resources/extras.dylib", + identity=sekrit_identity, + ), + sign_call( + tmp_path, + frameworks_path / "Extras.framework", + identity=sekrit_identity, + ), + sign_call( + tmp_path, + app_path, + identity=sekrit_identity, ), - sign_call(tmp_path, frameworks_path / "Extras.framework"), - sign_call(tmp_path, app_path), ], any_order=True, ) @@ -470,7 +551,13 @@ def test_sign_app(dummy_command, first_app_with_binaries, verbose, tmp_path, cap @pytest.mark.parametrize("verbose", [True, False]) -def test_sign_app_with_failure(dummy_command, first_app_with_binaries, verbose, capsys): +def test_sign_app_with_failure( + dummy_command, + sekrit_identity, + first_app_with_binaries, + verbose, + capsys, +): """If signing a single file in the app fails, the error is surfaced.""" if verbose: dummy_command.logger.verbosity = LogLevel.VERBOSE @@ -490,7 +577,8 @@ def _codesign(args, **kwargs): BriefcaseCommandError, match=r"Unable to code sign .*first_dylib\.dylib" ): dummy_command.sign_app( - first_app_with_binaries, identity="Sekrit identity (DEADBEEF)" + first_app_with_binaries, + identity=sekrit_identity, ) # There has been at least 1 call to sign files. We can't know how many are diff --git a/tests/platforms/macOS/test_SigningIdentity.py b/tests/platforms/macOS/test_SigningIdentity.py new file mode 100644 index 000000000..883dca3e4 --- /dev/null +++ b/tests/platforms/macOS/test_SigningIdentity.py @@ -0,0 +1,53 @@ +import pytest + +from briefcase.exceptions import BriefcaseCommandError +from briefcase.platforms.macOS import SigningIdentity + + +@pytest.mark.parametrize( + "identity_id, identity_name, team_id", + [ + ("CAFEBEEF", "Developer ID Application: Jane Developer (DEADBEEF)", "DEADBEEF"), + ( + "CAFEBEEF", + "Developer ID Application: Edwin (Buzz) Aldrin (DEADBEEF)", + "DEADBEEF", + ), + ], +) +def test_identity(identity_id, identity_name, team_id): + """A signing identity can be created.""" + identity = SigningIdentity(id=identity_id, name=identity_name) + assert identity.id == identity_id + assert identity.name == identity_name + assert identity.team_id == team_id + assert not identity.is_adhoc + assert repr(identity) == f"" + + +@pytest.mark.parametrize( + "identity_name", + [ + "Developer ID Application: Jane Developer", + "DEADBEEF", + ], +) +def test_bad_identity(identity_name): + """Creating a bad identity raises an error.""" + with pytest.raises( + BriefcaseCommandError, + match=r"Couldn't extract Team ID from signing identity", + ): + SigningIdentity(id="CAFEBEEF", name=identity_name) + + +def test_adhoc_identity(): + """An ad-hoc identity can be created.""" + adhoc = SigningIdentity() + assert adhoc.id == "-" + assert ( + adhoc.name + == "Ad-hoc identity. The resulting package will run but cannot be re-distributed." + ) + assert adhoc.is_adhoc + assert repr(adhoc) == "" diff --git a/tests/platforms/windows/app/conftest.py b/tests/platforms/windows/app/conftest.py new file mode 100644 index 000000000..6cb6517e8 --- /dev/null +++ b/tests/platforms/windows/app/conftest.py @@ -0,0 +1,14 @@ +import pytest + +from ....utils import create_file + + +# Windows' AppConfig requires attribute 'packaging_format' +@pytest.fixture +def first_app_templated(first_app_config, tmp_path): + app_path = tmp_path / "base_path/build/first-app/windows/app/src" + + # Create the stub binary + create_file(app_path / "Stub.exe", "Stub binary") + + return first_app_config diff --git a/tests/platforms/windows/app/test_build.py b/tests/platforms/windows/app/test_build.py index 4a59efd7b..af7cf8b24 100644 --- a/tests/platforms/windows/app/test_build.py +++ b/tests/platforms/windows/app/test_build.py @@ -67,7 +67,7 @@ def test_verify_without_windows_sdk(build_command, monkeypatch): assert not hasattr(build_command.tools, "windows_sdk") -def test_verify_with_windows_sdk(build_command, windows_sdk, monkeypatch, tmp_path): +def test_verify_with_windows_sdk(build_command, windows_sdk, monkeypatch): """Verifying on Windows creates an RCEdit and Windows SDK wrapper.""" build_command.tools.windows_sdk = windows_sdk @@ -95,15 +95,40 @@ def test_verify_with_windows_sdk(build_command, windows_sdk, monkeypatch, tmp_pa assert isinstance(build_command.tools.windows_sdk, WindowsSDK) -def test_build_app_without_windows_sdk(build_command, first_app_config, tmp_path): +@pytest.mark.parametrize("pre_existing", [True, False]) +@pytest.mark.parametrize("console_app", [True, False]) +def test_build_app_without_windows_sdk( + build_command, + first_app_templated, + pre_existing, + console_app, + tmp_path, +): """The stub binary will be updated when a Windows app is built.""" - build_command.build_app(first_app_config) + first_app_templated.console_app = console_app + + exec_path = tmp_path / "base_path/build/first-app/windows/app/src" + if pre_existing: + # If this is a pre-existing app, the stub has already been renamed + if console_app: + (exec_path / "Stub.exe").rename(exec_path / "first-app.exe") + else: + (exec_path / "Stub.exe").rename(exec_path / "First App.exe") + + build_command.build_app(first_app_templated) + + # The stub binary has been renamed + assert not (exec_path / "Stub.exe").is_file() + if console_app: + assert (exec_path / "first-app.exe").is_file() + else: + assert (exec_path / "First App.exe").is_file() # update the app binary resources build_command.tools.subprocess.run.assert_called_once_with( [ tmp_path / "briefcase/tools/rcedit-x64.exe", - Path("src/First App.exe"), + Path("src/first-app.exe") if console_app else Path("src/First App.exe"), "--set-version-string", "CompanyName", "Megacorp", @@ -118,7 +143,7 @@ def test_build_app_without_windows_sdk(build_command, first_app_config, tmp_path "first_app", "--set-version-string", "OriginalFilename", - "First App.exe", + "first-app.exe" if console_app else "First App.exe", "--set-version-string", "ProductName", "First App", @@ -136,13 +161,13 @@ def test_build_app_without_windows_sdk(build_command, first_app_config, tmp_path def test_build_app_with_windows_sdk( build_command, windows_sdk, - first_app_config, + first_app_templated, tmp_path, ): """The stub binary will be updated when a Windows app is built.""" build_command.tools.windows_sdk = windows_sdk - build_command.build_app(first_app_config) + build_command.build_app(first_app_templated) # remove any digital signatures on the app binary build_command.tools.subprocess.check_output.assert_called_once_with( @@ -191,7 +216,7 @@ def test_build_app_with_windows_sdk( def test_build_app_without_any_digital_signatures( build_command, windows_sdk, - first_app_config, + first_app_templated, tmp_path, ): """If the app binary is not already signed, then attempt to remove signatures fails @@ -208,7 +233,7 @@ def test_build_app_without_any_digital_signatures( """, ) - build_command.build_app(first_app_config) + build_command.build_app(first_app_templated) # remove any digital signatures on the app binary build_command.tools.subprocess.check_output.assert_called_once_with( @@ -257,7 +282,7 @@ def test_build_app_without_any_digital_signatures( def test_build_app_error_remove_signature( build_command, windows_sdk, - first_app_config, + first_app_templated, tmp_path, ): """If the attempt to remove any exist digital signatures fails because signtool @@ -282,7 +307,7 @@ def test_build_app_error_remove_signature( "\n" ) with pytest.raises(BriefcaseCommandError, match=re.escape(error_message)): - build_command.build_app(first_app_config) + build_command.build_app(first_app_templated) # remove any digital signatures on the app binary build_command.tools.subprocess.check_output.assert_called_once_with( @@ -298,7 +323,7 @@ def test_build_app_error_remove_signature( build_command.tools.subprocess.run.assert_not_called() -def test_build_app_failure(build_command, first_app_config, tmp_path): +def test_build_app_failure(build_command, first_app_templated, tmp_path): """If the stub binary cannot be updated, an error is raised.""" build_command.tools.subprocess.run.side_effect = subprocess.CalledProcessError( @@ -310,12 +335,12 @@ def test_build_app_failure(build_command, first_app_config, tmp_path): BriefcaseCommandError, match=r"Unable to update details on stub app for first-app.", ): - build_command.build_app(first_app_config) + build_command.build_app(first_app_templated) def test_build_app_with_support_package_update( build_command, - first_app_config, + first_app_templated, tmp_path, windows_sdk, capsys, @@ -327,10 +352,9 @@ def test_build_app_with_support_package_update( # app. build_command.tools.host_os = "Windows" build_command.tools.windows_sdk = windows_sdk - build_command.bundle_path(first_app_config).mkdir(parents=True) # Hard code a support revision so that the download support package is fixed - first_app_config.support_revision = "1" + first_app_templated.support_revision = "1" # Fake the existence of some source files. create_file( @@ -338,11 +362,8 @@ def test_build_app_with_support_package_update( "print('an app')", ) - # Mock the generated app template - (build_command.bundle_path(first_app_config) / "src").mkdir(parents=True) - # Populate a briefcase.toml that mirrors a real Windows app - with (build_command.bundle_path(first_app_config) / "briefcase.toml").open( + with (build_command.bundle_path(first_app_templated) / "briefcase.toml").open( "wb" ) as f: index = { @@ -355,7 +376,7 @@ def test_build_app_with_support_package_update( tomli_w.dump(index, f) # Build the app with a support package update - build_command(first_app_config, update_support=True) + build_command(first_app_templated, update_support=True) # update the app binary resources build_command.tools.subprocess.run.assert_called_once_with( diff --git a/tests/platforms/windows/app/test_run.py b/tests/platforms/windows/app/test_run.py index f4b97943b..270c3a56d 100644 --- a/tests/platforms/windows/app/test_run.py +++ b/tests/platforms/windows/app/test_run.py @@ -3,7 +3,7 @@ import pytest -from briefcase.console import Console, Log +from briefcase.console import Console, Log, LogLevel from briefcase.integrations.subprocess import Subprocess from briefcase.platforms.windows.app import WindowsAppRunCommand @@ -24,8 +24,8 @@ def run_command(tmp_path): return command -def test_run_app(run_command, first_app_config, tmp_path): - """A Windows app can be started.""" +def test_run_gui_app(run_command, first_app_config, tmp_path): + """A Windows GUI app can be started.""" # Set up the log streamer to return a known stream log_popen = mock.MagicMock() run_command.tools.subprocess.Popen.return_value = log_popen @@ -52,8 +52,10 @@ def test_run_app(run_command, first_app_config, tmp_path): ) -def test_run_app_with_passthrough(run_command, first_app_config, tmp_path): - """A Windows app can be started with args.""" +def test_run_gui_app_with_passthrough(run_command, first_app_config, tmp_path): + """A Windows GUI app can be started in debug mode with args.""" + run_command.logger.verbosity = LogLevel.DEBUG + # Set up the log streamer to return a known stream log_popen = mock.MagicMock() run_command.tools.subprocess.Popen.return_value = log_popen @@ -77,6 +79,7 @@ def test_run_app_with_passthrough(run_command, first_app_config, tmp_path): stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=1, + env={"BRIEFCASE_DEBUG": "1"}, ) # The streamer was started @@ -88,8 +91,8 @@ def test_run_app_with_passthrough(run_command, first_app_config, tmp_path): ) -def test_run_app_failed(run_command, first_app_config, tmp_path): - """If there's a problem started the app, an exception is raised.""" +def test_run_gui_app_failed(run_command, first_app_config, tmp_path): + """If there's a problem starting the GUI app, an exception is raised.""" run_command.tools.subprocess.Popen.side_effect = OSError @@ -110,8 +113,89 @@ def test_run_app_failed(run_command, first_app_config, tmp_path): run_command._stream_app_logs.assert_not_called() -def test_run_app_test_mode(run_command, first_app_config, tmp_path): +def test_run_console_app(run_command, first_app_config, tmp_path): + """A Windows GUI app can be started.""" + first_app_config.console_app = True + + # Set up the log streamer to return a known stream + log_popen = mock.MagicMock() + run_command.tools.subprocess.Popen.return_value = log_popen + + # Run the app + run_command.run_app(first_app_config, test_mode=False, passthrough=[]) + + # The process was started + run_command.tools.subprocess.run.assert_called_with( + [tmp_path / "base_path/build/first-app/windows/app/src/first-app.exe"], + cwd=tmp_path / "home", + encoding="UTF-8", + bufsize=1, + stream_output=False, + ) + + # There is no streamer + run_command._stream_app_logs.assert_not_called() + + +def test_run_console_app_with_passthrough(run_command, first_app_config, tmp_path): + """A Windows console app can be started in debug mode with args.""" + run_command.logger.verbosity = LogLevel.DEBUG + + first_app_config.console_app = True + + # Run the app with args + run_command.run_app( + first_app_config, + test_mode=False, + passthrough=["foo", "--bar"], + ) + + # The process was started + run_command.tools.subprocess.run.assert_called_with( + [ + tmp_path / "base_path/build/first-app/windows/app/src/first-app.exe", + "foo", + "--bar", + ], + cwd=tmp_path / "home", + encoding="UTF-8", + bufsize=1, + stream_output=False, + env={"BRIEFCASE_DEBUG": "1"}, + ) + + # There is no streamer + run_command._stream_app_logs.assert_not_called() + + +def test_run_console_app_failed(run_command, first_app_config, tmp_path): + """If there's a problem starting the console app, an exception is raised.""" + first_app_config.console_app = True + + run_command.tools.subprocess.run.side_effect = OSError + + with pytest.raises(OSError): + run_command.run_app(first_app_config, test_mode=False, passthrough=[]) + + # Popen was still invoked, though + run_command.tools.subprocess.run.assert_called_with( + [tmp_path / "base_path/build/first-app/windows/app/src/first-app.exe"], + cwd=tmp_path / "home", + encoding="UTF-8", + bufsize=1, + stream_output=False, + ) + + # No attempt to stream was made + run_command._stream_app_logs.assert_not_called() + + +@pytest.mark.parametrize("is_console_app", [True, False]) +def test_run_app_test_mode(run_command, first_app_config, is_console_app, tmp_path): """A Windows app can be started in test mode.""" + # Test mode apps are always streamed + first_app_config.console_app = is_console_app + # Set up the log streamer to return a known stream log_popen = mock.MagicMock() run_command.tools.subprocess.Popen.return_value = log_popen @@ -120,8 +204,9 @@ def test_run_app_test_mode(run_command, first_app_config, tmp_path): run_command.run_app(first_app_config, test_mode=True, passthrough=[]) # The process was started + exe_name = "first-app" if is_console_app else "First App" run_command.tools.subprocess.Popen.assert_called_with( - [tmp_path / "base_path/build/first-app/windows/app/src/First App.exe"], + [tmp_path / f"base_path/build/first-app/windows/app/src/{exe_name}.exe"], cwd=tmp_path / "home", encoding="UTF-8", stdout=subprocess.PIPE, @@ -139,8 +224,17 @@ def test_run_app_test_mode(run_command, first_app_config, tmp_path): ) -def test_run_app_test_mode_with_passthrough(run_command, first_app_config, tmp_path): +@pytest.mark.parametrize("is_console_app", [True, False]) +def test_run_app_test_mode_with_passthrough( + run_command, + first_app_config, + is_console_app, + tmp_path, +): """A Windows app can be started in test mode with args.""" + # Test mode apps are always streamed + first_app_config.console_app = is_console_app + # Set up the log streamer to return a known stream log_popen = mock.MagicMock() run_command.tools.subprocess.Popen.return_value = log_popen @@ -153,9 +247,10 @@ def test_run_app_test_mode_with_passthrough(run_command, first_app_config, tmp_p ) # The process was started + exe_name = "first-app" if is_console_app else "First App" run_command.tools.subprocess.Popen.assert_called_with( [ - tmp_path / "base_path/build/first-app/windows/app/src/First App.exe", + tmp_path / f"base_path/build/first-app/windows/app/src/{exe_name}.exe", "foo", "--bar", ],