DEV Community

Cover image for Shipping a paid Mac app outside the App Store in two days
Kirk Int
Kirk Int

Posted on

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 took about a day. Everything else signing, notarization, payments, license keys, updates, the installer took the other day, and that's the part nobody writes about. So here it is, including the mistakes.

The app, briefly

A menu bar app in Swift: AppKit for the status item and the panel, SwiftUI for the panel's contents. A 3.8 MB download, no Electron, one dependency (Sparkle).

Three system reads: IOPSCopyPowerSourcesInfo for battery, CoreWLAN for Wi-Fi, 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 kills the glass

The panel is an NSPanel with an NSVisualEffectView. I rounded it the obvious way:

effect.layer?.cornerRadius = 13
effect.layer?.masksToBounds = true
Enter fullscreen mode Exit fullscreen mode

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
effect.maskImage = roundedMask(radius: 13)   // a resizable capImage-style mask
Enter fullscreen mode Exit fullscreen mode

Worth knowing: offscreen renders (bitmapImageRepForCachingDisplay) always come out opaque, so you cannot verify vibrancy in a headless screenshot. I wasted time comparing renders that could never show the bug. Verify it on a real screen capture.

Cmd-V doesn't work in an LSUIElement app

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

The Keychain froze my app on launch

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

A determined user can edit it. A determined user can also patch the binary. The Keychain wasn't buying real protection here, only launch hangs.

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
POST /v1/customer-portal/license-keys/deactivate
Enter fullscreen mode Exit fullscreen mode

The app posts the key plus the organization ID, stores the returned activation ID, and revalidates on launch. One rule that matters: only remove a stored license on an explicit rejection. If the network is down or the API 500s, the customer keeps working. Failing closed on a flaky connection is how you earn refund requests.

Polar has a sandbox environment, which I compiled into debug builds only, behind a UserDefaults flag:

#if DEBUG
if UserDefaults.standard.bool(forKey: "PolarSandbox") { PolarConfig.useSandbox = true }
#endif
Enter fullscreen mode Exit fullscreen mode

Release builds cannot be pointed at sandbox by any user default. That turned out to matter the first time I tested: I activated a sandbox key in a release build and spent ten minutes confused about why "the key isn't recognised."

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

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

If you only test the build sitting in your own dist/ folder, you're testing the case Gatekeeper doesn't care about.

Sparkle: sign inside out, never --deep

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

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

swift build -c release -Xlinker -rpath -Xlinker @executable_path/../Frameworks
Enter fullscreen mode Exit fullscreen mode

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.

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.

Immutable caching will serve your old bytes forever

The site is on Cloudflare Pages with:

/releases/*
  Cache-Control: public, max-age=31536000, immutable
Enter fullscreen mode Exit fullscreen mode

I shipped 0.1.0, found a bug, rebuilt, and re-uploaded the same filename. Cloudflare kept serving the old file for a year, as instructed. Sparkle downloaded it, compared it against the new EdDSA signature in the appcast, and refused the update — correctly.

New version, new filename, always. My release script now refuses to run if the target filename already exists in the site folder, and refuses if the build number isn't higher than the one already in the published appcast. Guards like that are cheap and they only fire on the day you're rushing.

The DMG that renders nothing 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")
Enter fullscreen mode Exit fullscreen mode

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

Second Finder quirk: with a background picture, Finder draws icon labels in black even in dark mode. Design the installer art light, or your labels vanish.

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

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

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

Two things I'd tell myself: SMAppService.daemon(_:).status reports .notFound before the first registration, not .notRegistered, so treat anything that isn't .enabled as "try to register". And after register() the status is usually .requiresApproval — that's your cue to call SMAppService.openSystemSettingsLoginItems() and tell the user what to do, not to show an error.

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.

ScreenShot

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.

Top comments (0)