---
title: "Selling A Mac App Directly And On The Mac App Store"
url: https://deallocatedobjects.com/posts/selling-a-mac-app-directly-and-on-the-mac-app-store
author: Jake Marsh
published: 2026-10-25
updated: 2026-10-25
topics: ["Xcode & Tools", "Apple"]
description: "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."
---

# Selling A Mac App Directly And On The Mac App Store

By Jake Marsh, October 25, 2026. HTML: https://deallocatedobjects.com/posts/selling-a-mac-app-directly-and-on-the-mac-app-store

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:

```yaml title="project.yml"
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:

```swift title="RefrainApp.swift"
#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:

```swift title="PromptStore.swift"
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:

```xml title="Refrain.entitlements"
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
```

```xml title="Refrain-MAS.entitlements"
<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:

```swift title="PermissionManager.swift"
#if DIRECT_DISTRIBUTION
hasAccessibility = AXIsProcessTrusted()
#else
// Sandboxed build: only ask for what we use, posting mouse events.
hasAccessibility = CGPreflightPostEventAccess()
#endif
```

A 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:

```xml title="com.jakemarsh.Homestead.appstore.helper.plist"
<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:

```bash title="release-mas.sh"
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.
