Repository: beeware/briefcase

Issue:
Allow single-platform macOS apps
### What is the problem or limitation you are having?

With the addition of #1475, macOS apps are guaranteed to be universal, regardless of whether the wheels supplying binary components are universal.

However, there are legitimate use case for *not* producing universal binaries:
1. Concern over app size
2. Lack of availability of wheels on a given platform
3. A desire to drop x86_64 as a supported platform.

The last 2 points points in particular will only increase in frequency over time, as x86_64 macOS machines become increasingly uncommon.

Briefcase should allow an app to specify that it should *not* build a universal app.

### Describe the solution you'd like

A new option, `universal_build`, honored by the macOS backends. The default value is `True` for now; new apps templates should include an explicit value for this setting. At some point in the future, this default will be changed to `False`, and then the setting will be deprecated completely.

The current (post-#1475) behavior should continue if `universal_build==True`. If the user specifies `universal_build=False`, the older pre-#1475 pip install behavior should be restored (i.e., only wheels that match the current platform will be installed)

### Describe alternatives you've considered

Do nothing, and force all apps to be universal.

### Additional context

Updates to the [macOS App](https://github.com/beeware/briefcase-macOS-app-template/) and [macOS Xcode](https://github.com/beeware/briefcase-macOS-Xcode-template/) templates will also be required to set the new value explicitly.

Discussion:

Patch:
diff --git a/changes/1482.feature.rst b/changes/1482.feature.rst
new file mode 100644
index 00000000..c97d9193
--- /dev/null
+++ b/changes/1482.feature.rst
@@ -0,0 +1,1 @@
+macOS apps can now be configured to produce single platform binaries, or binaries that will work on both x86_64 and ARM64.
diff --git a/changes/1492.misc.rst b/changes/1492.misc.rst
new file mode 100644
index 00000000..9edfab30
--- /dev/null
+++ b/changes/1492.misc.rst
@@ -0,0 +1,1 @@
+The second binary wheel installation pass (for the other architecture) on macOS now requires binary wheels, disabling source compilation.
diff --git a/docs/reference/platforms/macOS/app.rst b/docs/reference/platforms/macOS/app.rst
index 6d866ece..f6863323 100644
--- a/docs/reference/platforms/macOS/app.rst
+++ b/docs/reference/platforms/macOS/app.rst
@@ -53,6 +53,21 @@ Do not submit the application for notarization. By default, apps will be
 submitted for notarization unless they have been signed with an ad-hoc
 signing identity.
 
+Application configuration
+=========================
+
+The following options can be added to the ``tool.briefcase.app.<appname>.macOS.app``
+section of your ``pyproject.toml`` file.
+
+``universal_build``
+~~~~~~~~~~~~~~~~~~~
+
+A Boolean, indicating whether Briefcase should build a universal app (i.e, an app that
+can target both x86_64 and ARM64). Defaults to ``true``; if ``false``, the binary will
+only be executable on the host platform on which it was built - i.e., if you build on
+an x86_64 machine, you will produce an x86_65 binary; if you build on an ARM64 machine,
+you will produce an ARM64 binary.
+
 Platform quirks
 ===============
 
diff --git a/docs/reference/platforms/macOS/xcode.rst b/docs/reference/platforms/macOS/xcode.rst
index 77c4e27b..49d756a0 100644
--- a/docs/reference/platforms/macOS/xcode.rst
+++ b/docs/reference/platforms/macOS/xcode.rst
@@ -59,6 +59,21 @@ Do not submit the application for notarization. By default, apps will be
 submitted for notarization unless they have been signed with an ad-hoc
 signing identity.
 
+Application configuration
+=========================
+
+The following options can be added to the ``tool.briefcase.app.<appname>.macOS.Xcode``
+section of your ``pyproject.toml`` file.
+
+``universal_build``
+~~~~~~~~~~~~~~~~~~~
+
+A Boolean, indicating whether Briefcase should build a universal app (i.e, an app that
+can target both x86_64 and ARM64). Defaults to ``true``; if ``false``, the binary will
+only be executable on the host platform on which it was built - i.e., if you build on
+an x86_64 machine, you will produce an x86_65 binary; if you build on an ARM64 machine,
+you will produce an ARM64 binary.
+
 Platform quirks
 ===============
 
diff --git a/src/briefcase/commands/create.py b/src/briefcase/commands/create.py
index af8b88fb..2b45d030 100644
--- a/src/briefcase/commands/create.py
+++ b/src/briefcase/commands/create.py
@@ -208,6 +208,8 @@ class CreateCommand(BaseCommand):
                 # Properties of the generating environment
                 # The full Python version string, including minor and dev/a/b/c suffixes (e.g., 3.11.0rc2)
                 "python_version": platform.python_version(),
+                # The host architecture
+                "host_arch": self.tools.host_arch,
                 # The Briefcase version
                 "briefcase_version": briefcase.__version__,
                 # Transformations of explicit properties into useful forms
@@ -445,19 +447,23 @@ class CreateCommand(BaseCommand):
     def _pip_install(
         self,
         app: AppConfig,
-        requires: list[str],
         app_packages_path: Path,
-        include_deps: bool = True,
-        **pip_kwargs: dict[str, str],
+        pip_args: list[str],
+        install_hint: str = "",
+        **pip_kwargs,
     ):
         """Invoke pip to install a set of requirements.
 
         :param app: The app configuration
-        :param requires: The list of requirements to install
         :param app_packages_path: The full path of the app_packages folder into which
             requirements should be installed.
-        :param progress_message: The waitbar progress message to display to the user.
-        :param pip_kwargs: Any additional keyword arguments to pass to the subprocess
+        :param pip_args: The list of arguments (including the list of requirements to
+            install) to pass to pip. This is in addition to the default arguments that
+            disable pip version checks, forces upgrades, and installs into the nominated
+            ``app_packages`` path.
+        :param install_hint: Additional hint information to provide in the exception
+            message if the pip install call fails.
+        :param pip_kwargs: Any additional keyword arguments to pass to ``subprocess.run``
             when invoking pip.
         """
         try:
@@ -476,21 +482,14 @@ class CreateCommand(BaseCommand):
                     "--no-user",
                     f"--target={app_packages_path}",
                 ]
-                + (
-                    [
-                        "--no-deps",
-                    ]
-                    if not include_deps
-                    else []
-                )
                 + self._extra_pip_args(app)
-                + requires,
+                + pip_args,
                 check=True,
                 encoding="UTF-8",
                 **pip_kwargs,
             )
         except subprocess.CalledProcessError as e:
-            raise RequirementsInstallError() from e
+            raise RequirementsInstallError(install_hint=install_hint) from e
 
     def _install_app_requirements(
         self,
@@ -520,8 +519,8 @@ class CreateCommand(BaseCommand):
             with self.input.wait_bar(progress_message):
                 self._pip_install(
                     app,
-                    requires=self._pip_requires(app, requires),
                     app_packages_path=app_packages_path,
+                    pip_args=self._pip_requires(app, requires),
                     **(pip_kwargs if pip_kwargs else {}),
                 )
         else:
diff --git a/src/briefcase/exceptions.py b/src/briefcase/exceptions.py
index bb12101b..c6ba1420 100644
--- a/src/briefcase/exceptions.py
+++ b/src/briefcase/exceptions.py
@@ -184,12 +184,12 @@ supported on {platform_name}. You will need to:
 
 
 class RequirementsInstallError(BriefcaseCommandError):
-    def __init__(self):
+    def __init__(self, install_hint=""):
         super().__init__(
-            """\
+            f"""\
 Unable to install requirements. This may be because one of your
 requirements is invalid, or because pip was unable to connect
-to the PyPI server.
+to the PyPI server.{install_hint}
 """
         )
 
diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py
index f1c7716c..8d7f2aa8 100644
--- a/src/briefcase/platforms/macOS/__init__.py
+++ b/src/briefcase/platforms/macOS/__init__.py
@@ -46,77 +46,106 @@ class macOSInstallMixin(AppPackagesMergeMixin):
         requires: list[str],
         app_packages_path: Path,
     ):
-        # Perform the initial install targeting the current platform
-        host_app_packages_path = (
-            self.bundle_path(app) / f"app_packages.{self.tools.host_arch}"
-        )
-        super()._install_app_requirements(
-            app,
-            requires=requires,
-            app_packages_path=host_app_packages_path,
-        )
-
-        # Find all the packages with binary components.
-        # We can ignore any -universal2 packages; they're already fat.
-        binary_packages = self.find_binary_packages(
-            host_app_packages_path,
-            universal_suffix="_universal2",
-        )
+        if getattr(app, "universal_build", True):
+            # Perform the initial install targeting the current platform
+            host_app_packages_path = (
+                self.bundle_path(app) / f"app_packages.{self.tools.host_arch}"
+            )
+            super()._install_app_requirements(
+                app,
+                requires=requires,
+                app_packages_path=host_app_packages_path,
+            )
 
-        # Now install dependencies for the architecture that isn't the host architecture.
-        other_arch = {
-            "arm64": "x86_64",
-            "x86_64": "arm64",
-        }[self.tools.host_arch]
+            # Find all the packages with binary components.
+            # We can ignore any -universal2 packages; they're already fat.
+            binary_packages = self.find_binary_packages(
+                host_app_packages_path,
+                universal_suffix="_universal2",
+            )
 
-        # Create a temporary folder targeting the other platform
-        other_app_packages_path = self.bundle_path(app) / f"app_packages.{other_arch}"
-        if other_app_packages_path.is_dir():
-            self.tools.shutil.rmtree(other_app_packages_path)
-        self.tools.os.mkdir(other_app_packages_path)
+            # Now install dependencies for the architecture that isn't the host architecture.
+            other_arch = {
+                "arm64": "x86_64",
+                "x86_64": "arm64",
+            }[self.tools.host_arch]
 
-        if binary_packages:
-            with self.input.wait_bar(
-                f"Installing binary app requirements for {other_arch}..."
-            ):
-                self._pip_install(
-                    app,
-                    requires=[
-                        f"{package}=={version}" for package, version in binary_packages
-                    ],
-                    app_packages_path=other_app_packages_path,
-                    include_deps=False,
-                    env={
-                        "PYTHONPATH": str(
-                            self.support_path(app)
-                            / "platform-site"
-                            / f"macosx.{other_arch}"
-                        )
-                    },
-                )
+            # Create a temporary folder targeting the other platform
+            other_app_packages_path = (
+                self.bundle_path(app) / f"app_packages.{other_arch}"
+            )
+            if other_app_packages_path.is_dir():
+                self.tools.shutil.rmtree(other_app_packages_path)
+            self.tools.os.mkdir(other_app_packages_path)
+
+            if binary_packages:
+                with self.input.wait_bar(
+                    f"Installing binary app requirements for {other_arch}..."
+                ):
+                    self._pip_install(
+                        app,
+                        app_packages_path=other_app_packages_path,
+                        pip_args=[
+                            "--no-deps",
+                            "--only-binary",
+                            ":all:",
+                        ]
+                        + [
+                            f"{package}=={version}"
+                            for package, version in binary_packages
+                        ],
+                        install_hint=f"""
+
+If an {other_arch} wheel has not been published for one or more of your requirements,
+you must compile those wheels yourself, or build a non-universal app by setting:
+
+    universal_build = False
+
+in the macOS configuration section of your pyproject.toml.
+""",
+                        env={
+                            "PYTHONPATH": str(
+                                self.support_path(app)
+                                / "platform-site"
+                                / f"macosx.{other_arch}"
+                            )
+                        },
+                    )
+            else:
+                self.logger.info("All packages are pure Python, or universal.")
+
+            # If given the option of a single architecture binary or a universal2 binary,
+            # pip will install the single platform binary. However, a common situation on
+            # macOS is for there to be an x86_64 binary and a universal2 binary. This means
+            # you only get a universal2 binary in the "other" install pass. This then causes
+            # problems with merging, because the "other" binary contains a copy of the
+            # architecture that the "host" platform provides.
+            #
+            # To avoid this - ensure that the libraries in the app packages for the "other"
+            # arch are all thin.
+            #
+            # This doesn't matter if it happens the other way around - if the "host" arch
+            # installs a universal binary, then the "other" arch won't be asked to install
+            # a binary at all.
+            self.thin_app_packages(other_app_packages_path, arch=other_arch)
+
+            # Merge the binaries
+            self.merge_app_packages(
+                target_app_packages=app_packages_path,
+                sources=[host_app_packages_path, other_app_packages_path],
+            )
         else:
-            self.logger.info("All packages are pure Python, or universal.")
-
-        # If given the option of a single architecture binary or a universal2 binary,
-        # pip will install the single platform binary. However, a common situation on
-        # macOS is for there to be an x86_64 binary and a universal2 binary. This means
-        # you only get a universal2 binary in the "other" install pass. This then causes
-        # problems with merging, because the "other" binary contains a copy of the
-        # architecture that the "host" platform provides.
-        #
-        # To avoid this - ensure that the libraries in the app packages for the "other"
-        # arch are all thin.
-        #
-        # This doesn't matter if it happens the other way around - if the "host" arch
-        # installs a universal binary, then the "other" arch won't be asked to install
-        # a binary at all.
-        self.thin_app_packages(other_app_packages_path, arch=other_arch)
-
-        # Merge the binaries
-        self.merge_app_packages(
-            target_app_packages=app_packages_path,
-            sources=[host_app_packages_path, other_app_packages_path],
-        )
+            # If we're not building a universal binary, we can do a single install pass
+            # directly into the app_packages folder.
+            super()._install_app_requirements(
+                app,
+                requires=requires,
+                app_packages_path=app_packages_path,
+            )
+
+            # Since we're only targeting 1 architecture, we can strip any universal
+            # libraries down to just the host architecture.
+            self.thin_app_packages(app_packages_path, arch=self.tools.host_arch)
 
 
 class macOSRunMixin:
diff --git a/src/briefcase/platforms/macOS/app.py b/src/briefcase/platforms/macOS/app.py
index 95ab4b21..ac99a544 100644
--- a/src/briefcase/platforms/macOS/app.py
+++ b/src/briefcase/platforms/macOS/app.py
@@ -59,6 +59,15 @@ class macOSAppCreateCommand(macOSAppMixin, macOSInstallMixin, CreateCommand):
                 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,
+                )
+
 
 class macOSAppUpdateCommand(macOSAppCreateCommand, UpdateCommand):
     description = "Update an existing macOS app."
diff --git a/src/briefcase/platforms/macOS/utils.py b/src/briefcase/platforms/macOS/utils.py
index 2e993a42..840de9fe 100644
--- a/src/briefcase/platforms/macOS/utils.py
+++ b/src/briefcase/platforms/macOS/utils.py
@@ -67,8 +67,8 @@ class AppPackagesMergeMixin:
 
         return binary_packages
 
-    def ensure_thin_dylib(self, path: Path, arch: str):
-        """Ensure that a library is thin, targeting a given architecture.
+    def ensure_thin_binary(self, path: Path, arch: str):
+        """Ensure that a binary is thin, targeting a given architecture.
 
         If the library is already thin, it is left as-is.
 
@@ -107,7 +107,7 @@ class AppPackagesMergeMixin:
                         )
                     except subprocess.CalledProcessError as e:
                         raise BriefcaseCommandError(
-                            f"Unable to create thin library from {path}"
+                            f"Unable to create thin binary from {path}"
                         ) from e
                     else:
                         # Having extracted the single architecture into a temporary
@@ -182,7 +182,7 @@ class AppPackagesMergeMixin:
                     futures = []
                     for path in dylibs:
                         future = executor.submit(
-                            self.ensure_thin_dylib,
+                            self.ensure_thin_binary,
                             path=path,
                             arch=arch,
                         )


Test patch:
diff --git a/tests/commands/create/test_generate_app_template.py b/tests/commands/create/test_generate_app_template.py
index 6cfe8ec9..cd9a5ce2 100644
--- a/tests/commands/create/test_generate_app_template.py
+++ b/tests/commands/create/test_generate_app_template.py
@@ -41,6 +41,7 @@ def full_context():
         "document_types": {},
         # Properties of the generating environment
         "python_version": platform.python_version(),
+        "host_arch": "gothic",
         "briefcase_version": briefcase.__version__,
         # Fields generated from other properties
         "module_name": "my_app",
diff --git a/tests/platforms/macOS/app/test_create.py b/tests/platforms/macOS/app/test_create.py
index d200aa3c..ab16fcc7 100644
--- a/tests/platforms/macOS/app/test_create.py
+++ b/tests/platforms/macOS/app/test_create.py
@@ -110,6 +110,8 @@ def test_install_app_packages(
                 "--no-user",
                 f"--target={bundle_path / ('app_packages.' + other_arch)}",
                 "--no-deps",
+                "--only-binary",
+                ":all:",
                 "second==1.2.3",
                 "third==3.4.5",
             ],
@@ -325,6 +327,8 @@ def test_install_app_packages_failure(create_command, first_app_templated, tmp_p
                 "--no-user",
                 f"--target={bundle_path / 'app_packages.x86_64'}",
                 "--no-deps",
+                "--only-binary",
+                ":all:",
                 "second==1.2.3",
                 "third==3.4.5",
             ],
@@ -348,17 +352,97 @@ def test_install_app_packages_failure(create_command, first_app_templated, tmp_p
     create_command.merge_app_packages.assert_not_called()
 
 
+@pytest.mark.parametrize(
+    "host_arch, other_arch",
+    [
+        ("arm64", "x86_64"),
+        ("arm64", "x86_64"),
+    ],
+)
+def test_install_app_packages_non_universal(
+    create_command,
+    first_app_templated,
+    tmp_path,
+    host_arch,
+    other_arch,
+):
+    """If the app is non-universal, only a single install pass occurs, followed by
+    thinning."""
+    bundle_path = tmp_path / "base_path" / "build" / "first-app" / "macos" / "app"
+
+    create_command.tools.host_arch = host_arch
+    first_app_templated.requires = ["first", "second==1.2.3", "third>=3.2.1"]
+    first_app_templated.universal_build = False
+
+    # Mock the find_binary_packages command so we can confirm it wasn't invoked.
+    create_command.find_binary_packages = mock.Mock()
+
+    # Mock the thin command so we can confirm it was invoked.
+    create_command.thin_app_packages = mock.Mock()
+
+    # Mock the merge command so we can confirm it wasn't invoked.
+    create_command.merge_app_packages = mock.Mock()
+
+    create_command.install_app_requirements(first_app_templated, test_mode=False)
+
+    # We didn't search for binary packages
+    create_command.find_binary_packages.assert_not_called()
+
+    # One request was made to install requirements
+    assert create_command.tools[first_app_templated].app_context.run.mock_calls == [
+        mock.call(
+            [
+                sys.executable,
+                "-u",
+                "-X",
+                "utf8",
+                "-m",
+                "pip",
+                "install",
+                "--disable-pip-version-check",
+                "--no-python-version-warning",
+                "--upgrade",
+                "--no-user",
+                f"--target={bundle_path / 'First App.app' / 'Contents' / 'Resources' / 'app_packages'}",
+                "first",
+                "second==1.2.3",
+                "third>=3.2.1",
+            ],
+            check=True,
+            encoding="UTF-8",
+        ),
+    ]
+
+    # An attempt was made to thin the app packages
+    create_command.thin_app_packages.assert_called_once_with(
+        bundle_path / "First App.app" / "Contents" / "Resources" / "app_packages",
+        arch=host_arch,
+    )
+
+    # No attempt was made to merge packages.
+    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"
@@ -415,3 +499,12 @@ def test_install_support_package(
 
     # The legacy content has been purged
     assert not (runtime_support_path / "python-stdlib" / "old-stdlib").exists()
+
+    # Only thin if this is a non-universal app
+    if universal_build:
+        create_command.ensure_thin_binary.assert_not_called()
+    else:
+        create_command.ensure_thin_binary.assert_called_once_with(
+            bundle_path / "First App.app" / "Contents" / "MacOS" / "First App",
+            arch="gothic",
+        )
diff --git a/tests/platforms/macOS/test_AppPackagesMergeMixin__ensure_thin_dylib.py b/tests/platforms/macOS/test_AppPackagesMergeMixin__ensure_thin_binary.py
similarity index 90%
rename from tests/platforms/macOS/test_AppPackagesMergeMixin__ensure_thin_dylib.py
rename to tests/platforms/macOS/test_AppPackagesMergeMixin__ensure_thin_binary.py
index a344b1f1..dd2742ed 100644
--- a/tests/platforms/macOS/test_AppPackagesMergeMixin__ensure_thin_dylib.py
+++ b/tests/platforms/macOS/test_AppPackagesMergeMixin__ensure_thin_binary.py
@@ -8,8 +8,8 @@ from ...utils import create_file, file_content
 
 
 @pytest.mark.parametrize("debug", [True, False])
-def test_thin_dylib(dummy_command, tmp_path, debug, capsys):
-    """A thin binary library is left as-is."""
+def test_thin_binary(dummy_command, tmp_path, debug, capsys):
+    """A thin binary is left as-is."""
     if debug:
         dummy_command.logger.verbosity = 1
 
@@ -21,8 +21,8 @@ def test_thin_dylib(dummy_command, tmp_path, debug, capsys):
         "Non-fat file: path/to/file.dylib is architecture: gothic\n"
     )
 
-    # Thin the library; this is effectively a no-op
-    dummy_command.ensure_thin_dylib(
+    # Thin the binary; this is effectively a no-op
+    dummy_command.ensure_thin_binary(
         tmp_path / "path" / "to" / "file.dylib",
         arch="gothic",
     )
@@ -67,8 +67,8 @@ def test_fat_dylib(dummy_command, tmp_path, debug, capsys):
 
     dummy_command.tools.subprocess.run.side_effect = thin_dylib
 
-    # Thin the library to the "gothic" architecture
-    dummy_command.ensure_thin_dylib(
+    # Thin the binary to the "gothic" architecture
+    dummy_command.ensure_thin_binary(
         tmp_path / "path" / "to" / "file.dylib",
         arch="gothic",
     )
@@ -117,12 +117,12 @@ def test_fat_dylib_arch_mismatch(dummy_command, tmp_path, debug, capsys):
         "Architectures in the fat file: path/to/file.dylib are: modern artdeco\n"
     )
 
-    # Thin the library to the "gothic" architecture. This will raise an exception
+    # Thin the binary to the "gothic" architecture. This will raise an exception
     with pytest.raises(
         BriefcaseCommandError,
         match=r"file\.dylib does not contain a gothic slice",
     ):
-        dummy_command.ensure_thin_dylib(
+        dummy_command.ensure_thin_binary(
             tmp_path / "path" / "to" / "file.dylib",
             arch="gothic",
         )
@@ -155,12 +155,12 @@ def test_fat_dylib_unknown_info(dummy_command, tmp_path, debug, capsys):
         "This is unexpected output...\n"
     )
 
-    # Thin the library to the "gothic" architecture. This will raise an exception
+    # Thin the binary to the "gothic" architecture. This will raise an exception
     with pytest.raises(
         BriefcaseCommandError,
         match=r"Unable to determine architectures in .*file\.dylib",
     ):
-        dummy_command.ensure_thin_dylib(
+        dummy_command.ensure_thin_binary(
             tmp_path / "path" / "to" / "file.dylib",
             arch="gothic",
         )
@@ -188,11 +188,11 @@ def test_lipo_info_fail(dummy_command, tmp_path):
         subprocess.CalledProcessError(cmd="lipo -info", returncode=-1)
     )
 
-    # Thin the library to the "gothic" architecture. This will raise an exception
+    # Thin the binary to the "gothic" architecture. This will raise an exception
     with pytest.raises(
         BriefcaseCommandError, match=r"Unable to inspect architectures in .*file\.dylib"
     ):
-        dummy_command.ensure_thin_dylib(
+        dummy_command.ensure_thin_binary(
             tmp_path / "path" / "to" / "file.dylib",
             arch="gothic",
         )
@@ -229,12 +229,12 @@ def test_lipo_thin_fail(dummy_command, tmp_path, debug, capsys):
         cmd="lipo -thin", returncode=-1
     )
 
-    # Thin the library to the "gothic" architecture. This will raise an exception
+    # Thin the binary to the "gothic" architecture. This will raise an exception
     with pytest.raises(
         BriefcaseCommandError,
-        match=r"Unable to create thin library from .*file.dylib",
+        match=r"Unable to create thin binary from .*file.dylib",
     ):
-        dummy_command.ensure_thin_dylib(
+        dummy_command.ensure_thin_binary(
             tmp_path / "path" / "to" / "file.dylib",
             arch="gothic",
         )
diff --git a/tests/platforms/macOS/test_AppPackagesMergeMixin__thin_app_packages.py b/tests/platforms/macOS/test_AppPackagesMergeMixin__thin_app_packages.py
index 26143fcf..0e30bb26 100644
--- a/tests/platforms/macOS/test_AppPackagesMergeMixin__thin_app_packages.py
+++ b/tests/platforms/macOS/test_AppPackagesMergeMixin__thin_app_packages.py
@@ -79,7 +79,7 @@ def test_thin_app_packages_problem(dummy_command, tmp_path):
     # Thin the app_packages folder to gothic dylibs. This raises an error:
     with pytest.raises(
         BriefcaseCommandError,
-        match=r"Unable to create thin library from .*module2\.so",
+        match=r"Unable to create thin binary from .*module2\.so",
     ):
         dummy_command.thin_app_packages(app_packages, arch="gothic")