Repository: pyinstaller/pyinstaller

Issue:
Fails to start on M1: `Python: mach-o, but wrong architecture`
Due to #5762 we had our PyInstaller pinned to 4.2 for a long time, for our next release we unpinned the version and used `4.5.1`.
From the documentation I expected everything to go well, since unless specified it should just build the `x86` version just as before.
However, users have quickly reported errors when trying to run our application on M1 Mac's:
```
mediencenter@Mac-mini ~ % /Applications/SABnzbd.app/Contents/MacOS/SABnzbd 
[25667] Error loading Python lib '/Applications/SABnzbd.app/Contents/MacOS/Python': dlopen: dlopen(/Applications/SABnzbd.app/Contents/MacOS/Python, 10): no suitable image found.  Did find:
	/Applications/SABnzbd.app/Contents/MacOS/Python: mach-o, but wrong architecture
	/Applications/SABnzbd.app/Contents/MacOS/Python: stat() failed with errno=1
```
We hypothesize this maybe has to do with `SABnzbd` being "fat" while `Python` is only `x84`?
```
sander@witte2004:/media/sander/SABnzbd-3.4.0/SABnzbd.app/Contents$ file ./MacOS/SABnzbd
./MacOS/SABnzbd: Mach-O universal binary with 2 architectures: 
[x86_64:Mach-O 64-bit x86_64 executable, flags:<NOUNDEFS|DYLDLINK|TWOLEVEL|PIE>] 
[arm64:Mach-O 64-bit arm64 executable, flags:<NOUNDEFS|DYLDLINK|TWOLEVEL|PIE>]


sander@witte2004:/media/sander/SABnzbd-3.4.0/SABnzbd.app/Contents$ file ./MacOS/Python
./MacOS/Python: Mach-O 64-bit x86_64 dynamically linked shared library, flags:<NOUNDEFS|DYLDLINK|TWOLEVEL|NO_REEXPORTED_DYLIBS>
```

I tried to force the `target_arg` (https://github.com/sabnzbd/sabnzbd/commit/c5422e848190c80e72de0c630ef6c1af048d38e1), but this still did not work and still results in the `SABnzbd` binary being "fat". Build result [here](https://github.com/sabnzbd/sabnzbd/actions/runs/1255094244).

Additionally, users of old macOS versions have reported that it no longer works on their older macOS (10.12) versions.
To freeze our application we use Github Actions but specifically fetch the python.org version of Python [so that it supports macOS 10.9+](https://github.com/sabnzbd/sabnzbd/blob/develop/.github/workflows/build_release.yml#L86-L90).

Just setting PyInstaller back to 4.2 "fixes" both these problems.

All our build-logs are available on Github Actions, I can link to specific build logs if desired:
https://github.com/sabnzbd/sabnzbd/actions/workflows/build_release.yml
Spec file: https://github.com/sabnzbd/sabnzbd/blob/develop/builder/SABnzbd.spec


Discussion:
> We hypothesize this maybe has to do with `SABnzbd` being "fat" while `Python` is only `x84`?

Indeed. This shouldn't happen as we thin out the bootloader executable to the target architecture. Or at least, we're supposed to... But now I see that part of the pipeline [is bypassed](https://github.com/pyinstaller/pyinstaller/blob/54083110106d2c20e45364d1f98457880dcbd31e/PyInstaller/building/api.py#L663) when external PKG (`append_pkg=False`) is used.

> Additionally, users of old macOS versions have reported that it no longer works on their older macOS (10.12) versions.

That's because in newer releases, we build the bootloaders [with MACOSX_DEPLOYMENT_TARGET set to 10.13](https://github.com/pyinstaller/pyinstaller/blob/54083110106d2c20e45364d1f98457880dcbd31e/bootloader/wscript#L471). So if you want to support older versions, you'll need to download the sdist (or clone tag from git), build the bootloaders with MACOSX_DEPLOYMENT_TARGET set to custom target, and install pyinstaller from that. 

Building bootloaders yourself should also allow you to side-step the issue with fat bootloaders, as building them with `--no-universal2` will give you an x86_64-only bootloader that does not need to be thinned (it will also not build fat bootloaders if the toolchain does not support that in the first place).
> Indeed. This shouldn't happen as we thin out the bootloader executable to the target architecture. Or at least, we're supposed to... But now I see that part of the pipeline is bypassed when external PKG (append_pkg=False) is used.

Ah yes, we could probably drop that and switch to using `MERGE` if I understand the documentation correctly.

> That's because in newer releases, we build the bootloaders with MACOSX_DEPLOYMENT_TARGET set to 10.13. So if you want to support older versions, you'll need to download the sdist (or clone tag from git), build the bootloaders with MACOSX_DEPLOYMENT_TARGET set to custom target, and install pyinstaller from that.

Isn't that something to put in the release notes? We can't be the only ones wanting to support older macOS versions..
> Isn't that something to put in the release notes? We can't be the only ones wanting to support older macOS versions..

The switch has supposedly already been done in 4.1:
> (OSX) Bump MACOSX_DEPLOYMENT_TARGET from 10.7 to 10.13. (#4627, #4886)

and since then, the main README does state that supported OSes are:
>  - Mac OS X 10.13 (High Sierra) or newer.

In practice, `MACOSX_DEPLOYMENT_TARGET` switch did not fully work (because it was overridden in several places), so pre-built bootloaders continued being built with old deployment target.
Oke, I missed that part of the README. Good to know and thus expected behavior. 

Resolving patch:
diff --git a/PyInstaller/building/api.py b/PyInstaller/building/api.py
--- a/PyInstaller/building/api.py
+++ b/PyInstaller/building/api.py
@@ -18,7 +18,6 @@
 import os
 import pprint
 import shutil
-import tempfile
 from operator import itemgetter
 
 from PyInstaller import HOMEPATH, PLATFORM
@@ -40,6 +39,9 @@
     from PyInstaller.utils.win32.winutils import set_exe_checksum
     from PyInstaller.utils.win32 import (icon, versioninfo, winmanifest, winresource)
 
+if is_darwin:
+    import PyInstaller.utils.osx as osxutils
+
 
 class PYZ(Target):
     """
@@ -589,7 +591,6 @@ def _bootloader_file(self, exe, extension=None):
     def assemble(self):
         from PyInstaller.config import CONF
         logger.info("Building EXE from %s", self.tocbasename)
-        trash = []
         if os.path.exists(self.name):
             os.remove(self.name)
         if not os.path.exists(os.path.dirname(self.name)):
@@ -598,20 +599,25 @@ def assemble(self):
         if not os.path.exists(exe):
             raise SystemExit(_MISSING_BOOTLOADER_ERRORMSG)
 
+        # Step 1: copy the bootloader file, and perform any operations that need to be done prior to appending the PKG.
+        logger.info("Copying bootloader EXE to %s", self.name)
+        self._copyfile(exe, self.name)
+        os.chmod(self.name, 0o755)
+
         if is_win:
-            fd, tmpnm = tempfile.mkstemp(prefix=os.path.basename(exe) + ".", dir=CONF['workpath'])
-            # We need to close the file, otherwise copying resources fails with "the file [...] is being used by
-            # another process"
-            os.close(fd)
-            self._copyfile(exe, tmpnm)
-            os.chmod(tmpnm, 0o755)
             # First, remove all resources from the file. This ensures that no manifest is embedded, even if bootloader
             # was compiled with a toolchain that forcibly embeds a default manifest (e.g., mingw toolchain from msys2).
-            winresource.RemoveAllResources(tmpnm)
+            winresource.RemoveAllResources(self.name)
+            # Embed icon.
             if self.icon != "NONE":
-                icon.CopyIcons(tmpnm, self.icon)
+                logger.info("Copying icon to EXE")
+                icon.CopyIcons(self.name, self.icon)
+            # Embed version info.
             if self.versrsrc:
-                versioninfo.SetVersion(tmpnm, self.versrsrc)
+                logger.info("Copying version information to EXE")
+                versioninfo.SetVersion(self.name, self.versrsrc)
+            # Embed other resources.
+            logger.info("Copying %d resources to EXE", len(self.resources))
             for res in self.resources:
                 res = res.split(",")
                 for i in range(1, len(res)):
@@ -631,12 +637,15 @@ def assemble(self):
                     reslang = res[3]
                 try:
                     winresource.UpdateResourcesFromResFile(
-                        tmpnm, resfile, [restype or "*"], [resname or "*"], [reslang or "*"]
+                        self.name, resfile, [restype or "*"], [resname or "*"], [reslang or "*"]
                     )
                 except winresource.pywintypes.error as exc:
                     if exc.args[0] != winresource.ERROR_BAD_EXE_FORMAT:
                         logger.error(
-                            "Error while updating resources in %s from resource file %s!", tmpnm, resfile, exc_info=1
+                            "Error while updating resources in %s from resource file %s!",
+                            self.name,
+                            resfile,
+                            exc_info=1
                         )
                         continue
 
@@ -652,37 +661,38 @@ def assemble(self):
                         )
                         continue
                     try:
-                        winresource.UpdateResourcesFromDataFile(tmpnm, resfile, restype, [resname], [reslang or 0])
+                        winresource.UpdateResourcesFromDataFile(self.name, resfile, restype, [resname], [reslang or 0])
                     except winresource.pywintypes.error:
                         logger.error(
                             "Error while updating resource %s %s in %s from data file %s!",
                             restype,
                             resname,
-                            tmpnm,
+                            self.name,
                             resfile,
                             exc_info=1
                         )
             # Embed the manifest into the executable.
             if self.embed_manifest:
-                self.manifest.update_resources(tmpnm, [1])
-            trash.append(tmpnm)
-            exe = tmpnm
-
-        # NOTE: Do not look up for bootloader file in the cache because it might get corrupted by UPX when UPX is
-        #       available. See #1863 for details.
+                logger.info("Emedding manifest in EXE")
+                self.manifest.update_resources(self.name, [1])
+        elif is_darwin:
+            # Convert bootloader to the target arch
+            logger.info("Converting EXE to target arch (%s)", self.target_arch)
+            osxutils.binary_to_target_arch(self.name, self.target_arch, display_name='Bootloader EXE')
 
+        # Step 2: append the PKG, if necessary
         if not self.append_pkg:
-            logger.info("Copying bootloader exe to %s", self.name)
-            self._copyfile(exe, self.name)
             # In onefile mode, copy the stand-alone pkg next to the executable. In onedir, this will be done by the
             # COLLECT() target.
             if not self.exclude_binaries:
                 pkg_dst = os.path.join(os.path.dirname(self.name), os.path.basename(self.pkgname))
-                logger.info("Copying archive %s to %s", self.pkg.name, pkg_dst)
+                logger.info("Copying stand-alone PKG archive from %s to %s", self.pkg.name, pkg_dst)
                 self._copyfile(self.pkg.name, pkg_dst)
+            else:
+                logger.info("Stand-alone PKG archive will be handled by COLLECT")
         elif is_linux:
-            self._copyfile(exe, self.name)
-            logger.info("Appending archive to ELF section in EXE %s", self.name)
+            # Linux: append PKG into ELF section using objcopy
+            logger.info("Appending PKG archive to ELF section in EXE")
             retcode, stdout, stderr = exec_command_all(
                 'objcopy', '--add-section', 'pydata=%s' % self.pkg.name, self.name
             )
@@ -694,17 +704,7 @@ def assemble(self):
             if retcode != 0:
                 raise SystemError("objcopy Failure: %s" % stderr)
         elif is_darwin:
-            import PyInstaller.utils.osx as osxutils
-
-            # Copy bootloader
-            logger.info("Copying bootloader exe to %s", self.name)
-            with open(self.name, 'wb') as outf:
-                with open(exe, 'rb') as inf:
-                    shutil.copyfileobj(inf, outf, length=64 * 1024)
-
-            # Convert bootloader to target arch
-            logger.info("Converting EXE to target arch (%s)", self.target_arch)
-            osxutils.binary_to_target_arch(self.name, self.target_arch, display_name='Bootloader EXE')
+            # macOS: remove signature, append PKG, and fix-up headers so that PKG appears to be part of the executable.
 
             # Strip signatures from all arch slices. Strictly speaking, we need to remove signature (if present) from
             # the last slice, because we will be appending data to it. When building universal2 bootloaders natively on
@@ -715,11 +715,27 @@ def assemble(self):
             logger.info("Removing signature(s) from EXE")
             osxutils.remove_signature_from_binary(self.name)
 
-            # Append the data
+            # Append the PKG data
+            logger.info("Appending PKG archive to EXE")
             with open(self.name, 'ab') as outf:
                 with open(self.pkg.name, 'rb') as inf:
                     shutil.copyfileobj(inf, outf, length=64 * 1024)
 
+            # Fix Mach-O header for code signing
+            logger.info("Fixing EXE headers for code signing")
+            osxutils.fix_exe_for_code_signing(self.name)
+        else:
+            # Fall back to just appending PKG at the end of the file
+            logger.info("Appending PKG archive to EXE")
+            with open(self.name, 'ab') as outf:
+                with open(self.pkg.name, 'rb') as inf:
+                    shutil.copyfileobj(inf, outf, length=64 * 1024)
+
+        # Step 3: post-processing
+        if is_win:
+            # Set checksum to appease antiviral software.
+            set_exe_checksum(self.name)
+        elif is_darwin:
             # If the version of macOS SDK used to build bootloader exceeds that of macOS SDK used to built Python
             # library (and, by extension, bundled Tcl/Tk libraries), force the version declared by the frozen executable
             # to match that of the Python library.
@@ -740,33 +756,14 @@ def assemble(self):
                 )
                 osxutils.set_macos_sdk_version(self.name, *pylib_version)
 
-            # Fix Mach-O header for codesigning on OS X.
-            logger.info("Fixing EXE for code signing %s", self.name)
-            osxutils.fix_exe_for_code_signing(self.name)
-
             # Re-sign the binary (either ad-hoc or using real identity, if provided).
             logger.info("Re-signing the EXE")
             osxutils.sign_binary(self.name, self.codesign_identity, self.entitlements_file)
-        else:
-            # Fall back to just append on end of file
-            logger.info("Appending archive to EXE %s", self.name)
-            with open(self.name, 'wb') as outf:
-                # write the bootloader data
-                with open(exe, 'rb') as infh:
-                    shutil.copyfileobj(infh, outf, length=64 * 1024)
-                # write the archive data
-                with open(self.pkg.name, 'rb') as infh:
-                    shutil.copyfileobj(infh, outf, length=64 * 1024)
-
-        if is_win:
-            # Set checksum to appease antiviral software.
-            set_exe_checksum(self.name)
 
+        # Ensure executable flag is set
         os.chmod(self.name, 0o755)
-        # get mtime for storing into the guts
+        # Get mtime for storing into the guts
         self.mtm = misc.mtime(self.name)
-        for item in trash:
-            os.remove(item)
         logger.info("Building EXE from %s completed successfully.", self.tocbasename)
 
     def _copyfile(self, infile, outfile):


Test patch: