I built a desktop app in Python (PySide6 + MediaPipe + ONNX, ~33k lines) and shipped it to real beta users on macOS and Windows. Building the app took months. Getting it to install and run on other people's machines took weeks of fighting Apple's notarization, Windows antivirus heuristics, and browser download blocking — and almost none of it is documented in one place.
This is that one place. Everything below happened to me, with the exact errors and the exact fixes.
Part 1 — macOS: signing and notarization
Without Developer ID signing + notarization, your users get "App is damaged and can't be opened. You should move it to the Trash." That message alone kills your app for non-technical users.
The paperwork is easy: Apple Developer account ($99/year), create a Developer ID Application certificate, create an app-specific password, store it once with xcrun notarytool store-credentials. Then the actual pipeline is: codesign every binary with hardened runtime → zip → submit to Apple → staple the ticket. Simple in theory. Here are the three traps that each cost me hours.
Trap 1 — security find-identity says "0 valid identities" even though your certificate is right there
Certificate in the keychain ✅, private key in the keychain ✅, and yet:
$ security find-identity -v -p codesigning
0 valid identities found
The missing piece is Apple's intermediate certificate (Developer ID G2 CA). Without it the trust chain can't be built, so your perfectly valid certificate is treated as invalid. Nothing in the error tells you this.
Fix: download and import https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer.
The decisive test: codesign -d -vvv YourApp.app must show three authorities: Developer ID Application: … → Developer ID Certification Authority → Apple Root CA. If you see fewer, the chain is broken.
Trap 2 — Qt and Python framework binaries have NO file extension, so your signing loop silently skips them
If you bundle Qt (PySide6/PyQt) or the Python framework, the main binaries look like this:
QtCore.framework/Versions/A/QtCore ← no .dylib, no .so, no extension at all
Python.framework/Versions/3.11/Python ← same
Every signing script you'll find online does some variant of find . -name "*.dylib" -o -name "*.so" — which misses these files entirely. They stay ad-hoc signed, everything looks fine locally, and 15 minutes later Apple rejects your notarization with unsigned-binary errors buried in the log.
Two sub-traps stacked on top:
- Piping the file list through
xargs -I{}blows up with "command line cannot be assembled, too long" — because your signing identity string (Developer ID Application: Your Name (TEAMID)) is long. And if your script has a|| trueanywhere in that pipe, the failure is silently swallowed. -
codesign --verifyreports ad-hoc binaries as valid — verification checks integrity, not who signed. So a local "verify" pass proves nothing.
Fix: select binaries by their real file type, not their name, and use find -exec (no command-line length limit):
find "$APP/Contents" -type f -exec /bin/sh -c '
case "$(file -b "$1")" in
Mach-O*) codesign --force --options runtime --timestamp \
--sign "$IDENTITY" "$1" ;;
esac' _ {} \;
Then add a guard that checks the authority of every Mach-O after signing (grep codesign -dvv output for your Developer ID). A broken build should fail on your machine in 20 seconds, not on Apple's servers in 15 minutes.
Trap 3 — notarytool submit --wait exits 0 even when Apple REJECTS your app
This one is nasty. The exit code of xcrun notarytool submit --wait only means "the upload completed". Apple can answer status: Invalid and your script continues happily — mine then tried to staple a nonexistent ticket ("Record not found", error 65) and reported success on a rejected build.
Fix: never trust the exit code. Parse the text output:
SUB_OUT=$(xcrun notarytool submit "$ZIP" --keychain-profile PROFILE --wait 2>&1)
if echo "$SUB_OUT" | grep -q "status: Accepted"; then
xcrun stapler staple "$APP"
else
xcrun notarytool log <submission-id> --keychain-profile PROFILE # the real errors are here
exit 1
fi
Bonus trap — App Translocation breaks your auto-updater
Discovered with my very first real tester: if a user runs your app straight from the Downloads folder or a mounted DMG, macOS runs it from a randomized read-only location (App Translocation / Gatekeeper path randomization). My updater downloaded the new version, "installed" it, relaunched… the old version, downloaded again, forever. An infinite update loop that never happens on the developer's machine, because you never run your own app from quarantine. Detect the translocated path (/private/var/folders/.../AppTranslocation/...) and handle it (install to a fixed location outside the bundle, or tell the user to move the app to Applications).
Part 2 — Windows: your app is malware now
No notarization equivalent exists on Windows. Instead you get three layers of blocking, and they are not equally serious:
| Block | Can the user get past it? |
|---|---|
Browser: "unconfirmed download" (.crdownload) |
Yes: Ctrl+J → "…" → Keep |
| SmartScreen blue screen | Yes: "More info" → "Run anyway" |
| Antivirus quarantine | No. The file is deleted. There is no button. |
Only quarantine is truly fatal — and PyInstaller apps trigger it constantly. The reason is structural: a onefile PyInstaller exe unpacks a Python runtime into a temp folder and executes it, which is exactly what malware droppers do. Behavioral heuristics can't tell the difference.
What I tried, with results
Recompiling the PyInstaller bootloader from source (the standard advice — the stock bootloader's signature is in every AV database). Result: VirusTotal went from flagged to 2/70, Avast and Defender clean, real installs working. Victory?
Five days later, Avast quarantined the same app again under EvoGen, its evolving heuristic family. The installer ran to completion, placed 1253 files, then Avast silently removed the main exe and the install died with "CreateProcess failed; code 5. Access denied." Lesson: bootloader recompilation reduces your surface but is not durable — every new build is an unknown hash that can re-trigger heuristics at any time.
Adding VERSIONINFO metadata (publisher, description, copyright). I tested this properly: fresh build, metadata verified present, copied to the install location, waited, then ran it. It survived sitting on disk and got quarantined at execution — the detection is behavioral, not static. Metadata is free and correct to add, but it changes nothing here.
Azure Trusted Signing: dead end for individuals in France (identity validation not supported for FR individual accounts; and Microsoft requires a 3-year verifiable history even for organizations). Don't waste a day like I did.
EV certificates: since March 2024 Microsoft removed instant SmartScreen reputation for EV. They now build reputation exactly like standard OV certificates. Paying 300-700 €/year for EV buys you nothing anymore.
What actually works
A standard code signing certificate (I used Certum's cloud-based individual offering, ~100 €/year, available to individuals with just an ID and proof of address — no company needed). Signing doesn't remove the dropper-like behavior; it exempts you from the "rare + unsigned" treatment that heuristics hammer, and it gives you a stable identity on which download reputation accumulates. Unsigned, every release restarts from zero reputation.
One ordering trap: sign the app exe FIRST, then build the installer, then sign the installer. Installer builders (Inno, etc.) embed a copy of your files at compile time — signing the exe after building the setup leaves an unsigned copy inside the installer. Same for your auto-update archive: produce it after signing.
And distribute a ZIP, not a bare exe: Chromium-based browsers frequently hold naked exe downloads hostage in "unconfirmed" state, with the "Keep" option hidden behind a menu. They're far less aggressive with archives.
The checklist
macOS: Developer ID cert + G2 intermediate → sign every real Mach-O (by file -b type, find -exec, hardened runtime + timestamp) → verify authority, not just integrity → notarize and parse status: Accepted textually → staple → handle App Translocation in your updater.
Windows: OV code signing certificate (Certum works for EU individuals) → sign exe, then build installer, then sign installer → recompile the bootloader anyway (defense in depth) → add metadata (free) → ship a ZIP → expect SmartScreen until reputation builds.
I went through all of this shipping my own app, and I'm now setting this pipeline up for other Python/Qt desktop apps — fixed fee, async, everything over email. If you're stuck at the "it works on my machine but users can't install it" stage, or you'd want a tool that automates this end-to-end, email me: **shipyourpythonapp@gmail.com.
Top comments (0)