Selling A Mac App Directly And On The Mac App Store
Jake MarshIn this post
Every Mac app I make now ships two ways from the same code. There’s a direct build that I sell on the app’s own website, signed with Developer ID, notarized, packed in a DMG and kept up to date with Sparkle. There’s also a Mac App Store build. It runs in the sandbox and gets its updates from the App Store, so there’s no Sparkle anywhere in it.
Refrain, Shake It On and Multimeter all have both targets now. Homestead, which I’m still building, goes the other way and will live only on the store, for a reason I’ll get to. Here’s how it all fits together.
Why Both#
The two channels reach different people. Plenty of Mac folks only buy apps from the App Store. They like having one place for purchases and updates, and they like the sandbox. Other people would rather buy straight from the developer. And some of my apps do things the sandbox doesn’t allow, so the direct build is where the full version lives.
Doing both means I don’t have to pick. The cost is some extra setup per app, and most of it only happens once.
One Project, Two Targets#
I use XcodeGen for every Apple project, and it makes this part pretty painless. The shared setup goes in a target template, and each distribution gets its own small target on top of it. Here’s Refrain’s, trimmed down:
packages:
Sparkle:
url: https://github.com/sparkle-project/Sparkle
version: 2.6.0
targetTemplates:
RefrainApp:
type: application
platform: macOS
sources:
- Sources
- path: Resources/Assets.xcassets
buildPhase: resources
targets:
# Direct sale: Developer ID, notarized DMG, Sparkle.
Refrain:
templates: [RefrainApp]
dependencies:
- package: Sparkle
product: Sparkle
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.jakemarsh.Refrain
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) DIRECT_DISTRIBUTION"
info:
path: Resources/Info.plist
properties:
SUFeedURL: https://refrainformac.com/appcast.xml
# Mac App Store: sandboxed, no Sparkle, separate bundle id.
Refrain-MAS:
templates: [RefrainApp]
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.jakemarsh.Refrain.appstore
CODE_SIGN_ENTITLEMENTS: Resources/Refrain-MAS.entitlements
CODE_SIGN_STYLE: Manual
CONFIGURATION_BUILD_DIR: "$(BUILD_DIR)/$(CONFIGURATION)-MAS"
configs:
Debug:
CODE_SIGN_IDENTITY: "-"The store target never lists the Sparkle package, so Sparkle can’t sneak into that build. Both targets produce an app called Refrain.app, so CONFIGURATION_BUILD_DIR sends the store build to its own folder where the two can’t land on top of each other. Debug builds of the store target are ad-hoc signed, which means I can run them locally and they’re still sandboxed by their entitlements. The Info.plist keys both targets share live in a YAML anchor, so I only write them once, and each target gets its own scheme.
The DIRECT_DISTRIBUTION Flag#
The direct target adds DIRECT_DISTRIBUTION to SWIFT_ACTIVE_COMPILATION_CONDITIONS, and anything that only makes sense in the direct build goes behind it. Sparkle is the obvious one. Here’s Refrain’s app delegate:
#if DIRECT_DISTRIBUTION
import Sparkle
#endif
final class AppDelegate: NSObject, NSApplicationDelegate {
#if DIRECT_DISTRIBUTION
static var updaterController: SPUStandardUpdaterController?
#endif
func applicationDidFinishLaunching(_ notification: Notification) {
#if DIRECT_DISTRIBUTION
Self.updaterController = SPUStandardUpdaterController(
startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil
)
#endif
// ...
}
}The “Check for Updates…” menu item and the automatic updates toggle in Settings get the same treatment. So do links out to the website. Shake It On’s Settings has a “How to use…” button that opens a help page on shakeiton.app, right next to the purchase links, so the store build leaves that button out.
The flag works the other way too, with #if !DIRECT_DISTRIBUTION for code only the sandboxed build needs. Refrain keeps your snippets in a plain text file wherever you like. In the sandbox, access to a file you picked in an open panel goes away when the app quits, so the store build saves a security-scoped bookmark and resolves it on the next launch:
init() {
#if DIRECT_DISTRIBUTION
let savedURL = UserDefaults.standard.string(forKey: Self.filePathKey)
.map { URL(fileURLWithPath: $0) }
#else
let savedURL = restoreBookmarkedFile()
#endif
// ...
}Homestead’s store targets use a MAS_BUILD flag instead, since over there it mostly swaps identifiers like bundle ids and the helper’s Mach service name.
Two Entitlements Files#
The direct build runs outside the sandbox with the hardened runtime turned on, which notarization requires. The store build turns the sandbox on and asks for exactly what it needs. Here’s the difference for Refrain:
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/><key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>That last key is what lets the bookmark trick above work. Multimeter’s pair looks almost the same, plus network.client in the store version, and it uses the same bookmark approach so file meters keep reading the file you chose after a relaunch.
Shake It On is where the sandbox makes the biggest difference. Its direct build asks for full Accessibility access and watches for its on/off keyboard shortcut with a global NSEvent monitor. The store build registers the shortcut with Carbon’s RegisterEventHotKey, which works in the sandbox with no permission at all, and only asks to post mouse events:
#if DIRECT_DISTRIBUTION
hasAccessibility = AXIsProcessTrusted()
#else
// Sandboxed build: only ask for what we use, posting mouse events.
hasAccessibility = CGPreflightPostEventAccess()
#endifA couple of things change shape in that build. The “Focus / Do Not Disturb is on” condition reads Control Center’s preferences, and inside the sandbox that domain resolves to the app’s own container, so the store build hides that toggle. And the direct build asks Music for its player state over AppleScript at launch, while the store build picks up the state from Music’s next play or pause notification.
Separate Bundle IDs, and Setting Things Up Twice#
Store builds use the same bundle id with .appstore on the end: com.jakemarsh.Refrain.appstore, com.jakemarsh.ShakeItOn.appstore, com.greatwork.Multimeter.appstore. Both versions can be installed on the same Mac side by side, which makes testing a lot easier, and each one keeps its own preferences.
The tradeoff is that anything Apple ties to an App ID gets set up twice and kept in sync: HomeKit, WeatherKit, MapKit, iCloud containers, App Groups and push. Each of those is a capability on two App IDs with two provisioning profiles. Homestead ends up with four bundle ids for this reason, the app and its helper once for each build.
The two purchases are also separate. Buying the direct version doesn’t unlock the store version, or the other way around. There’s no way to connect the two, so I treat them as two separate products.
What Stays Off the Store, and What Can Only Go There#
Some apps stay direct-only. An app built around non-sandbox powers, like posting events through Accessibility, scripting other apps with AppleScript or installing a privileged helper, either gives those features up in the store build or skips the store. I’d rather not put out a store version that feels like a cut-down copy of the real app, so Costumes, my menu bar appearance app, is direct-only.
Homestead is the opposite case. It’s a native Mac app for HomeKit I’m working on, and it shows the weather with WeatherKit. Apple’s macOS capability table lists HomeKit and WeatherKit as App Store only, with neither one available to Developer ID apps, so a direct build can’t deliver them to customers. Homestead will ship only on the Mac App Store.
Its layout is a little unusual. HomeKit on the Mac lives in Mac Catalyst, so Homestead is a sandboxed AppKit app with a small sandboxed Catalyst helper inside its bundle that does the HomeKit work. The app registers the helper with SMAppService.agent, launchd starts it on demand, and the two talk over XPC. A sandboxed process can only use a Mach service name that’s an immediate child of an App Group it claims, so the helper’s launch agent plist looks like this:
<key>BundleProgram</key>
<string>Contents/Helpers/HomesteadHelper.app/Contents/MacOS/HomesteadHelper</string>
<key>AssociatedBundleIdentifiers</key>
<array>
<string>com.jakemarsh.Homestead.appstore</string>
</array>
<key>MachServices</key>
<dict>
<key>group.com.jakemarsh.Homestead.helper</key>
<true/>
</dict>Both entitlements files claim group.com.jakemarsh.Homestead. The helper’s adds com.apple.developer.homekit, and the app’s adds WeatherKit and Maps. The helper exits a minute after the app disconnects, so nothing stays running after you quit. Xcode won’t embed a Catalyst target in a macOS target through a normal dependency, so a post-compile script in project.yml copies the already-built helper into Contents/Helpers, where it keeps its own signature and provisioning profile.
Shipping Each One#
Each of the two-target apps has two release scripts. release.sh handles the direct build. Refrain’s archives the direct scheme, exports with the developer-id method, notarizes and staples the app, wraps it in a DMG with create-dmg, then signs, notarizes and staples the DMG and generates the Sparkle appcast. It stages all of that first and publishes in a separate step, so I can check a build before anything goes public. Publishing copies the DMG and appcast.xml to the app’s website and deploys it, and Sparkle picks up the update from there.
release-mas.sh archives the -MAS scheme, exports a signed .pkg with the app-store-connect method, and uploads it to App Store Connect with the asc CLI. Before it exports anything, it checks the archive:
APP="$ARCHIVE_PATH/Products/Applications/$APP_NAME.app"
if [ -d "$APP/Contents/Frameworks/Sparkle.framework" ]; then
echo "ERROR: Sparkle is embedded in the MAS build" >&2; exit 1
fi
codesign -d --entitlements - "$APP" 2>/dev/null | grep -q "com.apple.security.app-sandbox" \
|| { echo "ERROR: MAS build is not sandboxed" >&2; exit 1; }It also runs strings on the binary to make sure none of my screenshot or test launch hooks made it into the store build. Those hooks are compiled in only under #if DEBUG || DIRECT_DISTRIBUTION, and the script confirms it. Refrain, Shake It On and Multimeter all share the same release-mas.sh shape, and only the config block at the top changes.