Shipping a paid Mac app outside the App Store in two days
DEV Community

Shipping a paid Mac app outside the App Store in two days

Shipping a Paid Mac App Outside the App Store in Two Days

On Wednesday Apple showed the iPhone Duo and its status ring: battery, Wi-Fi and volume folded into one small ring. I wanted it on my Mac. By Friday the app was notarized, on sale and updating itself.

Writing the App

Writing the app took about a day. The app is a menu bar app in Swift, using AppKit for the status item and the panel, and SwiftUI for the panel's contents. A 3.8 MB download, no Electron, one dependency (Sparkle).

System Reads and Permissions

Three system reads are used: IOPSCopyPowerSourcesInfo for battery, CoreWLAN for Wi-Fi, and CoreAudio for volume, each with a change listener so the icon redraws only when something actually changes. One non-obvious thing: Wi-Fi on/off needs no permission, but reading the SSID does. CWInterface.setPower(_:) just works. The moment you call scanForNetworks or read the current network name, macOS wants Location access - for a menu bar utility, a Location prompt on first launch looks like spyware. So the app never reads the SSID and never asks.

Rounding a Glass Panel

The panel is an NSPanel with an NSVisualEffectView. I rounded it the obvious way: effect.layer?.cornerRadius = 13 and effect.layer?.masksToBounds = true. The corners rounded. The blur silently disappeared - the panel became a flat grey rectangle, and no API told me why. Behind-window blending can't survive being composited into a masked layer. The fix is to let the effect view do its own masking: effect.material = .menu, effect.blendingMode = .behindWindow, effect.state = .active, and effect.maskImage = roundedMask(radius: 13).

LSUIElement and Key Equivalents

The app is an LSUIElement (no Dock icon). The license key window had a text field, and paste did nothing. Neither did Cmd-A or Cmd-Z. Those shortcuts aren't implemented by the text field - they're menu items. An accessory app has no menu bar, so there are no menu items, so the key equivalents never fire. You have to build a main menu yourself, even though the user will never see it:

let edit = NSMenu(title: "Edit")
edit.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
edit.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
edit.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
// โ€ฆplus Select All and Undo/Redo
NSApp.mainMenu = mainMenu

Keychain and License Keys

I stored the license record in the Keychain, which is what you're supposed to do. Then every time I re-signed the app with a different identity, launch hung on a SecurityAgent prompt asking whether this "new" app may read its own item. Keychain items are bound to the signing identity. For a $4.99 utility whose secret is a license key the customer already has in their email, that's a bad trade. It now lives in UserDefaults: UserDefaults(suiteName: "app.dynamicring.mac")?.set(data, forKey: "license.polar").

Payments and License Keys Without Building an Account System

I didn't want accounts, logins or a server. Polar sells the product, emails a license key, and exposes a customer-portal API that needs no API key in the client: POST /v1/customer-portal/license-keys/activate, POST /v1/customer-portal/license-keys/validate, and POST /v1/customer-portal/license-keys/deactivate. The app posts the key plus the organization ID, stores the returned activation ID, and revalidates on launch.

Developer ID and Notarization

Two surprises here. Only the Account Holder can create a Developer ID certificate. Admin isn't enough. If you're using a company account where someone else is the holder, plan for that before launch day. notarytool with an Apple ID and app-specific password returned 401 for two different accounts. The same credentials worked on the website. Switching to an App Store Connect API key worked immediately:

xcrun notarytool store-credentials dynamicring-notary \
  --key ~/.appstoreconnect/private_keys/AuthKey_XXXXXXXX.p8 \
  --key-id XXXXXXXX --issuer <issuer-uuid>

Signing and Notarization

Notarize the zipped app, staple it, build the DMG, then notarize and staple the DMG too. Verify the way Gatekeeper will, on a copy that carries a quarantine flag:

xattr -w com.apple.quarantine "0081; $(printf %x $(date +%s)) ;Safari;" DynamicRing.dmg
spctl -a -t exec -vv /Volumes/DynamicRing/DynamicRing.app
# source=Notarized Developer ID

Sparkle

Sparkle ships XPC services and a helper app inside its framework, and they carry entitlements the other binaries must not inherit. codesign --deep flattens that and produces an updater that fails in ways you'll only see on a customer's machine. Sign the nested parts first, the framework next, the app last:

for nested in Downloader.xpc Installer.xpc Autoupdate Updater.app; do
  codesign --force --options runtime --timestamp --sign "$ID" "$VERSIONS/$nested"
done
codesign --force --options runtime --timestamp --sign "$ID" "$APP/Contents/Frameworks/Sparkle.framework"
codesign --force --options runtime --timestamp --sign "$ID" "$APP"

SwiftPM and RPATH

SwiftPM also doesn't add the RPATH a bundled framework needs, so build with:

swift build -c release -Xlinker -rpath -Xlinker @executable_path/../Frameworks

Backing Up the EdDSA Private Key

Back up the EdDSA private key (generate_keys -x) somewhere that isn't the Mac you're typing on. Lose it and you can never ship an update to existing customers again.

Immutable Caching

One more: generate_appcast writes delta updates into the appcast by default. My release script copied the DMG and the appcast to the site, but not the .delta files, so the feed advertised downloads that 404. Either upload the deltas or turn them off (--maximum-deltas 0). For an app this small, off is fine.

DMG Rendering on macOS 26

I built a proper installer with dmgbuild: app icon on the left, Applications alias on the right, a background image with an arrow. On macOS 26 the background didn't show. Same DMG, same .DS_Store, older Macs fine. What fixed it was deleting the pBBk record - a bookmark Finder writes into .DS_Store next to the background picture. Newer Finder seems to prefer that bookmark over the embedded picture and then render nothing when it can't resolve it. Strip it after building:

with DSStore.open(sys.argv[1], "r+") as store:
  if any(e.filename == "." and e.code == b"pBBk" for e in store):
    store.delete(".", b"pBBk")

Note b"pBBk" - the code is bytes. Passing the string silently deletes nothing, which is how I lost twenty minutes.

Low Power Mode

Low Power Mode can only be changed by root. The panel has a switch for Low Power Mode. There is no public API for it, and pmset -a lowpowermode 1 needs root. The supported route is a launchd daemon registered with SMAppService, which the user approves once under Login Items โ€บ Allow in the Background. The daemon plist ships inside the bundle at Contents/Library/LaunchDaemons/:

<key>BundleProgram</key><string>Contents/MacOS/DynamicRingHelper</string>
<key>MachServices</key><dict><key>app.dynamicring.mac.helper</key><true/></dict>
<key>AssociatedBundleIdentifiers</key><array><string>app.dynamicring.mac</string></array>

The helper is 66 lines. It runs one command and nothing else, and it only listens to the app:

let listener = NSXPCListener(machServiceName: PowerHelper.label)
listener.setConnectionCodeSigningRequirement("identifier \"app.dynamicring.mac\" and anchor apple generic " +
  "and certificate leaf[subject.OU] = \"<TEAMID>\"")

It also exits after 60 seconds of inactivity, so an app update never leaves an old root binary running.

Was it Worth it?

The plumbing is about 300 lines of shell and one afternoon of reading documentation, and it's reusable for every app I ship after this one. Now VERSION=0.1.2 BUILD=3 ./scripts/release.sh builds, signs, notarizes, staples, makes the DMG, notarizes that, checks Gatekeeper, regenerates the appcast and stages everything into the website repo. The whole release takes under fifteen minutes, most of it waiting on Apple's notary service. The app is DynamicRing, $4.99, macOS 14+, Apple silicon - and it exists because a keynote gave me an idea on Wednesday and nothing in the toolchain stopped me from selling it on Friday. That's a pretty good deal, once you know where the tripwires are.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.