DEV Community

Cover image for Distributing a Python desktop app on Windows and Mac — the full release pipeline
Susumu Takahashi
Susumu Takahashi

Posted on • Originally published at en.wpmm.jp

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
Enter fullscreen mode Exit fullscreen mode

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}"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}"
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)