Repository: pyinstaller/pyinstaller

Issue:
Code signing on macOS fails on PyQt6 with "bundle format unrecognized" after upgrade from v5.13 to v6.2
<!--
Welcome to the PyInstaller issue tracker! Before creating an issue, please heed the following:

1. This tracker should only be used to report bugs and request features / enhancements to PyInstaller
    - For questions and general support, use the discussions forum.
2. Use the search function before creating a new issue. Duplicates will be closed and directed to
   the original discussion.
3. When making a bug report, make sure you provide all required information. The easier it is for
   maintainers to reproduce, the faster it'll be fixed.
-->

<!-- +++ ONLY TEXT +++ DO NOT POST IMAGES +++ -->

## Description of the issue

Build fails while signing, ending with:

```
104417 INFO: Signing the BUNDLE...
115485 WARNING: Error while signing the bundle: codesign command (['codesign', '-s', '***', '--force', '--all-architectures', '--timestamp', '--options=runtime', '--entitlements', PosixPath('scripts/package/entitlements.plist'), '--deep', '/Users/runner/work/activitywatch/activitywatch/dist/ActivityWatch.app']) failed with error code 1!
output: /Users/runner/work/activitywatch/activitywatch/dist/ActivityWatch.app: replacing existing signature
/Users/runner/work/activitywatch/activitywatch/dist/ActivityWatch.app: bundle format unrecognized, invalid, or unsuitable
In subcomponent: /Users/runner/work/activitywatch/activitywatch/dist/ActivityWatch.app/Contents/Frameworks/PyQt6/Qt6/lib/QtPdf.framework

115486 WARNING: You will need to sign the bundle manually!
```

After upgrading PyInstaller from v5.13 to v6.2.

Perhaps the hooks need to be updated? Not sure why it suddenly has an issue with that file in `v6.2.0`.

 - PR: https://github.com/ActivityWatch/activitywatch/pull/976
 - CI run: https://github.com/ActivityWatch/activitywatch/actions/runs/6959043476/job/18935405327?pr=976

### Context information (for bug reports)

* Output of `pyinstaller --version`: 6.2.0
* Version of Python: 3.9
* Platform: macOS

I have skipped the extra steps in the issue template, which I found to not be relevant to this issue since the error clearly points out PyQt6 as the culprit, and I have a small PR with failing CI run that previously worked in v5.13.

I will not provide a minimal example program, due to a lack of time (and questionable value of the exercise in this case).


Discussion:
Starting with 6.x, we are trying to preserve the Qt .framework bundles structure, and somewhat in your build setup seems to be running afoul of that.

FWIW, trying to freeze a simple `import PyQt6.QtPdf` program seems to correctly collect `example.app/Contents/Frameworks/PyQt6/Qt6/lib/QtPdf.framework`, so I don't think this is a problem with PyQt6 per-se.

Can you try removing `MERGE` from the .spec file? You are generating onedir builds on all OSes, so `MERGE` makes no sense in that context - if you wanted a package with multiple executables and shared binaries/datas, you should create a single COLLECT with all EXEs, and all `datas` and `binaries` from all `Analysis` objects. 

But the way you are passing all the COLLECTs to BUNDLE, their TOCs should be merged and de-duplicated before collection without any explicit merging.

Also, could you enable `--log-level DEBUG` on the CI, so we can see detailed build log?

I'll try to recreate your build process locally/off-line and see if I can find something useful...
Also, can you try ensuring that PyQt6 and PyQt6-Qt6 are in sync? From [the build log](https://github.com/ActivityWatch/activitywatch/actions/runs/6959043476/job/18935405327?pr=976#step:13:1218):

> ```
> PyQt6==6.3.1
> PyQt6-Qt6==6.5.3
> PyQt6-sip==13.6.0
> PyQt6-stubs @ git+https://github.com/TilmanK/PyQt6-stubs@86d119ed202c1f9f86923563c2754cf93c1ec303
> ``` 

Resolving patch:
diff --git a/PyInstaller/building/api.py b/PyInstaller/building/api.py
--- a/PyInstaller/building/api.py
+++ b/PyInstaller/building/api.py
@@ -1181,6 +1181,7 @@ def __init__(self, *args):
             directory name and executable name (e.g., `myapp/myexecutable`).
         """
         self._dependencies = {}
+        self._symlinks = set()
 
         # Process all given (analysis, identifier, path_to_exe) tuples
         for analysis, identifier, path_to_exe in args:
@@ -1208,19 +1209,31 @@ def _process_toc(self, toc, path_to_exe):
         toc_refs = []
         for entry in toc:
             dest_name, src_name, typecode = entry
+
+            # Special handling and bookkeeping for symbolic links. We need to account both for dest_name and src_name,
+            # because src_name might be the same in different contexts. For example, when collecting Qt .framework
+            # bundles on macOS, there are multiple relative symbolic links `Current -> A` (one in each .framework).
+            if typecode == 'SYMLINK':
+                key = dest_name, src_name
+                if key not in self._symlinks:
+                    # First occurrence; keep the entry in "for-keep" TOC, same as we would for binaries and datas.
+                    logger.debug("Keeping symbolic link %r entry in original TOC.", entry)
+                    self._symlinks.add(key)
+                    toc_keep.append(entry)
+                else:
+                    # Subsequent occurrence; keep the SYMLINK entry intact, but add it to the references TOC instead of
+                    # "for-keep" TOC, so it ends up in `a.dependencies`.
+                    logger.debug("Moving symbolic link %r entry to references TOC.", entry)
+                    toc_refs.append(entry)
+                del key  # Block-local variable
+                continue
+
             if src_name not in self._dependencies:
                 logger.debug("Adding dependency %s located in %s", src_name, path_to_exe)
                 self._dependencies[src_name] = path_to_exe
                 # Add entry to list of kept TOC entries
                 toc_keep.append(entry)
             else:
-                # Keep SYMLINK entries intact (but add them to the references TOC instead of "for-keep" TOC, so they
-                # end up in `a.dependencies`).
-                if typecode == 'SYMLINK':
-                    logger.debug("Referenced dependency %s is symbolic link; keeping it intact!", src_name)
-                    toc_refs.append(entry)
-                    continue
-
                 # Construct relative dependency path; i.e., the relative path from this executable (or rather, its
                 # parent directory) to the executable that contains the dependency.
                 dep_path = os.path.relpath(self._dependencies[src_name], os.path.dirname(path_to_exe))


Test patch: