I set out to build my app.

Has your economy ever depended on shipping a legacy Python 2.7 application from 2011 that bundles Qt 4.6 targeting a 2025 macOS? See, I hate when that happens.

I knew systems would change. Apple, in particular, is one of those companies that will update a system after two years with a loud “Backwards compatibility my ass” scream and cause everything to break, then blame you about it. I expected friction on this quest. I did not expect the whole machine to start speaking in riddles.

DAFUQ #1: Xcode could not find the SDK I asked for.

Xcode only ships specific SDK versions. It does not do “close enough” on your behalf. If the build asks for macosx15.2 and the machine only has 15.4, it just stops. No grace period. No helpful suggestion. Just a hard refusal.

To see what was actually installed, I ran:

xcrun --sdk macosx --show-sdk-path
ls "$(xcrun --show-sdk-path | xargs dirname)"

The machine answered with the usual sort of insult:

MacOSX15.4.sdk

So the first fix was simple enough:

xcodebuild \
  -sdk macosx15.4 \
  ARCHS=x86_64 ONLY_ACTIVE_ARCH=NO \
  -project spideroak_osx_fsevents.xcodeproj

Nice. That should have been the annoying part. I had maybe ten seconds of hope, which was long enough for the next problem to arrive and ruin the mood.

DAFUQ #2: the frameworks were laid out like somebody lost a fight with symlinks.

macOS frameworks are fussy. They want a very specific structure:

MyFramework.framework/
  MyFramework -> Versions/Current/MyFramework
  Versions/
    Current -> A
    A/
      MyFramework

Some Qt 4-era distributions, and a few Python-packaged variants, cheat on that structure. They ship real files where symlinks should be, skip Versions/Current, or otherwise leave the framework looking technically present and structurally wrong.

That is enough to make codesign complain with:

bundle format is ambiguous (could be app or framework)

So I fixed the layout:

fix_framework() {
  fw="$1"
  name="$(basename "$fw" .framework)"
  cd "$fw" || return

  rm -f "$name"
  rm -rf Versions/Current

  ln -s 4 Versions/Current
  ln -s Versions/Current/$name "$name"
}

for fw in *.framework; do fix_framework "$fw"; done

The annoying part here is that the version number matters. 4 assumes Qt 4, so check the actual framework version before you start telling the filesystem what its childhood was like. And if the framework is more broken than this, you may need to rebuild Resources, Headers, and the rest.

That fix felt like progress. The app made it a little further on the next run, which is how I knew I was in trouble.

Then I launched it again and got a new surprise.

DAFUQ #3: SVG icons vanished into blank boxes.

Very soothing. Very normal. Nothing suspicious about that at all.

Qt loads plugins dynamically through QCoreApplication::libraryPaths(). PyQt4 installs plugin paths in places that are not always in that list by default, especially on custom Python installs. So the app can start, the UI can load, and then half the icons turn into empty boxes like they were offended by the whole project.

The missing pieces were the usual ghosts:

  • imageformats/libqsvg.dylib
  • iconengines/libqsvgicon.dylib

The quick fix is to point Qt at the plugin directory:

export QT_PLUGIN_PATH=/opt/so2.7/plugins

Or set it before you create the application:

import os
from PyQt4.QtGui import QApplication

os.environ["QT_PLUGIN_PATH"] = "/opt/so2.7/plugins"

app = QApplication([])
print([str(p) for p in QApplication.libraryPaths()])

If you’re packaging this thing, the least cursed option is to bundle the plugins inside the app:

YourApp.app/
  Contents/
    PlugIns/
      imageformats/libqsvg.dylib
      iconengines/libqsvgicon.dylib

Then make sure Qt can actually see that directory at runtime. Because of course it can’t just infer that you meant the obvious thing.

By now the app felt haunted. Every time I fixed one thing, another piece of the machine complained like I had personally offended its ancestors.

DAFUQ #4: codesign cares deeply about the order of operations.

At that point the app was alive enough to deserve a signature, which is when codesign joined the party.

This was the part where I learned that signing is not a single action. It is a ritual.

You sign from the inside out:

  1. inner Mach-O binaries
  2. frameworks
  3. the .app

If you sign the outer bundle first and then touch anything nested inside it, the outer signature becomes invalid. It is extremely unforgiving and, in fairness, internally consistent.

The signing pass looked like this:

# 1. Sign binaries, if needed.
find YourApp.app -type f -perm +111 -exec codesign -f -s "Developer ID Application: Your Corp" {} \;

# 2. Sign frameworks.
find YourApp.app/Contents/Frameworks -name "*.framework" \
  -exec codesign -f -s "Developer ID Application: Your Corp" \
  --preserve-metadata=identifier,entitlements {} \;

# 3. Sign the app.
codesign -f -s "Developer ID Application: Your Corp" \
  --deep --options runtime YourApp.app

And then the usual verification:

codesign -vvv --deep --strict YourApp.app
spctl -a -t exec -vvv YourApp.app
otool -L YourApp.app/Contents/MacOS/YourBinary

A few things became very clear:

  • --deep helps, but it is not magic.
  • Hardened runtime, via --options runtime, matters for notarization.
  • Every nested executable has to be signed correctly.
  • Any change after signing breaks the whole thing.

By this point I stopped calling it “packaging” and started calling it “domesticating a very angry bundle.”

Fine. The thing signs now, so maybe we can package it like a normal adult project.

Creating the DMG is the easy-looking part:

hdiutil create -fs HFS+ \
  -srcfolder YourApp.app \
  -volname "YourCoolApp" \
  YourCoolApp.dmg

That only gets you a disk image. It does not mean the app is blessed, trusted, or allowed to exist in polite company.

For that, modern macOS wants notarytool:

codesign --options runtime -s "Developer ID Application: ..." YourApp.app

xcrun notarytool submit YourCoolApp.dmg \
  --apple-id ... \
  --team-id ... \
  --password ... \
  --wait

xcrun stapler staple YourCoolApp.dmg

A few notes from the bleeding edge:

  • notarytool replaced altool.
  • Stapling is not ceremonial. It lets Gatekeeper verify the notarization without phoning home at launch time.
  • If notarization fails, read the JSON log. It will be rude, but usually correct.

Eventually the app did ship.

And once the smoke cleared, the whole adventure boiled down to a few very unpoetic truths:

  • SDK versions are not suggestions.
  • Framework layout matters more than your mood.
  • Qt plugins will disappear if you do not babysit them.
  • codesign is deterministic. You are not.

If your business depends on shipping a Python 2.7 + Qt4 app in 2025, you are not really maintaining software.

You are preserving a historical artifact under active hostile conditions.

The legacy Python 2.7 application from 2011 targeting a 2025 macOS did ship. See, I hate when that happens.