Repository: beeware/briefcase

Issue:
Code Signing Error
### Describe the bug

I'm using macbook pro 13inch(M1) with sonoma.
<img width="173" alt="image" src="https://github.com/beeware/briefcase/assets/98791104/fbd0c96d-36ef-4306-bfba-cbe06adc6c00">


When using **QtWebEngineCore** with briefcase, code signing failed.
I fixed manually line 538 of `brieacase/platforms/macOS/__init__.py` like below, and worked fine.

<img width="619" alt="image" src="https://github.com/beeware/briefcase/assets/98791104/b12c214b-650f-41c8-8c9f-23879486cb44">


I think we need additional option for **--deep** or just add into **process_command**.

### Steps to reproduce

1. run build command
```zsh
briefcase dev
```
2. raise exception(copy all log into logs section)

### Expected behavior

build complete and no other exceptions

### Screenshots

_No response_

### Environment

- Operating System: Mac M1(Sonoma)
- Python version: Python 3.11
- Software versions:
  - Briefcase: 0.3.19
  - PySide6: 6.7.2


### Logs

```
│ /Volumes/Dev/04_study_test/01_TEST/python/pyside6_webview_svelte/env/lib/python3.11/site-packages/briefcase/platforms/macOS/__init__.py:573 in sign_file                         │
│                                                                                                                                                                                  │
│    570 │   │   │   │   )                                                                                                                                                         │
│    571 │   │   │   │   return                                                                                                                                                    │
│    572 │   │   │   else:                                                                                                                                                         │
│ ❱  573 │   │   │   │   raise BriefcaseCommandError(f"Unable to code sign {path}.")                                                                                               │
│    574 │                                                                                                                                                                         │
│    575 │   def sign_app(self, app: AppConfig, identity: SigningIdentity):                                                                                                        │
│    576 │   │   """Sign an entire app with a specific identity.                                                                                                                   │
│                                                                                                                                                                                  │
│ ╭─────────────────────────────────────────────────────────────────────────────────── locals ───────────────────────────────────────────────────────────────────────────────────╮ │
│ │    entitlements = PosixPath('/Volumes/Dev/00_ongoing/SHLEE/templates/pyside6-svelte-template/build/pyside6-svelte-template/macos/app/Entitlements.plist')                    │ │
│ │          errors = '/Volumes/Dev/00_ongoing/SHLEE/templates/pyside6-svelte-template/build/pyside6-sv'+715                                                                     │ │
│ │        identity = <AdhocSigningIdentity>                                                                                                                                     │ │
│ │         options = None                                                                                                                                                       │ │
│ │            path = PosixPath('/Volumes/Dev/00_ongoing/SHLEE/templates/pyside6-svelte-template/build/pyside6-svelte-template/macos/app/PySide6 Webview                         │ │
│ │                   Template.app/Contents/Resources/app_packages/PySide6/Qt/lib/QtWebEngineCore.framework/Versions/A/QtWebEngineCore')                                         │ │
│ │ process_command = [                                                                                                                                                          │ │
│ │                   │   'codesign',                                                                                                                                            │ │
│ │                   │   PosixPath('/Volumes/Dev/00_ongoing/SHLEE/templates/pyside6-svelte-template/build/pyside6-svelte-template/macos/app/PySide6 Webview                     │ │
│ │                   Template.app/Contents/Resources/app_packages/PySide6/Qt/lib/QtWebEngineCore.framework/Versions/A/QtWebEngineCore'),                                        │ │
│ │                   │   '--sign',                                                                                                                                              │ │
│ │                   │   '-',                                                                                                                                                   │ │
│ │                   │   '--force',                                                                                                                                             │ │
│ │                   │   '--entitlements',                                                                                                                                      │ │
│ │                   │   PosixPath('/Volumes/Dev/00_ongoing/SHLEE/templates/pyside6-svelte-template/build/pyside6-svelte-template/macos/app/Entitlements.plist')                │ │
│ │                   ]                                                                                                                                                          │ │
│ │            self = <briefcase.platforms.macOS.app.macOSAppBuildCommand object at 0x106362190>                                                                                 │ │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
BriefcaseCommandError: Unable to code sign /Volumes/Dev/00_ongoing/SHLEE/templates/pyside6-svelte-template/build/pyside6-svelte-template/macos/app/PySide6 Webview Template.app/Contents/Resources/app_packages/PySide6/Qt/lib/QtWebEngineCore.framework/Versions/A/QtWebEngineCore.
```

### Additional context

_No response_


Discussion:
I was able to reproduce this error. Be sure that PySide-Addons is being packaged.

[briefcase.2024_06_30-10_13_00.package.log](https://github.com/user-attachments/files/16044976/briefcase.2024_06_30-10_13_00.package.log)

Regarding the specific suggestion: Briefcase *had* support for the `--deep` option; however, that option is officially deprecated by macOS, so it was removed recently (see #1221).

It would be interesting to see if moving the frameworks from the Resources folder to the Frameworks folder (which is where Frameworks *should* live) resolves the issue. If it does, a macOS processing step that moves frameworks wouldn't be too difficult to add.

Another possibility is to explicitly *not* sign Frameworks (on the basis that they *should* already be signed by the original distributor).
> I was able to reproduce this error. Be sure that PySide-Addons is being packaged.
> 
> [briefcase.2024_06_30-10_13_00.package.log](https://github.com/user-attachments/files/16044976/briefcase.2024_06_30-10_13_00.package.log)

<br><br>

I added full requirements in pyproject.toml like below,
<img width="254" alt="image" src="https://github.com/beeware/briefcase/assets/98791104/07443269-f48c-4f90-95fd-6b5acbe30ae8">


and check PySide6-Addons, PySide6-Esentials in app_packages directory,
<img width="353" alt="image" src="https://github.com/beeware/briefcase/assets/98791104/eac382e9-f0d7-4495-be9c-773b58ea06f6">

I think all fine, but same result.
Is anything wrong from my configures?
> Is anything wrong from my configures?

That's the correct configuration. I only mentioned `PySide-Addons` since it isn't installed, by default, when Briefcase creates a PySide app but it must be installed to recreate the error.
The issue appears to be related to the order of signing.

Inside the PySide6 package is an embedded framework named `QtWebEngineCore.framework`. It contains 5 signable files; in the current code, they are signed in this order:
```
Versions/A/QtWebEngineCore
Versions/A/Helpers/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess
Versions/A/Helpers/QtWebEngineProcess.app
Helpers/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess
Helpers/QtWebEngineProcess.app
```

If you sign in this order, you get the error:

```
In subcomponent: <redacted>/QtWebEngineCore.framework/Versions/A/Helpers/QtWebEngineProcess.app
```

However, if you sign QtWebEngine Core *last*:
```
Versions/A/Helpers/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess
Versions/A/Helpers/QtWebEngineProcess.app
Helpers/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess
Helpers/QtWebEngineProcess.app
Versions/A/QtWebEngineCore
```

The signing process succeeds. 

It appears this is the only part of the Qt library that has this problem - if you manually do this signing sequence, then re-build (which re-signs everything), the signing of the app succeeds. 

Signing is currently done by grouping on the parent directory after sorting in reverse order, then signing everything in that directory in parallel. However, because the file name `QtWebEngineCore` sorts lexically after the folder `Helpers`, the file is signed before all the sub folders. We need to modify this sort order so that all the subfolders in a directory are signed before any file in that directory is signed.



Patch:
diff --git a/automation/src/automation/bootstraps/pyside6.py b/automation/src/automation/bootstraps/pyside6.py
index 20e1ec110..ee48f9040 100644
--- a/automation/src/automation/bootstraps/pyside6.py
+++ b/automation/src/automation/bootstraps/pyside6.py
@@ -49,4 +49,15 @@ def main():
     app = QtWidgets.QApplication(sys.argv)
     main_window = {{{{ cookiecutter.class_name }}}}()
     sys.exit(app.exec())
+"""
+
+    def pyproject_table_macOS(self):
+        # Include PySide6-Addons on macOS, even though it isn't used, as a way to
+        # exercise signing of apps that include nested frameworks and apps. See #1891
+        # for details.
+        return """\
+universal_build = true
+requires = [
+    "PySide6-Addons~=6.5",
+]
 """
diff --git a/changes/1891.bugfix.rst b/changes/1891.bugfix.rst
new file mode 100644
index 000000000..06fdc41de
--- /dev/null
+++ b/changes/1891.bugfix.rst
@@ -0,0 +1,1 @@
+The order in which nested frameworks and apps are signed on macOS was corrected.
diff --git a/src/briefcase/integrations/file.py b/src/briefcase/integrations/file.py
index 3a6856da8..afdf71123 100644
--- a/src/briefcase/integrations/file.py
+++ b/src/briefcase/integrations/file.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import itertools
 import os
 import shutil
 import sys
@@ -7,6 +8,7 @@
 from contextlib import suppress
 from email.message import Message
 from pathlib import Path
+from typing import TYPE_CHECKING
 from urllib.parse import urlparse
 
 import requests.exceptions as requests_exceptions
@@ -19,6 +21,9 @@
 )
 from briefcase.integrations.base import Tool, ToolCache
 
+if TYPE_CHECKING:
+    from typing import Iterable, Sequence
+
 
 class File(Tool):
     name = "file"
@@ -34,6 +39,65 @@ def verify_install(cls, tools: ToolCache, **kwargs) -> File:
         tools.file = File(tools=tools)
         return tools.file
 
+    @classmethod
+    def sorted_depth_first(cls, paths: Sequence[Path]) -> Iterable[Path]:
+        """Sort a list of paths, so that they appear in lexical order, with
+        subdirectories of a folder sorting before files in the same directory.
+
+        :param paths: The list of paths to sort.
+        :returns: The sorted list of paths
+        """
+        # The sort key for a path is a triple - the parent of the path; whether the path
+        # is a directory or not; and finally, the actual path. We then sort this in
+        # reverse order.
+        #
+        # Sorting by the parent of the path first (in reverse order) guarantees that
+        # long paths are sorted first; so a file in a folder will come *after* the
+        # folder it is in.
+        #
+        # The second term (parent.is_dir()) guarantees that subfolders in a folder are
+        # found before files in the same folder (because in reverse order, booleans sort
+        # True before False).
+        #
+        # Lastly, we sort on the actual path itself. This provide a guaranteed lexical
+        # ordering inside any given folder. This isn't strictly required, but it's
+        # helpful for testing and reproducibility that the sort order is reliable and
+        # repeatable.
+        return sorted(paths, key=lambda p: (p.parent, p.is_dir(), p), reverse=True)
+
+    @classmethod
+    def sorted_depth_first_groups(
+        cls,
+        paths: Sequence[Path],
+    ) -> Iterable[Iterable[Path]]:
+        """Convert a list of paths into a collection of groups that are all at the same
+        "level", grouped depth-first.
+
+        Subfolders in a folder will be in a separate group, returned *before* files in
+        the same folder.
+
+        :param paths: The list of paths return grouped.
+        :returns: A generator returning a iterable groups of paths that are all at the
+            same sorting level.
+        """
+        # The sort function guarantees depth first ordering, with folders before files
+        # at the same level.
+        #
+        # The grouping key is based on the parent of the path (so that all objects in
+        # the same folder group together), with an additional discriminator to ensure
+        # that subfolders are in a different group to files.
+        #
+        # itertools.groupby guarantees a new group whenever the key changes; and the
+        # input sort order guarantees that directories come before files, so we get
+        # our desired sort grouping and ordering.
+        return (
+            group_paths
+            for _, group_paths in itertools.groupby(
+                cls.sorted_depth_first(paths),
+                lambda path: (path.parent, path.is_dir()),
+            )
+        )
+
     def is_archive(self, filename: str | os.PathLike) -> bool:
         """Can a file be unpacked via `shutil.unpack_archive()`?
 
diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py
index f0c7b8d5d..22ebb2195 100644
--- a/src/briefcase/platforms/macOS/__init__.py
+++ b/src/briefcase/platforms/macOS/__init__.py
@@ -1,7 +1,6 @@
 from __future__ import annotations
 
 import concurrent.futures
-import itertools
 import os
 import plistlib
 import re
@@ -601,32 +600,33 @@ def sign_app(self, app: AppConfig, identity: SigningIdentity):
         # Sign the bundle path itself
         sign_targets.append(bundle_path)
 
-        # Run signing through a ThreadPoolExecutor so that they run in parallel.
-        # However, we need to ensure that objects are signed from the inside out
-        # (i.e., a folder must be signed *after* all it's contents has been
-        # signed). To do this, we sort the list of signing targets in reverse
-        # lexicographic order, and then group all the signing targets by parent.
-        # This sorts all the signable files into folders; and sign all files in
-        # a folder before sorting the next group. This ensures that longer paths
-        # are signed first, and all files in a folder are signed before the
-        # folder is signed.
+        # Run signing through a ThreadPoolExecutor so that they run in parallel. We need
+        # to ensure that objects are signed from the inside out (i.e., a folder must be
+        # signed *after* all it's contents has been signed; files in a folder must be
+        # signed *after* all subfolders in that same folder); and an app that uses a
+        # library must be signed *after* all the libraries it uses. See
+        # https://developer.apple.com/documentation/xcode/creating-distribution-signed-code-for-the-mac#Determine-the-signing-order
+        # for details.
         #
-        # NOTE: We are relying on the fact that the final iteration order
-        # produced by groupby() reflects the order in which groups are found in
-        # the input data. The documentation for groupby() says that a new break
-        # is created every time a new group is found in the input data; sorting
-        # the input in reverse order ensures that only one group is found per folder,
-        # and that the deepest folder is found first.
+        # To do this, we utilize grouping on a sorted list, and rely on the fact that a
+        # new group is created whenever the grouping key changes. The sorting process
+        # guarantees depth-first ordering; the grouping is applied over the sorted
+        # content, ensuring that that folders are signed before files.
+        #
+        # This approach isn't perfect. It will fail if there's an embedded app that uses
+        # a library in a different framework, and the app is lexically sorted before the
+        # library (e.g. if app_packages/foobar/Alpha.framework/Helpers/My App.app
+        # depends on app_packages/footbar/Beta.framework/libbeta). However, if you're
+        # embedding apps in frameworks in Python libraries that are installed by pip,
+        # you're already making poor life choices; this approach is enough to satisfy
+        # the one use of app-in-framework embedding that we're aware of (PySide).
         progress_bar = self.input.progress_bar()
         task_id = progress_bar.add_task("Signing App", total=len(sign_targets))
         with progress_bar:
-            for _, names in itertools.groupby(
-                sorted(sign_targets, reverse=True),
-                lambda name: name.parent,
-            ):
+            for group in self.tools.file.sorted_depth_first_groups(sign_targets):
                 with concurrent.futures.ThreadPoolExecutor() as executor:
                     futures = []
-                    for path in names:
+                    for path in group:
                         future = executor.submit(
                             self.sign_file,
                             path,


Test patch:
diff --git a/tests/integrations/file/test_File__sorted_depth_first.py b/tests/integrations/file/test_File__sorted_depth_first.py
new file mode 100644
index 000000000..4859541dc
--- /dev/null
+++ b/tests/integrations/file/test_File__sorted_depth_first.py
@@ -0,0 +1,75 @@
+import pytest
+
+from briefcase.integrations.file import File
+
+from ...utils import create_file
+
+
+@pytest.mark.parametrize(
+    "files, sorted",
+    [
+        # Files in a directory are sorted lexically
+        (
+            ["foo/bar/a.txt", "foo/bar/c.txt", "foo/bar/b.txt"],
+            ["foo/bar/c.txt", "foo/bar/b.txt", "foo/bar/a.txt"],
+        ),
+        # Subfolders are sorted before files in that directory; but sorted lexically in themselves
+        (
+            [
+                "foo/bar/b",
+                "foo/bar/b/aaa.txt",
+                "foo/bar/b/zzz.txt",
+                "foo/bar/b/deeper",
+                "foo/bar/b/deeper/deeper_db2.txt",
+                "foo/bar/b/deeper/deeper_db1.txt",
+                "foo/bar/a.txt",
+                "foo/bar/c.txt",
+                "foo/bar/e.txt",
+                "foo/bar/d",
+                "foo/bar/d/deep_d2.txt",
+                "foo/bar/d/deep_d1.txt",
+            ],
+            [
+                "foo/bar/d/deep_d2.txt",
+                "foo/bar/d/deep_d1.txt",
+                "foo/bar/b/deeper/deeper_db2.txt",
+                "foo/bar/b/deeper/deeper_db1.txt",
+                "foo/bar/b/deeper",
+                "foo/bar/b/zzz.txt",
+                "foo/bar/b/aaa.txt",
+                "foo/bar/d",
+                "foo/bar/b",
+                "foo/bar/e.txt",
+                "foo/bar/c.txt",
+                "foo/bar/a.txt",
+            ],
+        ),
+        # If a folder contains both folders and files, the folders are returned first.
+        (
+            [
+                "foo/bar/a",
+                "foo/bar/b.txt",
+                "foo/bar/c",
+            ],
+            [
+                "foo/bar/c",
+                "foo/bar/a",
+                "foo/bar/b.txt",
+            ],
+        ),
+    ],
+)
+def test_sorted_depth_first(files, sorted, tmp_path):
+    # Convert the strings into paths in the temp folder
+    paths = [tmp_path / file_path for file_path in files]
+
+    # Create all the paths that have a suffix
+    for file_path in paths:
+        if file_path.suffix:
+            create_file(tmp_path / file_path, content=str(file_path))
+        else:
+            file_path.mkdir(parents=True, exist_ok=True)
+
+    assert File.sorted_depth_first(paths) == [
+        tmp_path / file_path for file_path in sorted
+    ]
diff --git a/tests/integrations/file/test_File__sorted_depth_first_groups.py b/tests/integrations/file/test_File__sorted_depth_first_groups.py
new file mode 100644
index 000000000..b73e07020
--- /dev/null
+++ b/tests/integrations/file/test_File__sorted_depth_first_groups.py
@@ -0,0 +1,78 @@
+import pytest
+
+from briefcase.integrations.file import File
+
+from ...utils import create_file
+
+
+@pytest.mark.parametrize(
+    "files, groups",
+    [
+        # Files in a single directory are sorted lexically into the same group
+        (
+            ["foo/bar/a.txt", "foo/bar/c.txt", "foo/bar/b.txt"],
+            [
+                ["foo/bar/c.txt", "foo/bar/b.txt", "foo/bar/a.txt"],
+            ],
+        ),
+        # Subfolders are sorted before files in that directory, and in a different
+        # group; but sorted lexically in themselves
+        (
+            [
+                "foo/bar/b",
+                "foo/bar/b/aaa.txt",
+                "foo/bar/b/zzz.txt",
+                "foo/bar/b/deeper",
+                "foo/bar/b/deeper/deeper_db2.txt",
+                "foo/bar/b/deeper/deeper_db1.txt",
+                "foo/bar/a.txt",
+                "foo/bar/c.txt",
+                "foo/bar/e.txt",
+                "foo/bar/d",
+                "foo/bar/d/deep_d2.txt",
+                "foo/bar/d/deep_d1.txt",
+            ],
+            [
+                ["foo/bar/d/deep_d2.txt", "foo/bar/d/deep_d1.txt"],
+                ["foo/bar/b/deeper/deeper_db2.txt", "foo/bar/b/deeper/deeper_db1.txt"],
+                ["foo/bar/b/deeper"],
+                ["foo/bar/b/zzz.txt", "foo/bar/b/aaa.txt"],
+                ["foo/bar/d", "foo/bar/b"],
+                ["foo/bar/e.txt", "foo/bar/c.txt", "foo/bar/a.txt"],
+            ],
+        ),
+        # If a folder contains both folders and files, the folders are in a different
+        # group the files, and are returned first.
+        (
+            [
+                "foo/bar/a",
+                "foo/bar/b.txt",
+                "foo/bar/c",
+            ],
+            [
+                [
+                    "foo/bar/c",
+                    "foo/bar/a",
+                ],
+                [
+                    "foo/bar/b.txt",
+                ],
+            ],
+        ),
+    ],
+)
+def test_sorted_depth_first_groups(files, groups, tmp_path):
+    # Convert the strings into paths in the temp folder
+    paths = [tmp_path / file_path for file_path in files]
+
+    # Create files for all paths that have a suffix; otherwise, ensure the folder
+    # exists.
+    for file_path in paths:
+        if file_path.suffix:
+            create_file(tmp_path / file_path, content=str(file_path))
+        else:
+            file_path.mkdir(parents=True, exist_ok=True)
+
+    assert list(list(group) for group in File.sorted_depth_first_groups(paths)) == [
+        [tmp_path / file_path for file_path in group] for group in groups
+    ]
diff --git a/tests/platforms/macOS/app/test_signing.py b/tests/platforms/macOS/app/test_signing.py
index c0e596b1c..f891c50c8 100644
--- a/tests/platforms/macOS/app/test_signing.py
+++ b/tests/platforms/macOS/app/test_signing.py
@@ -713,6 +713,6 @@ def _codesign(args, **kwargs):
         # coverage we need to. However, win32 doesn't handle executable permissions
         # the same as linux/unix, `unknown.binary` is identified as a signing target.
         # We ignore this discrepancy for testing purposes.
-        assert len(output.strip("\n").split("\n")) == (7 if verbose else 1)
+        assert len(output.strip("\n").split("\n")) == (9 if verbose else 1)
     else:
-        assert len(output.strip("\n").split("\n")) == (6 if verbose else 1)
+        assert len(output.strip("\n").split("\n")) == (8 if verbose else 1)