Skip to content

Distributing a Python desktop app on Windows and Mac — the full release pipeline

WP Maintenance Manager ships from a single Python codebase to both Windows and macOS. “Python is cross-platform — write once, run anywhere,” the saying goes. The reality is that the distribution pipeline is completely separate per OS, each with its own pitfalls.

PyInstaller / Inno Setup / Apple Notarization / eSigner — the release cycle is a combination of OS-specific toolchains. Here’s the full picture, plus what to watch out for at each step. (The choice of internal architecture, Flask + browser UI, is covered separately in why we built a desktop app on local Flask + browser UI; this post is about distributing that architecture across two operating systems.)

The per-OS pipeline at a glance

Step Mac Windows
Build PyInstaller (--target-arch x86_64) PyInstaller
Distribution format .app bundle → .dmg folder → .exe installer
Installer creation hdiutil / create_dmg.sh Inno Setup (.iss script)
Code signing codesign + Developer ID certificate eSigner CSC (cloud signing)
Pre-distribution validation Apple Notarization SmartScreen reputation buildup
Final artifact WP_Maintenance_Pro_X.X.X.dmg WP_Maintenance_Pro_Setup_X.X.X.exe

Both OSes share PyInstaller, but the path diverges from there. Mac sits inside Apple’s review process; Windows runs through Microsoft’s reputation system. They’re fundamentally different ecosystems.

Mac — PyInstaller → sign → Notarization → DMG

The Intel / Apple Silicon trap

The first trap in Mac PyInstaller builds is architecture. Running pip install + python build_app.py on an Apple Silicon Mac without thinking produces native binaries (like cffi) for arm64 only — which then don’t run on Intel Macs at all.

The fix is to run the entire build through arch -x86_64:

arch -x86_64 pip3 install -r requirements.txt
arch -x86_64 python3 build_app.py

That produces an .app containing only x86_64 binaries, which runs natively on Intel Macs and through Rosetta 2 on Apple Silicon — a unified distribution.

Sign inside-out

The .app PyInstaller produces contains many Mach-O binaries internally (_cffi_backend.so from cryptography, etc.). The straightforward codesign --deep approach has known compatibility issues with Hardened Runtime, so we detect Mach-O binaries with the file command and sign them individually from the inside out:

find "${APP_BUNDLE}" -type f -exec file {} \; \
  | grep "Mach-O" | cut -d: -f1 \
  | while read bin; do
    codesign --force --options=runtime \
      --entitlements "${ENTITLEMENTS}" \
      --sign "${APP_CERT}" "${bin}"
  done

# Finally, sign the whole .app
codesign --force --options=runtime \
  --entitlements "${ENTITLEMENTS}" \
  --sign "${APP_CERT}" "${APP_BUNDLE}"

Notarize via ZIP, then build the DMG

There’s an empirical rule that submitting a ZIP for notarization is faster than submitting a DMG directly. Apple’s backend goes through a more complex DMG analysis path; ZIP rides a simpler scan path.

ditto -c -k --keepParent "${APP_BUNDLE}" "${ZIP_PATH}"
xcrun notarytool submit "${ZIP_PATH}" \
  --keychain-profile "wpmm-notary" --wait
xcrun stapler staple "${APP_BUNDLE}"
# Then build the DMG from the stapled .app

Notarization sometimes stalls at In Progress for days. The cause is often incomplete Apple Developer Program setup — missing Tax Forms or banking information — rather than anything technical. Contacting Apple support occasionally results in a batch of stuck submissions all flipping to Accepted at once. Surprisingly often, the blocker is contractual rather than technical.

Windows — PyInstaller → Inno Setup → eSigner CSC

The Inno Setup script

On Windows, WP_Maintenance_Pro.iss is the Inno Setup script that assembles the installer. User-mode installation (no admin required) into %APPDATA%, shortcut creation, residual-file cleanup on uninstall — all defined here:

[Setup]
AppId={{B3A7F2C1-4E8D-4A9F-B2C3-D5E6F7A8B9C0}
DefaultDirName={userappdata}\{#MyAppName}
PrivilegesRequired=lowest

PrivilegesRequired=lowest installs in user mode so people without admin rights — common in corporate environments — can still install the app. The visible drop in support tickets just from avoiding the UAC dialog is noticeable.

Cloud signing with eSigner CSC

Windows code signing traditionally requires a physical USB token holding the certificate. eSigner CSC (SSL.com’s cloud signing service) lets you sign from automation scripts without any token plugged in:

& "C:\esigner\CodeSignTool.bat" sign `
  -input_file_path "WP_Maintenance_Pro_Setup.exe" `
  -output_dir_path "signed/" `
  -credential_id "${CRED_ID}" `
  -username "${USERNAME}" -password "${PASSWORD}" `
  -totp_secret "${TOTP_SECRET}"

OV/EV certificate grade differences, and SmartScreen reputation buildup (you get “Unknown publisher” warnings until enough installs accumulate) are each topics of their own — but for “just getting it signed and shipping,” eSigner CSC automation is the practical path.

The cross-cutting challenge — version synchronization and reproducibility

The recurring headache on every release is version number synchronization. version.py, MyAppVersion in WP_Maintenance_Pro.iss, server/wpmm-web/version.json, the LP download links — four or more files all need to match per release. If one drifts, you get bugs like “the new version is live, but the in-app update check doesn’t notice.”

release.py exists as a single-shot updater, but as files get added over time the script needs to be kept in sync. A release checklist remains essential.

Reflection — “the same Python,” distributed completely differently

“Cross-platform” sounds simple, but distribution-side work splits cleanly per OS. Binary building can be unified through PyInstaller, but installers, code signing, and OS-side validation all live in separate ecosystems — you have to follow each one’s conventions.

That said, once the pipeline is built, new releases come out in about 30 minutes — python build_app.py + bash sign_and_notarize.sh + Inno Setup F9 and you’re done. High initial setup cost, but easy to spin afterward — that’s the lived experience of two-OS distribution.