From f1924d7fdecadf3f8181a14b7a8beadf5460c4e2 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 6 May 2024 13:36:43 +0800 Subject: [PATCH 01/23] Add changenote. --- changes/1184.feature.rst | 1 + changes/556.feature.rst | 1 + 2 files changed, 2 insertions(+) create mode 100644 changes/1184.feature.rst create mode 100644 changes/556.feature.rst 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/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. From 8031f942fe45691eb7a3ad6079b8b8441bbb68cb Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 7 May 2024 14:57:01 +0800 Subject: [PATCH 02/23] Add an option to configure console apps. --- docs/reference/configuration.rst | 6 ++++++ src/briefcase/config.py | 2 ++ tests/commands/create/test_generate_app_template.py | 1 + 3 files changed, 9 insertions(+) diff --git a/docs/reference/configuration.rst b/docs/reference/configuration.rst index f038f2384..719f2ee51 100644 --- a/docs/reference/configuration.rst +++ b/docs/reference/configuration.rst @@ -237,6 +237,12 @@ 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 as GUI app). + ``exit_regex`` ~~~~~~~~~~~~~~ diff --git a/src/briefcase/config.py b/src/briefcase/config.py index 34d50800a..94ecbf0f5 100644 --- a/src/briefcase/config.py +++ b/src/briefcase/config.py @@ -201,6 +201,7 @@ def __init__( test_requires=None, supported=True, long_description=None, + console_app=False, **kwargs, ): super().__init__(**kwargs) @@ -228,6 +229,7 @@ def __init__( self.test_requires = test_requires self.supported = supported self.long_description = long_description + self.console_app = console_app if not is_valid_app_name(self.app_name): raise BriefcaseConfigError( diff --git a/tests/commands/create/test_generate_app_template.py b/tests/commands/create/test_generate_app_template.py index 4b5ed110c..e250caf08 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": {}, From 5b730bc940f6d2fcabc3d4a3fbee14d31ce5cb85 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 7 May 2024 15:50:46 +0800 Subject: [PATCH 03/23] Disable log streaming when running console apps. --- src/briefcase/commands/dev.py | 68 +++++++++------ src/briefcase/platforms/macOS/__init__.py | 57 ++++++++++++ tests/commands/dev/test_run_dev_app.py | 100 +++++++++++++++++++++- tests/platforms/macOS/app/test_run.py | 88 +++++++++++++++++-- 4 files changed, 280 insertions(+), 33 deletions(-) diff --git a/src/briefcase/commands/dev.py b/src/briefcase/commands/dev.py index f37e36ec5..6cdc250d0 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)' + ), + ] + + # 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, - ) + # 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 diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py index 415ebd3e8..ed14af6ac 100644 --- a/src/briefcase/platforms/macOS/__init__.py +++ b/src/briefcase/platforms/macOS/__init__.py @@ -204,6 +204,63 @@ 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.logger.info("=" * 75) + 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_env(app=app, test_mode=False) + + # Start the app directly + 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, + **kwargs, + ) + + except subprocess.CalledProcessError: + raise BriefcaseCommandError(f"Unable to start app {app.app_name}.") + + 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 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/platforms/macOS/app/test_run.py b/tests/platforms/macOS/app/test_run.py index 0fe776116..e28d77038 100644 --- a/tests/platforms/macOS/app/test_run.py +++ b/tests/platforms/macOS/app/test_run.py @@ -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,7 +86,7 @@ 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, @@ -149,7 +149,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 +189,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 +239,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 +301,76 @@ 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, + ) + + # 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 with args.""" + # 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, + ) + + # 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, + ) + + with pytest.raises(BriefcaseCommandError): + 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, + ) + + # No attempt was made to stream the log or cleanup + run_command._stream_app_logs.assert_not_called() From f42099d8caa76012bb9b3db6606d714c55f9162c Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 7 May 2024 16:16:50 +0800 Subject: [PATCH 04/23] Add a wrapper class for describing macOS signing identities. --- src/briefcase/platforms/macOS/__init__.py | 44 ++++++++++------ .../test_package__team_id_from_identity.py | 42 --------------- tests/platforms/macOS/test_SigningIdentity.py | 51 +++++++++++++++++++ 3 files changed, 79 insertions(+), 58 deletions(-) delete mode 100644 tests/platforms/macOS/app/test_package__team_id_from_identity.py create mode 100644 tests/platforms/macOS/test_SigningIdentity.py diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py index ed14af6ac..0e0abba61 100644 --- a/src/briefcase/platforms/macOS/__init__.py +++ b/src/briefcase/platforms/macOS/__init__.py @@ -33,6 +33,34 @@ ) +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): + return f"" + + def __eq__(self, other): + return isinstance(other, SigningIdentity) and self.id == other.id + + class macOSMixin: platform = "macOS" supported_host_os = {"Darwin"} @@ -620,22 +648,6 @@ 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. - - 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}" - ) - def notarize(self, filename, team_id): """Notarize a file. 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/test_SigningIdentity.py b/tests/platforms/macOS/test_SigningIdentity.py new file mode 100644 index 000000000..0e9338eea --- /dev/null +++ b/tests/platforms/macOS/test_SigningIdentity.py @@ -0,0 +1,51 @@ +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 + + +@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 From 1445a4fd3bc083920801a973f0c62d424430ed8b Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 7 May 2024 16:51:02 +0800 Subject: [PATCH 05/23] Switch packaging code to use SigningIdentity. --- src/briefcase/platforms/macOS/__init__.py | 230 ++++++++++-------- tests/platforms/macOS/app/conftest.py | 12 + tests/platforms/macOS/app/test_mixin.py | 4 +- tests/platforms/macOS/app/test_package.py | 140 +++++------ .../macOS/app/test_package__notarize.py | 82 +++++-- tests/platforms/macOS/app/test_signing.py | 208 ++++++++++++---- tests/platforms/macOS/test_SigningIdentity.py | 2 + 7 files changed, 432 insertions(+), 246 deletions(-) diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py index 0e0abba61..641113f5c 100644 --- a/src/briefcase/platforms/macOS/__init__.py +++ b/src/briefcase/platforms/macOS/__init__.py @@ -55,7 +55,10 @@ def is_adhoc(self): return self.id == "-" def __repr__(self): - return f"" + if self.is_adhoc: + return "" + else: + return f"" def __eq__(self, other): return isinstance(other, SigningIdentity) and self.id == other.id @@ -409,7 +412,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 @@ -426,13 +429,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( @@ -465,18 +468,22 @@ def select_identity(self, identity=None): """ ) - 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") @@ -528,7 +535,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 @@ -608,17 +615,17 @@ class macOSPackageMixin(macOSSigningMixin): @property def packaging_formats(self): - return ["app", "dmg"] + return ["zip", "dmg"] @property def default_packaging_format(self): return "dmg" 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" + else: + return self.dist_path / f"{app.formal_name}-{app.version}.dmg" def add_options(self, parser): super().add_options(parser) @@ -648,7 +655,7 @@ def __init__(self, *args, **kwargs): # These are abstracted to enable testing without patching. self.dmgbuild = dmgbuild - def notarize(self, filename, team_id): + def notarize(self, filename: Path, identity: SigningIdentity): """Notarize a file. Submits the file to Apple for notarization; if successful, staples the @@ -657,7 +664,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": @@ -678,7 +685,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: @@ -689,7 +696,7 @@ 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 """ ) @@ -718,7 +725,7 @@ def notarize(self, filename, team_id): "notarytool", "store-credentials", "--team-id", - team_id, + identity.team_id, profile, ], check=True, @@ -726,7 +733,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 @@ -799,12 +806,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" @@ -835,93 +841,117 @@ 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, + ) + + 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, - ) - 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, - ) + 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, + ) - 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, - } + 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" + 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/tests/platforms/macOS/app/conftest.py b/tests/platforms/macOS/app/conftest.py index 94139c227..9eed7e7e4 100644 --- a/tests/platforms/macOS/app/conftest.py +++ b/tests/platforms/macOS/app/conftest.py @@ -3,9 +3,21 @@ 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 = ( diff --git a/tests/platforms/macOS/app/test_mixin.py b/tests/platforms/macOS/app/test_mixin.py index 3fbfb17de..25ac3ef6a 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" diff --git a/tests/platforms/macOS/app/test_package.py b/tests/platforms/macOS/app/test_package.py index 8900bc059..6bcd3d5e6 100644 --- a/tests/platforms/macOS/app/test_package.py +++ b/tests/platforms/macOS/app/test_package.py @@ -32,7 +32,7 @@ def package_command(tmp_path): def test_package_formats(package_command): """Packaging formats are as expected.""" - assert package_command.packaging_formats == ["app", "dmg"] + assert package_command.packaging_formats == ["zip", "dmg"] assert package_command.default_packaging_format == "dmg" @@ -50,20 +50,20 @@ def test_device_option(package_command): assert overrides == {} -def test_package_app(package_command, first_app_with_binaries, tmp_path, capsys): +def test_package_app( + package_command, first_app_with_binaries, sekrit_identity, tmp_path, capsys +): """A macOS App can be packaged.""" # Select a codesigning identity - package_command.select_identity.return_value = ( - "CAFEBEEF", - "Sekrit identity (DEADBEEF)", - ) + 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="CAFEBEEF" + app=first_app_with_binaries, + identity=sekrit_identity, ) # The DMG has been built as expected @@ -98,13 +98,13 @@ def test_package_app(package_command, first_app_with_binaries, tmp_path, capsys) # 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", + 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", - team_id="DEADBEEF", + identity=sekrit_identity, ) # The app doesn't specify an app icon or installer icon, so there's no @@ -115,22 +115,21 @@ def test_package_app(package_command, first_app_with_binaries, tmp_path, capsys) def test_package_app_no_notarization( package_command, first_app_with_binaries, + sekrit_identity, 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_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="CAFEBEEF" + app=first_app_with_binaries, + identity=sekrit_identity, ) # The DMG has been built as expected @@ -165,7 +164,7 @@ def test_package_app_no_notarization( # 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", + identity=sekrit_identity, ) # A request was made to notarize the DMG @@ -176,14 +175,16 @@ def test_package_app_no_notarization( assert "DMG installer icon" not in capsys.readouterr().out -def test_package_app_sign_failure(package_command, first_app_with_binaries, tmp_path): +def test_package_app_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 = ( - "CAFEBEEF", - "Sekrit identity (DEADBEEF)", - ) + 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") @@ -195,7 +196,7 @@ def test_package_app_sign_failure(package_command, first_app_with_binaries, tmp_ # A request has been made to sign the app package_command.sign_app.assert_called_once_with( app=first_app_with_binaries, - identity="CAFEBEEF", + identity=sekrit_identity, ) # dmgbuild has not been called @@ -229,17 +230,14 @@ def test_package_app_notarize_adhoc_signed(package_command, first_app_with_binar def test_package_app_notarize_adhoc_signed_via_prompt( - package_command, first_app_with_binaries + package_command, + first_app_with_binaries, + adhoc_identity, ): """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.select_identity.return_value = adhoc_identity + # Package the app without code signing with pytest.raises( BriefcaseCommandError, @@ -258,17 +256,15 @@ def test_package_app_notarize_adhoc_signed_via_prompt( def test_package_app_adhoc_signed_via_prompt( - package_command, first_app_with_binaries, tmp_path + package_command, + first_app_with_binaries, + adhoc_identity, + 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.select_identity.return_value = adhoc_identity + package_command.package_app( first_app_with_binaries, notarize_app=False, @@ -277,7 +273,7 @@ def test_package_app_adhoc_signed_via_prompt( # A request has been made to sign the app package_command.sign_app.assert_called_once_with( app=first_app_with_binaries, - identity="-", + identity=adhoc_identity, ) # The DMG has been built as expected @@ -312,16 +308,20 @@ def test_package_app_adhoc_signed_via_prompt( # by calling sign_app() package_command.sign_file.assert_called_once_with( tmp_path / "base_path/dist/First App-0.0.1.dmg", - identity="-", + identity=adhoc_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): +def test_package_app_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( @@ -333,7 +333,7 @@ def test_package_app_adhoc_sign(package_command, first_app_with_binaries, tmp_pa # A request has been made to sign the app package_command.sign_app.assert_called_once_with( app=first_app_with_binaries, - identity="-", + identity=adhoc_identity, ) # The DMG has been built as expected @@ -368,7 +368,7 @@ def test_package_app_adhoc_sign(package_command, first_app_with_binaries, tmp_pa # by calling sign_app() package_command.sign_file.assert_called_once_with( tmp_path / "base_path/dist/First App-0.0.1.dmg", - identity="-", + identity=adhoc_identity, ) # No request was made to notarize @@ -376,10 +376,12 @@ def test_package_app_adhoc_sign(package_command, first_app_with_binaries, tmp_pa def test_package_app_adhoc_sign_default_notarization( - package_command, first_app_with_binaries, tmp_path + 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( @@ -390,7 +392,7 @@ def test_package_app_adhoc_sign_default_notarization( # A request has been made to sign the app package_command.sign_app.assert_called_once_with( app=first_app_with_binaries, - identity="-", + identity=adhoc_identity, ) # The DMG has been built as expected @@ -425,31 +427,33 @@ def test_package_app_adhoc_sign_default_notarization( # by calling sign_app() package_command.sign_file.assert_called_once_with( tmp_path / "base_path/dist/First App-0.0.1.dmg", - identity="-", + identity=adhoc_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" +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 = ( - "CAFEBEEF", - "Sekrit identity (DEADBEEF)", - ) + package_command.select_identity.return_value = sekrit_identity - # Package the app in app (not DMG) format - first_app_with_binaries.packaging_format = "app" + # 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="CAFEBEEF" + app=first_app_with_binaries, + identity=sekrit_identity, ) # A request has been made to notarize the app @@ -461,7 +465,7 @@ def test_package_bare_app(package_command, first_app_with_binaries, tmp_path): / "macos" / "app" / "First App.app", - team_id="DEADBEEF", + identity=sekrit_identity, ) # No dmg was built. @@ -504,19 +508,15 @@ def test_package_bare_app(package_command, first_app_with_binaries, tmp_path): ] -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" +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 = ( - "CAFEBEEF", - "Sekrit identity (DEADBEEF)", - ) + package_command.select_identity.return_value = sekrit_identity - # Package the app in app (not DMG) format, disabling notarization - first_app_with_binaries.packaging_format = "app" + # Package the app in zip (not DMG) format, disabling notarization package_command.package_app( first_app_with_binaries, notarize_app=False, @@ -525,7 +525,7 @@ def test_package_bare_app_no_notarization(package_command, first_app_with_binari # A request has been made to sign the app package_command.sign_app.assert_called_once_with( app=first_app_with_binaries, - identity="CAFEBEEF", + identity=sekrit_identity, ) # No request has been made to notarize the app diff --git a/tests/platforms/macOS/app/test_package__notarize.py b/tests/platforms/macOS/app/test_package__notarize.py index 794b83e6c..51b0ae7c1 100644 --- a/tests/platforms/macOS/app/test_package__notarize.py +++ b/tests/platforms/macOS/app/test_package__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 @@ -114,10 +119,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 +161,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 +170,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 +191,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 +255,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 +289,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 +330,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 +354,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 +393,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 +426,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 +454,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 +476,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 +501,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 +526,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 +580,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 +610,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 +638,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 +659,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 +684,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 +703,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/test_signing.py b/tests/platforms/macOS/app/test_signing.py index 79e2f7c32..3ed787810 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, deep=False, @@ -57,7 +57,7 @@ def sign_call( "codesign", filepath, "--sign", - identity, + identity.id, "--force", ] if entitlements: @@ -113,9 +113,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 @@ -133,9 +133,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 @@ -171,9 +171,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 @@ -191,13 +191,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 @@ -217,9 +212,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 @@ -227,13 +222,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( @@ -241,7 +242,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, ), @@ -255,7 +256,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 @@ -263,7 +270,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" @@ -276,7 +283,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, ) @@ -287,7 +298,13 @@ def test_sign_file_entitlements(dummy_command, verbose, tmp_path, capsys): @pytest.mark.parametrize("verbose", [True, False]) -def test_sign_file_deep_sign(dummy_command, verbose, tmp_path, capsys): +def test_sign_file_deep_sign( + dummy_command, + sekrit_identity, + verbose, + tmp_path, + capsys, +): """A file can be identified as needing a deep sign.""" if verbose: dummy_command.logger.verbosity = LogLevel.VERBOSE @@ -299,7 +316,8 @@ def test_sign_file_deep_sign(dummy_command, verbose, tmp_path, capsys): # Sign the file dummy_command.sign_file( - tmp_path / "base_path/random.file", identity="Sekrit identity (DEADBEEF)" + tmp_path / "base_path/random.file", + identity=sekrit_identity, ) # 2 attempt to codesign was made; the second enabled the deep argument. @@ -308,11 +326,13 @@ def test_sign_file_deep_sign(dummy_command, verbose, tmp_path, capsys): sign_call( tmp_path, tmp_path / "base_path/random.file", + identity=sekrit_identity, entitlements=False, ), sign_call( tmp_path, tmp_path / "base_path/random.file", + identity=sekrit_identity, entitlements=False, deep=True, ), @@ -330,7 +350,13 @@ def test_sign_file_deep_sign(dummy_command, verbose, tmp_path, capsys): @pytest.mark.parametrize("verbose", [True, False]) -def test_sign_file_deep_sign_failure(dummy_command, verbose, tmp_path, capsys): +def test_sign_file_deep_sign_failure( + dummy_command, + sekrit_identity, + verbose, + tmp_path, + capsys, +): """If deep signing fails, an error is raised.""" if verbose: dummy_command.logger.verbosity = LogLevel.VERBOSE @@ -347,7 +373,7 @@ def test_sign_file_deep_sign_failure(dummy_command, verbose, tmp_path, capsys): with pytest.raises(BriefcaseCommandError, match="Unable to deep 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 @@ -356,6 +382,7 @@ def test_sign_file_deep_sign_failure(dummy_command, verbose, tmp_path, capsys): sign_call( tmp_path, tmp_path / "base_path/random.file", + identity=sekrit_identity, entitlements=False, ), ], @@ -372,7 +399,13 @@ def test_sign_file_deep_sign_failure(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: @@ -386,7 +419,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 @@ -395,6 +428,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, ), ], @@ -411,7 +445,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: @@ -425,7 +465,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 @@ -434,6 +474,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, ), ], @@ -450,7 +491,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 @@ -461,7 +508,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 @@ -470,6 +517,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, ), ], @@ -482,14 +530,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 @@ -513,20 +569,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, ) @@ -558,7 +655,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 @@ -578,7 +681,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 index 0e9338eea..883dca3e4 100644 --- a/tests/platforms/macOS/test_SigningIdentity.py +++ b/tests/platforms/macOS/test_SigningIdentity.py @@ -22,6 +22,7 @@ def test_identity(identity_id, identity_name, team_id): assert identity.name == identity_name assert identity.team_id == team_id assert not identity.is_adhoc + assert repr(identity) == f"" @pytest.mark.parametrize( @@ -49,3 +50,4 @@ def test_adhoc_identity(): == "Ad-hoc identity. The resulting package will run but cannot be re-distributed." ) assert adhoc.is_adhoc + assert repr(adhoc) == "" From c28f51e6ac5ddd6cf464d21ebcb8726cd40e75e8 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 7 May 2024 17:00:06 +0800 Subject: [PATCH 06/23] Initial implementation of .pkg format generation. --- docs/reference/platforms/macOS/app.rst | 7 +- docs/reference/platforms/macOS/xcode.rst | 7 +- src/briefcase/platforms/macOS/__init__.py | 137 ++++++++++++++++++++-- tests/platforms/macOS/app/test_mixin.py | 8 ++ 4 files changed, 148 insertions(+), 11 deletions(-) diff --git a/docs/reference/platforms/macOS/app.rst b/docs/reference/platforms/macOS/app.rst index 0dbacde68..721ffd98a 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`` bundle: 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``). +2. A zipped ``.app`` folder (using ``briefcase package macOS -p zip``). +3. A ``.pkg`` installer (using ``briefcase package macOS -p pkg``). + +The ``.pkg`` format is the *required* format for command line apps. Icon format =========== diff --git a/docs/reference/platforms/macOS/xcode.rst b/docs/reference/platforms/macOS/xcode.rst index d00b8be04..bbda91a89 100644 --- a/docs/reference/platforms/macOS/xcode.rst +++ b/docs/reference/platforms/macOS/xcode.rst @@ -21,11 +21,14 @@ 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``). +2. A zipped ``.app`` folder (using ``briefcase package macOS Xcode -p zip``). +3. A ``.pkg`` installer (using ``briefcase package macOS Xcode -p pkg``). + +The ``.pkg`` format is the *required* format for command line apps. Icon format =========== diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py index 641113f5c..d195be84f 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 @@ -55,10 +56,7 @@ def is_adhoc(self): return self.id == "-" def __repr__(self): - if self.is_adhoc: - return "" - else: - return f"" + return f"" def __eq__(self, other): return isinstance(other, SigningIdentity) and self.id == other.id @@ -482,7 +480,7 @@ def sign_file( :param identity: The code signing identity to use. :param entitlements: The path to the entitlements file to use. """ - options = "runtime" if not identity.is_adhoc else None + options = "runtime" if identity.is_adhoc else None process_command = ["codesign", path, "--sign", identity.id, "--force"] if entitlements: @@ -615,7 +613,7 @@ class macOSPackageMixin(macOSSigningMixin): @property def packaging_formats(self): - return ["zip", "dmg"] + return ["zip", "dmg", "pkg"] @property def default_packaging_format(self): @@ -624,6 +622,8 @@ def default_packaging_format(self): def distribution_path(self, app): 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" @@ -655,7 +655,7 @@ def __init__(self, *args, **kwargs): # These are abstracted to enable testing without patching. self.dmgbuild = dmgbuild - def notarize(self, filename: Path, identity: SigningIdentity): + def notarize(self, filename, identity: SigningIdentity): """Notarize a file. Submits the file to Apple for notarization; if successful, staples the @@ -852,6 +852,13 @@ def package_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, @@ -883,6 +890,122 @@ def package_zip( 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(): + self.tools.shutil.copy( + license_file, + installer_path / "resources/LICENSE", + ) + 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. +""" + ) + + # 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, + ) + + # 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", + ] + ) + + # 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, + ] + ) + + if notarize_app: + self.logger.info( + f"Notarizing installer using team ID {identity.team_id}...", + prefix=app.app_name, + ) + self.notarize(dist_path, identity=identity) + def package_dmg( self, app: AppConfig, diff --git a/tests/platforms/macOS/app/test_mixin.py b/tests/platforms/macOS/app/test_mixin.py index 25ac3ef6a..f2ca94575 100644 --- a/tests/platforms/macOS/app/test_mixin.py +++ b/tests/platforms/macOS/app/test_mixin.py @@ -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 From b55182b3efe37038cc485b19c61498a11f9756bd Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 7 May 2024 17:11:20 +0800 Subject: [PATCH 07/23] Add removal changenote for macOS app format. --- changes/1781.removal.rst | 1 + docs/reference/configuration.rst | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 changes/1781.removal.rst 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/docs/reference/configuration.rst b/docs/reference/configuration.rst index 719f2ee51..4f94d1f43 100644 --- a/docs/reference/configuration.rst +++ b/docs/reference/configuration.rst @@ -241,7 +241,7 @@ on an app with a formal name of "My App" would remove: ~~~~~~~~~~~~~~~ A boolean describing if the app is a console app, or a GUI app. Defaults to ``False`` -(producing as GUI app). +(producing a GUI app). ``exit_regex`` ~~~~~~~~~~~~~~ From 480e5a0a406fafccddcf243969fd3da25e3fb02f Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 8 May 2024 10:55:14 +0800 Subject: [PATCH 08/23] Restructure packaging tests to make format-specific test modules. --- docs/reference/configuration.rst | 5 +- src/briefcase/platforms/macOS/__init__.py | 7 +- tests/platforms/macOS/app/package/__init__.py | 0 tests/platforms/macOS/app/package/conftest.py | 26 + .../test_notarize.py} | 0 .../macOS/app/package/test_package.py | 384 ++++++++ .../macOS/app/package/test_package_dmg.py | 296 ++++++ .../macOS/app/package/test_package_zip.py | 107 +++ tests/platforms/macOS/app/test_package.py | 864 ------------------ 9 files changed, 821 insertions(+), 868 deletions(-) create mode 100644 tests/platforms/macOS/app/package/__init__.py create mode 100644 tests/platforms/macOS/app/package/conftest.py rename tests/platforms/macOS/app/{test_package__notarize.py => package/test_notarize.py} (100%) create mode 100644 tests/platforms/macOS/app/package/test_package.py create mode 100644 tests/platforms/macOS/app/package/test_package_dmg.py create mode 100644 tests/platforms/macOS/app/package/test_package_zip.py delete mode 100644 tests/platforms/macOS/app/test_package.py diff --git a/docs/reference/configuration.rst b/docs/reference/configuration.rst index 4f94d1f43..b08c02ac0 100644 --- a/docs/reference/configuration.rst +++ b/docs/reference/configuration.rst @@ -240,8 +240,9 @@ on an app with a formal name of "My App" would remove: ``console_app`` ~~~~~~~~~~~~~~~ -A boolean describing if the app is a console app, or a GUI app. Defaults to ``False`` -(producing a GUI 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). ``exit_regex`` ~~~~~~~~~~~~~~ diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py index d195be84f..b3e8ffe87 100644 --- a/src/briefcase/platforms/macOS/__init__.py +++ b/src/briefcase/platforms/macOS/__init__.py @@ -56,7 +56,10 @@ def is_adhoc(self): return self.id == "-" def __repr__(self): - return f"" + if self.is_adhoc: + return "" + else: + return f"" def __eq__(self, other): return isinstance(other, SigningIdentity) and self.id == other.id @@ -480,7 +483,7 @@ def sign_file( :param identity: The code signing identity to use. :param entitlements: The path to the entitlements file to use. """ - options = "runtime" if identity.is_adhoc else None + options = "runtime" if not identity.is_adhoc else None process_command = ["codesign", path, "--sign", identity.id, "--force"] if entitlements: 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 100% rename from tests/platforms/macOS/app/test_package__notarize.py rename to tests/platforms/macOS/app/package/test_notarize.py 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..9b076b866 --- /dev/null +++ b/tests/platforms/macOS/app/package/test_package.py @@ -0,0 +1,384 @@ +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"] + assert package_command.default_packaging_format == "dmg" + + +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": "dmg", + "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_zip.py b/tests/platforms/macOS/app/package/test_package_zip.py new file mode 100644 index 000000000..d84f39eee --- /dev/null +++ b/tests/platforms/macOS/app/package/test_package_zip.py @@ -0,0 +1,107 @@ +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/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_package.py b/tests/platforms/macOS/app/test_package.py deleted file mode 100644 index 6bcd3d5e6..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 == ["zip", "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, sekrit_identity, tmp_path, capsys -): - """A macOS App can be packaged.""" - # 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_package_app_no_notarization( - package_command, - first_app_with_binaries, - sekrit_identity, - tmp_path, - capsys, -): - """A macOS App can be packaged 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_package_app_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() - - -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, - adhoc_identity, -): - """A macOS App cannot be notarized if ad-hoc signing is requested.""" - - 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_package_app_adhoc_signed_via_prompt( - package_command, - first_app_with_binaries, - adhoc_identity, - tmp_path, -): - """A macOS App cannot be notarized if ad-hoc signing is requested.""" - - package_command.select_identity.return_value = adhoc_identity - - 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=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_package_app_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_package_app_adhoc_sign_default_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_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/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 - - -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 - ) From 000f3128e6a256b92f585b55e22a0ff111ca8c00 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 8 May 2024 12:19:00 +0800 Subject: [PATCH 09/23] Make the default packaging format for macOS dependent on the app type. --- docs/reference/platforms/macOS/app.rst | 7 +-- docs/reference/platforms/macOS/xcode.rst | 7 +-- src/briefcase/platforms/macOS/__init__.py | 23 ++++++--- src/briefcase/platforms/macOS/app.py | 3 +- .../macOS/app/package/test_package.py | 50 ++++++++++++++++++- tests/platforms/macOS/app/test_build.py | 3 +- 6 files changed, 75 insertions(+), 18 deletions(-) diff --git a/docs/reference/platforms/macOS/app.rst b/docs/reference/platforms/macOS/app.rst index 721ffd98a..692b512ce 100644 --- a/docs/reference/platforms/macOS/app.rst +++ b/docs/reference/platforms/macOS/app.rst @@ -26,12 +26,13 @@ Packaging format Briefcase supports three packaging formats for a macOS ``.app`` bundle: -1. A DMG that contains the ``.app`` bundle (the default output of ``briefcase package - macOS``, or by using ``briefcase package macOS -p dmg``); or +1. A DMG that contains the ``.app`` bundle (using ``briefcase package macOS -p dmg``); + or 2. A zipped ``.app`` folder (using ``briefcase package macOS -p zip``). 3. A ``.pkg`` installer (using ``briefcase package macOS -p pkg``). -The ``.pkg`` format is the *required* format for command line apps. +The ``.pkg`` format is the *required* format for console apps. ``.dmg`` format is the +default format GUI apps. Icon format =========== diff --git a/docs/reference/platforms/macOS/xcode.rst b/docs/reference/platforms/macOS/xcode.rst index bbda91a89..ae152669e 100644 --- a/docs/reference/platforms/macOS/xcode.rst +++ b/docs/reference/platforms/macOS/xcode.rst @@ -23,12 +23,13 @@ Packaging format 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 +1. A DMG that contains the ``.app`` bundle (using ``briefcase package macOS Xcode -p + dmg``); or 2. A zipped ``.app`` folder (using ``briefcase package macOS Xcode -p zip``). 3. A ``.pkg`` installer (using ``briefcase package macOS Xcode -p pkg``). -The ``.pkg`` format is the *required* format for command line apps. +The ``.pkg`` format is the *required* format for console apps. ``.dmg`` format is the +default format GUI apps. Icon format =========== diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py index b3e8ffe87..a49c58d49 100644 --- a/src/briefcase/platforms/macOS/__init__.py +++ b/src/briefcase/platforms/macOS/__init__.py @@ -620,7 +620,8 @@ def packaging_formats(self): @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 == "zip": @@ -658,6 +659,19 @@ def __init__(self, *args, **kwargs): # These are abstracted to enable testing without patching. self.dmgbuild = dmgbuild + def verify_app(self, app): + super().verify_app(app) + + 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, identity: SigningIdentity): """Notarize a file. @@ -1002,13 +1016,6 @@ def package_pkg( ] ) - if notarize_app: - self.logger.info( - f"Notarizing installer using team ID {identity.team_id}...", - prefix=app.app_name, - ) - self.notarize(dist_path, identity=identity) - def package_dmg( self, app: AppConfig, diff --git a/src/briefcase/platforms/macOS/app.py b/src/briefcase/platforms/macOS/app.py index 263cf2b62..986e23a4d 100644 --- a/src/briefcase/platforms/macOS/app.py +++ b/src/briefcase/platforms/macOS/app.py @@ -13,6 +13,7 @@ ) from briefcase.config import AppConfig from briefcase.platforms.macOS import ( + SigningIdentity, macOSCreateMixin, macOSMixin, macOSPackageMixin, @@ -97,7 +98,7 @@ def build_app(self, app: AppConfig, **kwargs): # 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/tests/platforms/macOS/app/package/test_package.py b/tests/platforms/macOS/app/package/test_package.py index 9b076b866..19935d038 100644 --- a/tests/platforms/macOS/app/package/test_package.py +++ b/tests/platforms/macOS/app/package/test_package.py @@ -10,7 +10,53 @@ def test_package_formats(package_command): """Packaging formats are as expected.""" assert package_command.packaging_formats == ["zip", "dmg", "pkg"] - assert package_command.default_packaging_format == "dmg" + # 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): @@ -21,7 +67,7 @@ def test_no_notarize_option(package_command): "adhoc_sign": False, "identity": None, "notarize_app": False, - "packaging_format": "dmg", + "packaging_format": None, "update": False, } assert overrides == {} diff --git a/tests/platforms/macOS/app/test_build.py b/tests/platforms/macOS/app/test_build.py index dfd7db053..6b30d5385 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 @@ -30,7 +31,7 @@ def test_build_app(build_command, first_app_with_binaries): # 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 From a6b5f8a8ca6f19b4073f00983409019b14b1324a Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 8 May 2024 15:01:17 +0800 Subject: [PATCH 10/23] Pass BRIEFCASE_DEBUG to the runtime environment if in verbose mode. --- src/briefcase/commands/dev.py | 4 + src/briefcase/commands/run.py | 24 +- src/briefcase/integrations/flatpak.py | 30 ++- src/briefcase/platforms/linux/appimage.py | 48 ++-- src/briefcase/platforms/linux/flatpak.py | 40 +-- src/briefcase/platforms/linux/system.py | 52 ++-- src/briefcase/platforms/macOS/__init__.py | 4 +- src/briefcase/platforms/windows/__init__.py | 51 ++-- tests/commands/dev/test_get_environment.py | 16 ++ .../integrations/flatpak/test_Flatpak__run.py | 54 +++- tests/platforms/linux/appimage/test_run.py | 111 ++++++++- tests/platforms/linux/flatpak/test_run.py | 89 ++++++- tests/platforms/linux/system/test_run.py | 231 +++++++++++++++--- tests/platforms/macOS/app/test_run.py | 12 +- tests/platforms/windows/app/test_run.py | 111 ++++++++- 15 files changed, 722 insertions(+), 155 deletions(-) diff --git a/src/briefcase/commands/dev.py b/src/briefcase/commands/dev.py index 6cdc250d0..066eb8e14 100644 --- a/src/briefcase/commands/dev.py +++ b/src/briefcase/commands/dev.py @@ -188,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..15fd77e6f 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: 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..5237c0b32 100644 --- a/src/briefcase/platforms/linux/appimage.py +++ b/src/briefcase/platforms/linux/appimage.py @@ -379,25 +379,37 @@ 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.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..886fda880 100644 --- a/src/briefcase/platforms/linux/flatpak.py +++ b/src/briefcase/platforms/linux/flatpak.py @@ -213,7 +213,7 @@ 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 @@ -221,20 +221,32 @@ def run_app( if test_mode: kwargs = {"main_module": kwargs["env"]["BRIEFCASE_MAIN_MODULE"]} - # 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, - ) + # 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.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 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 e6cd5ea75..d42f4e714 100644 --- a/src/briefcase/platforms/linux/system.py +++ b/src/briefcase/platforms/linux/system.py @@ -792,7 +792,11 @@ class LinuxSystemRunCommand(LinuxSystemMixin, RunCommand): supported_host_os_reason = "Linux system projects can only be executed on Linux." def run_app( - self, app: AppConfig, test_mode: bool, passthrough: list[str], **kwargs + self, + app: AppConfig, + test_mode: bool, + passthrough: list[str], + **kwargs, ): """Start the application. @@ -801,26 +805,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) + 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.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 a49c58d49..90137b293 100644 --- a/src/briefcase/platforms/macOS/__init__.py +++ b/src/briefcase/platforms/macOS/__init__.py @@ -270,7 +270,7 @@ def run_console_app( :param passthrough: The list of arguments to pass to the app """ try: - kwargs = self._prepare_app_env(app=app, test_mode=False) + kwargs = self._prepare_app_kwargs(app=app, test_mode=False) # Start the app directly self.tools.subprocess.run( @@ -336,7 +336,7 @@ def run_gui_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( diff --git a/src/briefcase/platforms/windows/__init__.py b/src/briefcase/platforms/windows/__init__.py index c4ca1374b..4597b42b6 100644 --- a/src/briefcase/platforms/windows/__init__.py +++ b/src/briefcase/platforms/windows/__init__.py @@ -136,26 +136,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) - - # 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.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/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/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..a5216d4ee 100644 --- a/tests/platforms/linux/flatpak/test_run.py +++ b/tests/platforms/linux/flatpak/test_run.py @@ -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,8 @@ 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 with args.""" # 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 +66,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 +78,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 +89,79 @@ 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_test_mode(run_command, first_app_config): +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_console_app_with_passthrough(run_command, first_app_config): + """A console flatpak can be executed with args.""" + 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 +174,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 +186,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 +208,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..5b280fcf1 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 definitley 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/test_run.py b/tests/platforms/macOS/app/test_run.py index e28d77038..cda886d67 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 @@ -93,7 +93,9 @@ def test_run_gui_app_with_passthrough( 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_gui_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 @@ -327,7 +330,9 @@ def test_run_console_app_with_passthrough( first_app_config, tmp_path, ): - """A macOS console app can be started with args.""" + """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 @@ -344,6 +349,7 @@ def test_run_console_app_with_passthrough( [bin_path / "Contents/MacOS/First App", "foo", "--bar"], cwd=tmp_path / "home", check=True, + env={"BRIEFCASE_DEBUG": "1"}, ) # The log stream was not started diff --git a/tests/platforms/windows/app/test_run.py b/tests/platforms/windows/app/test_run.py index f4b97943b..3ab8164ab 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 @@ -139,8 +223,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 From 99469678063be3812bb166c1ca6709d9d034233d Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 9 May 2024 13:21:48 +0800 Subject: [PATCH 11/23] Tweaked the execution of console apps. --- src/briefcase/platforms/linux/appimage.py | 1 + src/briefcase/platforms/linux/flatpak.py | 1 + src/briefcase/platforms/linux/system.py | 1 + src/briefcase/platforms/macOS/__init__.py | 7 +++++-- src/briefcase/platforms/windows/__init__.py | 1 + tests/platforms/macOS/app/test_run.py | 8 ++++++-- 6 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/briefcase/platforms/linux/appimage.py b/src/briefcase/platforms/linux/appimage.py index 5237c0b32..0a302ec6a 100644 --- a/src/briefcase/platforms/linux/appimage.py +++ b/src/briefcase/platforms/linux/appimage.py @@ -385,6 +385,7 @@ def run_app( # 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, diff --git a/src/briefcase/platforms/linux/flatpak.py b/src/briefcase/platforms/linux/flatpak.py index 886fda880..7b68c27b5 100644 --- a/src/briefcase/platforms/linux/flatpak.py +++ b/src/briefcase/platforms/linux/flatpak.py @@ -225,6 +225,7 @@ def run_app( # 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, diff --git a/src/briefcase/platforms/linux/system.py b/src/briefcase/platforms/linux/system.py index d42f4e714..9fbae0d06 100644 --- a/src/briefcase/platforms/linux/system.py +++ b/src/briefcase/platforms/linux/system.py @@ -812,6 +812,7 @@ def run_app( # 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, diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py index 90137b293..6e40a2d4c 100644 --- a/src/briefcase/platforms/macOS/__init__.py +++ b/src/briefcase/platforms/macOS/__init__.py @@ -244,7 +244,6 @@ def run_app( # 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.run_console_app( app, passthrough=passthrough, @@ -273,16 +272,20 @@ def run_console_app( 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: - raise BriefcaseCommandError(f"Unable to start app {app.app_name}.") + # The command line app *could* returns an error code, which is entirely legal. + # Ignore any subprocess error here. + pass def run_gui_app( self, diff --git a/src/briefcase/platforms/windows/__init__.py b/src/briefcase/platforms/windows/__init__.py index 4597b42b6..9bf6e317e 100644 --- a/src/briefcase/platforms/windows/__init__.py +++ b/src/briefcase/platforms/windows/__init__.py @@ -142,6 +142,7 @@ def run_app( # 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, diff --git a/tests/platforms/macOS/app/test_run.py b/tests/platforms/macOS/app/test_run.py index cda886d67..d82c0dee2 100644 --- a/tests/platforms/macOS/app/test_run.py +++ b/tests/platforms/macOS/app/test_run.py @@ -319,6 +319,7 @@ def test_run_console_app(run_command, first_app_config, tmp_path): [bin_path / "Contents/MacOS/First App"], cwd=tmp_path / "home", check=True, + stream_output=False, ) # The log stream was not started @@ -349,6 +350,7 @@ def test_run_console_app_with_passthrough( [bin_path / "Contents/MacOS/First App", "foo", "--bar"], cwd=tmp_path / "home", check=True, + stream_output=False, env={"BRIEFCASE_DEBUG": "1"}, ) @@ -367,14 +369,16 @@ def test_run_console_app_failed(run_command, first_app_config, sleep_zero, tmp_p returncode=1, ) - with pytest.raises(BriefcaseCommandError): - run_command.run_app(first_app_config, test_mode=False, passthrough=[]) + # 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, ) From b84f2d89f618b81e2b43406a3cff3688bc1b77cc Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 9 May 2024 14:29:11 +0800 Subject: [PATCH 12/23] Add tests for pkg building. --- src/briefcase/platforms/macOS/__init__.py | 1 + tests/platforms/macOS/app/conftest.py | 42 +- .../macOS/app/package/test_package_pkg.py | 483 ++++++++++++++++++ 3 files changed, 494 insertions(+), 32 deletions(-) create mode 100644 tests/platforms/macOS/app/package/test_package_pkg.py diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py index 6e40a2d4c..139c9f382 100644 --- a/src/briefcase/platforms/macOS/__init__.py +++ b/src/briefcase/platforms/macOS/__init__.py @@ -926,6 +926,7 @@ def package_pkg( 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", diff --git a/tests/platforms/macOS/app/conftest.py b/tests/platforms/macOS/app/conftest.py index 9eed7e7e4..69a83e914 100644 --- a/tests/platforms/macOS/app/conftest.py +++ b/tests/platforms/macOS/app/conftest.py @@ -20,25 +20,11 @@ def adhoc_identity(): @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 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" @@ -58,13 +44,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, @@ -75,6 +55,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" @@ -83,15 +69,7 @@ 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" # Create some libraries that need to be signed. lib_path = app_path / "Contents/Resources/app_packages" 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..2db2b0266 --- /dev/null +++ b/tests/platforms/macOS/app/package/test_package_pkg.py @@ -0,0 +1,483 @@ +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", + ] + ), + 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", + ] + ), + ] + + +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", + ] + ), + 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", + ] + ), + ] + + +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", + ] + ), + 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", + ] + ), + ] + + +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", + ] + ), + 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", + ] + ), + ] + + +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", + ] + ), + 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", + ] + ), + ] From bb1dabf82264bdbdf080df31f91a9394d0690302 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 9 May 2024 14:58:08 +0800 Subject: [PATCH 13/23] Improve spacing in multiline comments. --- src/briefcase/platforms/macOS/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py index 139c9f382..38194d43c 100644 --- a/src/briefcase/platforms/macOS/__init__.py +++ b/src/briefcase/platforms/macOS/__init__.py @@ -457,6 +457,7 @@ def select_identity(self, identity: str | None = None) -> SigningIdentity: In the future, you could specify this signing identity by running: $ briefcase {self.command} macOS --adhoc-sign + """ ) else: @@ -469,6 +470,7 @@ def select_identity(self, identity: str | None = None) -> SigningIdentity: or $ briefcase {self.command} macOS -i "{identity_name}" + """ ) @@ -717,6 +719,7 @@ def notarize(self, filename, identity: SigningIdentity): You can store these credentials by invoking: $ xcrun notarytool store-credentials --team-id {identity.team_id} profile + """ ) From 5b6eb744abdb01e7d817ef8f32fe25b46e3cd408 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 10 May 2024 10:30:38 +0800 Subject: [PATCH 14/23] Correct handling of flatpak debug mode. --- src/briefcase/platforms/linux/flatpak.py | 2 ++ tests/platforms/linux/flatpak/test_run.py | 9 ++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/briefcase/platforms/linux/flatpak.py b/src/briefcase/platforms/linux/flatpak.py index 7b68c27b5..d996a23c7 100644 --- a/src/briefcase/platforms/linux/flatpak.py +++ b/src/briefcase/platforms/linux/flatpak.py @@ -220,6 +220,8 @@ def run_app( # 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 diff --git a/tests/platforms/linux/flatpak/test_run.py b/tests/platforms/linux/flatpak/test_run.py index a5216d4ee..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 @@ -50,7 +50,9 @@ def test_run_gui_app(run_command, first_app_config): def test_run_gui_app_with_passthrough(run_command, first_app_config): - """A GUI flatpak can be executed with args.""" + """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 @@ -115,7 +117,8 @@ def test_run_console_app(run_command, first_app_config): def test_run_console_app_with_passthrough(run_command, first_app_config): - """A console flatpak can be executed with args.""" + """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 From 0f3a4908b1892276f1c240353225792b2d4011dc Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 10 May 2024 14:25:34 +0800 Subject: [PATCH 15/23] Add a UUID5 template filter to generate GUIDs for Windows. --- src/briefcase/integrations/cookiecutter.py | 16 ++++++++++++++++ .../cookiecutter/test_UUIDExtension.py | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 tests/integrations/cookiecutter/test_UUIDExtension.py diff --git a/src/briefcase/integrations/cookiecutter.py b/src/briefcase/integrations/cookiecutter.py index 25c0e5868..8e7c32095 100644 --- a/src/briefcase/integrations/cookiecutter.py +++ b/src/briefcase/integrations/cookiecutter.py @@ -1,5 +1,7 @@ """Jinja2 extensions.""" +import uuid + from jinja2.ext import Extension @@ -116,3 +118,17 @@ def bool_attr(obj): return "true" if obj else "false" environment.filters["bool_attr"] = bool_attr + + +class UUIDExtension(Extension): + """Extensions for generating UUIDs.""" + + def __init__(self, environment): + """Initialize the extension with the given environment.""" + super().__init__(environment) + + def dns_uuid5(obj): + """A DNS-based UUID5 object generated from the provided content.""" + return str(uuid.uuid5(uuid.NAMESPACE_DNS, obj)) + + environment.filters["dns_uuid5"] = dns_uuid5 diff --git a/tests/integrations/cookiecutter/test_UUIDExtension.py b/tests/integrations/cookiecutter/test_UUIDExtension.py new file mode 100644 index 000000000..6be2ebd28 --- /dev/null +++ b/tests/integrations/cookiecutter/test_UUIDExtension.py @@ -0,0 +1,19 @@ +from unittest.mock import MagicMock + +import pytest + +from briefcase.integrations.cookiecutter import UUIDExtension + + +@pytest.mark.parametrize( + "value, expected", + [ + ("example.com", "cfbff0d1-9375-5685-968c-48ce8b15ae17"), + ("foobar.example.com", "941bbcd9-03e1-568a-a728-8434055bc338"), + ], +) +def test_dns_uuid5_value(value, expected): + env = MagicMock() + env.filters = {} + UUIDExtension(env) + assert env.filters["dns_uuid5"](value) == expected From 03ce00dcc6c2c0f943a061f7be3c4c2b57b4df75 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 10 May 2024 14:26:15 +0800 Subject: [PATCH 16/23] Use a different executable name for Windows console apps. --- src/briefcase/platforms/windows/__init__.py | 7 ++++++- tests/platforms/windows/app/test_run.py | 12 +++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/briefcase/platforms/windows/__init__.py b/src/briefcase/platforms/windows/__init__.py index 9bf6e317e..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" diff --git a/tests/platforms/windows/app/test_run.py b/tests/platforms/windows/app/test_run.py index 3ab8164ab..270c3a56d 100644 --- a/tests/platforms/windows/app/test_run.py +++ b/tests/platforms/windows/app/test_run.py @@ -126,7 +126,7 @@ def test_run_console_app(run_command, first_app_config, tmp_path): # 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"], + [tmp_path / "base_path/build/first-app/windows/app/src/first-app.exe"], cwd=tmp_path / "home", encoding="UTF-8", bufsize=1, @@ -153,7 +153,7 @@ def test_run_console_app_with_passthrough(run_command, first_app_config, tmp_pat # 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", + tmp_path / "base_path/build/first-app/windows/app/src/first-app.exe", "foo", "--bar", ], @@ -179,7 +179,7 @@ def test_run_console_app_failed(run_command, first_app_config, tmp_path): # 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"], + [tmp_path / "base_path/build/first-app/windows/app/src/first-app.exe"], cwd=tmp_path / "home", encoding="UTF-8", bufsize=1, @@ -204,8 +204,9 @@ def test_run_app_test_mode(run_command, first_app_config, is_console_app, tmp_pa 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, @@ -246,9 +247,10 @@ def test_run_app_test_mode_with_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", "foo", "--bar", ], From 4715144b7a9724d2a9af78d35ae3cc1a33aca76d Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 11 May 2024 10:41:15 +0800 Subject: [PATCH 17/23] Rename/strip the stub binary as part of the build step. --- changes/1729.bugfix.rst | 1 + src/briefcase/commands/base.py | 11 ++++++ src/briefcase/commands/run.py | 4 +- src/briefcase/platforms/macOS/app.py | 39 ++++++++++++++----- tests/platforms/macOS/app/conftest.py | 6 +++ .../macOS/app/package/test_notarize.py | 2 + .../macOS/app/package/test_package_zip.py | 2 + tests/platforms/macOS/app/test_build.py | 36 ++++++++++++++++- tests/platforms/macOS/app/test_create.py | 18 --------- 9 files changed, 88 insertions(+), 31 deletions(-) create mode 100644 changes/1729.bugfix.rst 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/src/briefcase/commands/base.py b/src/briefcase/commands/base.py index 160108308..5e7e59907 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/run.py b/src/briefcase/commands/run.py index 15fd77e6f..5746bec71 100644 --- a/src/briefcase/commands/run.py +++ b/src/briefcase/commands/run.py @@ -292,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/platforms/macOS/app.py b/src/briefcase/platforms/macOS/app.py index 986e23a4d..7599b1cb6 100644 --- a/src/briefcase/platforms/macOS/app.py +++ b/src/briefcase/platforms/macOS/app.py @@ -20,6 +20,7 @@ macOSRunMixin, macOSSigningMixin, ) +from briefcase.platforms.macOS.utils import AppPackagesMergeMixin class macOSAppMixin(macOSMixin): @@ -31,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." @@ -60,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) @@ -85,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): @@ -93,6 +95,23 @@ 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 M1 hardware - even if it's only an # ad-hoc signing identity. Apply an ad-hoc signing identity to the diff --git a/tests/platforms/macOS/app/conftest.py b/tests/platforms/macOS/app/conftest.py index 69a83e914..48b5ca6c5 100644 --- a/tests/platforms/macOS/app/conftest.py +++ b/tests/platforms/macOS/app/conftest.py @@ -22,6 +22,9 @@ def adhoc_identity(): def first_app_templated(first_app_config, tmp_path): 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", @@ -71,6 +74,9 @@ def first_app_templated(first_app_config, tmp_path): 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" + # 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" frameworks_path = app_path / "Contents/Frameworks" diff --git a/tests/platforms/macOS/app/package/test_notarize.py b/tests/platforms/macOS/app/package/test_notarize.py index 51b0ae7c1..989f31a9c 100644 --- a/tests/platforms/macOS/app/package/test_notarize.py +++ b/tests/platforms/macOS/app/package/test_notarize.py @@ -71,6 +71,8 @@ def test_notarize_app( "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/", diff --git a/tests/platforms/macOS/app/package/test_package_zip.py b/tests/platforms/macOS/app/package/test_package_zip.py index d84f39eee..c02372725 100644 --- a/tests/platforms/macOS/app/package/test_package_zip.py +++ b/tests/platforms/macOS/app/package/test_package_zip.py @@ -56,6 +56,8 @@ def test_package_zip( "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/", diff --git a/tests/platforms/macOS/app/test_build.py b/tests/platforms/macOS/app/test_build.py index 6b30d5385..364012653 100644 --- a/tests/platforms/macOS/app/test_build.py +++ b/tests/platforms/macOS/app/test_build.py @@ -23,11 +23,45 @@ 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, diff --git a/tests/platforms/macOS/app/test_create.py b/tests/platforms/macOS/app/test_create.py index 8fed84707..7a1a24e40 100644 --- a/tests/platforms/macOS/app/test_create.py +++ b/tests/platforms/macOS/app/test_create.py @@ -703,26 +703,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" @@ -777,12 +768,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", - ) From 4f384373b2a16e3d88993f7044c01db96e66f92d Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 11 May 2024 11:40:51 +0800 Subject: [PATCH 18/23] Rename the windows stub binary in the build step. --- src/briefcase/platforms/windows/app.py | 6 +++ tests/platforms/windows/app/conftest.py | 14 +++++ tests/platforms/windows/app/test_build.py | 63 +++++++++++++++-------- 3 files changed, 62 insertions(+), 21 deletions(-) create mode 100644 tests/platforms/windows/app/conftest.py 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/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( From 9969b4b18179a63abdc96d7ed912daf6350bd080 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 11 May 2024 20:56:48 +0800 Subject: [PATCH 19/23] Add bootstrap and automation bootstrap for console apps. --- automation/pyproject.toml | 1 + .../src/automation/bootstraps/console.py | 16 +++ pyproject.toml | 1 + src/briefcase/bootstraps/__init__.py | 1 + src/briefcase/bootstraps/console.py | 119 ++++++++++++++++ src/briefcase/bootstraps/pursuedpybear.py | 2 +- src/briefcase/bootstraps/pygame.py | 2 +- src/briefcase/bootstraps/pyside6.py | 2 +- tests/commands/new/test_build_context.py | 134 +++++++++++++++++- 9 files changed, 271 insertions(+), 7 deletions(-) create mode 100644 automation/src/automation/bootstraps/console.py create mode 100644 src/briefcase/bootstraps/console.py diff --git a/automation/pyproject.toml b/automation/pyproject.toml index b44b83fda..7804c7f29 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" "PursuedPyBear Automation" = "automation.bootstraps.pursuedpybear:PursuedPyBearAutomationBootstrap" 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/pyproject.toml b/pyproject.toml index a993f1d51..032d73505 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -132,6 +132,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" PursuedPyBear = "briefcase.bootstraps.pursuedpybear:PursuedPyBearGuiBootstrap" Pygame = "briefcase.bootstraps.pygame:PygameGuiBootstrap" diff --git a/src/briefcase/bootstraps/__init__.py b/src/briefcase/bootstraps/__init__.py index c344e33de..c75d91c60 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.pursuedpybear import PursuedPyBearGuiBootstrap # noqa: F401 from briefcase.bootstraps.pygame import PygameGuiBootstrap # noqa: F401 from briefcase.bootstraps.pyside6 import PySide6GuiBootstrap # 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/pursuedpybear.py b/src/briefcase/bootstraps/pursuedpybear.py index 9f968db4b..17bbd1a57 100644 --- a/src/briefcase/bootstraps/pursuedpybear.py +++ b/src/briefcase/bootstraps/pursuedpybear.py @@ -2,7 +2,7 @@ class PursuedPyBearGuiBootstrap(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/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/tests/commands/new/test_build_context.py b/tests/commands/new/test_build_context.py index 2fd7b49e7..1a46982d8 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, PursuedPyBearGuiBootstrap, PygameGuiBootstrap, PySide6GuiBootstrap, @@ -15,6 +16,7 @@ def mock_builtin_bootstraps(): return { "Toga": TogaGuiBootstrap, + "Console": ConsoleBootstrap, "PySide6": PySide6GuiBootstrap, "PursuedPyBear": PursuedPyBearGuiBootstrap, "Pygame": PygameGuiBootstrap, @@ -251,6 +253,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 + "5", # 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.""" @@ -773,7 +899,7 @@ def test_question_sequence_none(new_command): "grace@navy.mil", # author email "https://navy.mil/myapplication", # URL "4", # license - "5", # None + "6", # None ] context = new_command.build_context( @@ -926,7 +1052,7 @@ def test_question_sequence_with_bad_bootstrap_override( # Prime answers for none of the questions. new_command.input.values = [ - "6", # None + "7", # None ] class GuiBootstrap: @@ -1240,7 +1366,7 @@ def platform(self): "grace@navy.mil", # author email "https://navy.mil/myapplication", # URL "4", # license - "5", # Custom GUI bootstrap + "6", # Custom GUI bootstrap ] context = new_command.build_context(project_overrides={}) @@ -1306,7 +1432,7 @@ def platform(self): "grace@navy.mil", # author email "https://navy.mil/myapplication", # URL "4", # license - "5", # Custom GUI bootstrap + "6", # Custom GUI bootstrap ] context = new_command.build_context(project_overrides={}) From aff0c970333944fd2a443ecc4353c60aa2374a8c Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 30 May 2024 13:58:53 +0800 Subject: [PATCH 20/23] Apply suggestions from code review Co-authored-by: Malcolm Smith --- docs/reference/platforms/macOS/app.rst | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/reference/platforms/macOS/app.rst b/docs/reference/platforms/macOS/app.rst index 692b512ce..ed10ad44b 100644 --- a/docs/reference/platforms/macOS/app.rst +++ b/docs/reference/platforms/macOS/app.rst @@ -24,15 +24,14 @@ By default, apps will be both signed and notarized when they are packaged. Packaging format ================ -Briefcase supports three packaging formats for a macOS ``.app`` bundle: +Briefcase supports three packaging formats for a macOS app: -1. A DMG that contains the ``.app`` bundle (using ``briefcase package macOS -p dmg``); - or +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``). -The ``.pkg`` format is the *required* format for console apps. ``.dmg`` format is the -default format GUI apps. +``.pkg`` is the *required* format for console apps. ``.dmg`` is the +default format for GUI apps. Icon format =========== From b402e32ea579d3514969ab29108bfbaf0d9b600e Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 30 May 2024 13:53:56 +0800 Subject: [PATCH 21/23] Clarify the effect of enabling console_app --- docs/reference/configuration.rst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/reference/configuration.rst b/docs/reference/configuration.rst index 9d043db64..4b11b28b1 100644 --- a/docs/reference/configuration.rst +++ b/docs/reference/configuration.rst @@ -241,8 +241,11 @@ on an app with a formal name of "My App" would remove: ~~~~~~~~~~~~~~~ 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). +(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`` ~~~~~~~~~~~~~~ From ee6eebdd231f011c3110545fe79db4c487b4a0e3 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 3 Jun 2024 10:58:12 +0800 Subject: [PATCH 22/23] Normalize the Xcode docs. --- docs/reference/platforms/macOS/xcode.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/reference/platforms/macOS/xcode.rst b/docs/reference/platforms/macOS/xcode.rst index 4f919da66..18850b4ec 100644 --- a/docs/reference/platforms/macOS/xcode.rst +++ b/docs/reference/platforms/macOS/xcode.rst @@ -24,12 +24,12 @@ Packaging format Briefcase supports three packaging formats for a macOS Xcode project: 1. A DMG that contains the ``.app`` bundle (using ``briefcase package macOS Xcode -p - dmg``); or + dmg``). 2. A zipped ``.app`` folder (using ``briefcase package macOS Xcode -p zip``). 3. A ``.pkg`` installer (using ``briefcase package macOS Xcode -p pkg``). -The ``.pkg`` format is the *required* format for console apps. ``.dmg`` format is the -default format GUI apps. +``.pkg`` is the *required* format for console apps. ``.dmg`` is the +default format for GUI apps. Icon format =========== From 9c0f09c1a94b8b9cc4ce378c7b5736013f576151 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 5 Jun 2024 06:57:28 +0800 Subject: [PATCH 23/23] Ensure return status of subprocesses is checked. --- src/briefcase/platforms/macOS/__init__.py | 6 ++-- .../macOS/app/package/test_package_pkg.py | 30 ++++++++++++------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py index f6d857ea1..02e828c9c 100644 --- a/src/briefcase/platforms/macOS/__init__.py +++ b/src/briefcase/platforms/macOS/__init__.py @@ -1004,7 +1004,8 @@ def package_pkg( + install_args + [ installer_packages_path / f"{app.app_name}.pkg", - ] + ], + check=True, ) # Build package @@ -1019,7 +1020,8 @@ def package_pkg( "--resources", installer_path / "resources", dist_path, - ] + ], + check=True, ) def package_dmg( diff --git a/tests/platforms/macOS/app/package/test_package_pkg.py b/tests/platforms/macOS/app/package/test_package_pkg.py index 2db2b0266..5f8e400ed 100644 --- a/tests/platforms/macOS/app/package/test_package_pkg.py +++ b/tests/platforms/macOS/app/package/test_package_pkg.py @@ -90,7 +90,8 @@ def test_gui_app( "--install-location", "/Applications", bundle_path / "installer/packages/first-app.pkg", - ] + ], + check=True, ), mock.call( [ @@ -102,7 +103,8 @@ def test_gui_app( "--resources", bundle_path / "installer/resources", tmp_path / "base_path/dist/First App-0.0.1.pkg", - ] + ], + check=True, ), ] @@ -183,7 +185,8 @@ def test_gui_app_adhoc_identity( "--install-location", "/Applications", bundle_path / "installer/packages/first-app.pkg", - ] + ], + check=True, ), mock.call( [ @@ -195,7 +198,8 @@ def test_gui_app_adhoc_identity( "--resources", bundle_path / "installer/resources", tmp_path / "base_path/dist/First App-0.0.1.pkg", - ] + ], + check=True, ), ] @@ -260,7 +264,8 @@ def test_console_app( "--scripts", bundle_path / "installer/scripts", bundle_path / "installer/packages/first-app.pkg", - ] + ], + check=True, ), mock.call( [ @@ -272,7 +277,8 @@ def test_console_app( "--resources", bundle_path / "installer/resources", tmp_path / "base_path/dist/First App-0.0.1.pkg", - ] + ], + check=True, ), ] @@ -338,7 +344,8 @@ def test_console_app_adhoc_signed( "--scripts", bundle_path / "installer/scripts", bundle_path / "installer/packages/first-app.pkg", - ] + ], + check=True, ), mock.call( [ @@ -350,7 +357,8 @@ def test_console_app_adhoc_signed( "--resources", bundle_path / "installer/resources", tmp_path / "base_path/dist/First App-0.0.1.pkg", - ] + ], + check=True, ), ] @@ -466,7 +474,8 @@ def test_package_pkg_previously_built( "--install-location", "/Applications", bundle_path / "installer/packages/first-app.pkg", - ] + ], + check=True, ), mock.call( [ @@ -478,6 +487,7 @@ def test_package_pkg_previously_built( "--resources", bundle_path / "installer/resources", tmp_path / "base_path/dist/First App-0.0.1.pkg", - ] + ], + check=True, ), ]