The jump from CLI tool to Mac App Store feels impossible. It's not. You can ship a signed, auto-updating desktop app in a week if you make the right architectural choices upfront.
FlowDoc's desktop journey started with a reality check. Hand the v0.10 CLI to someone who's never touched a terminal. They couldn't get past the install chain: Node, ffmpeg, Python venv, torch, a 3GB model download, Playwright Chromium. The tool worked perfectly for developers. It was unusable for everyone else.
The tempting answer was "host it as a web app." Wrong direction. A hosted browser can't drive another browser through enterprise apps or record the user's mic. The right shape was a packaged Mac app with zero manual installs.
The desktop app is the CLI
The smallest possible architecture: Electron boots the same HTTP server that flowdoc ui runs and loads it in a window. The server spawns the compiled CLI as a child for every command. One codebase, two distribution methods.
This decision saved the entire project. A fix in capture logic or site generation reaches both CLI and desktop app at once. The desktop app adds a window, signing pipeline, and 30MB of glue. Everything else was already built.
Two filesystem roots matter when you package:
- appRoot: where your compiled code lives (read only inside the .app bundle)
- dataRoot: where flows are written (writable, survives auto updates)
Never assume they're the same directory.
Code signing: the part you learn by doing
Apple's documentation makes signing sound straightforward. Reality has sharp edges you only discover by running into them.
Create a "Developer ID Application" certificate through developer.apple.com, not Xcode's dropdown. Generate the CSR in Keychain Access. When the cert appears in your keychain but security find-identity -v -p codesigning returns zero valid identities, you're missing the intermediate CA. Fetch DeveloperIDG2CA.cer from Apple's certificate authority page and import it. Your identity will go green immediately.
Sign with your personal Apple Developer account, not your work team. A binary signed as your company gets distributed as your company. That's fine for internal tools, problematic for anything broader.
First notarization from a new Developer account can take hours. Apple runs extended review on first submissions. Subsequent ones clear in minutes. Don't assume something's wrong if your first attempt sits "In Progress" for an hour.
Auto update: the silent failure trap
Auto updates work when they work and fail silently when they don't. The failure mode that cost hours: macOS auto updater expects a ZIP file for in place updates, not a DMG. The DMG is the installer. Your electron-builder config needs both:
"mac": {
"target": ["dmg", "zip"]
}
With only DMG, the updater downloads it, can't apply it, and gives you nothing. No error dialog, no log entry, no indication anything happened.
When an Electron app misbehaves and the GUI shows nothing, relaunch it from terminal with stdout captured:
nohup /Applications/FlowDoc.app/Contents/MacOS/FlowDoc > /tmp/app-stdout.log 2>&1 &
macOS unified log doesn't capture Electron stdout. The terminal will.
CI pipeline: five secrets, one workflow
GitHub Actions handles the full pipeline: build, sign, notarize, publish. Five repository secrets make it work:
CSC_LINK: base64 of your .p12 certificate exported from Keychain AccessCSC_KEY_PASSWORD: the export password you setAPPLE_ID: your Apple ID emailAPPLE_APP_SPECIFIC_PASSWORD: generated at account.apple.com, revocableAPPLE_TEAM_ID: 10 character team identifier from your developer account
The workflow triggers on v* tags. Development workflow: bump version, git tag vX.Y.Z, git push --tags. Done. Twenty minutes later you have a signed, notarized release.
Set publish.releaseType: "release" in your electron-builder config. Draft releases aren't visible to the auto updater. This mistake will make you think updates aren't working when the pipeline is fine.
Bundle everything, trust nothing
Desktop apps can't assume anything about the user's environment. No Python, no system ffmpeg, no model downloads from random servers. Everything has to be bundled or downloaded through your own pipeline.
FlowDoc v1.0.4 dropped the Python transcription entirely. Whisper.cpp compiles to a single static binary with no dependencies. The nodejs-whisper package bundles the source and cmake build, but invoke the compiled binary directly rather than their API. Their API has hidden gotchas: calls system ffmpeg instead of your bundled one, mutates process.cwd() during model downloads.
CMake builds have to happen during npm install, not lazily at runtime. node_modules is read only inside packaged apps. Static linking (-DBUILD_SHARED_LIBS=OFF) avoids dylib symlink issues that make electron-builder choke.
Model downloads get their own UX. A 500MB download with progress every 10% feels broken. Update every 5 seconds with speed, ETA, and alternating tips. Users need to know something's happening and roughly how long it'll take.
Polish that scales
The difference between "works for me" and "feels professional" is in the details that handle edge cases gracefully.
Update dialogs should show release notes fetched from GitHub's API. Users want to know what changed before they restart. Add a "Skip This Version" option that persists until they check manually.
checkForUpdatesAndNotify() is too subtle. Users miss macOS notifications and wonder if anything happened. Replace with explicit dialogs: "Update available" with install/later/skip buttons, "Up to date" confirmation for manual checks.
Add a proper application menu with { role: "editMenu" } blocks so cut/copy/paste shortcuts work in text fields. Electron doesn't include these by default when you set a custom menu.
The payoff is an app that works the first time for someone who's never used it. No terminal, no manual installs, no "did anything happen?" moments. One double click from download to running.