---
title: SwiftPM's Bundle.module crashes your Mac app on every machine but yours
source: https://arazioformac.com/notes/bundle-module-crashes-outside-your-mac
---

# SwiftPM's Bundle.module crashes your Mac app on every machine but yours

I shipped a Mac app. It was signed, notarized, stapled, and verified by
Gatekeeper. 378 tests passed. I installed it from the DMG on my own machine and
used it for an hour.

A friend downloaded it. It crashed on launch. Not misbehaved — crashed,
instantly, every time, before a window appeared.

The cause is a generated accessor that most people never read, and it will do
the same thing to your app if you build a `.app` out of Swift Package Manager
targets that have resources.

## What SwiftPM generates

When a target declares `resources:`, SwiftPM writes a file called
`resource_bundle_accessor.swift` into the build directory and compiles it into
your module. That file is what `Bundle.module` resolves to. It looks roughly
like this:

```swift
extension Foundation.Bundle {
    static let module: Bundle = {
        let bundleName = "Arazio_ArazioCore"
        let overrides: [URL] = [ /* SWIFT_PACKAGE_RESOURCE_BUNDLE_PATH, if set */ ]
        let candidates = overrides + [
            Bundle.main.resourceURL,
            Bundle(for: BundleFinder.self).resourceURL,
            Bundle.main.bundleURL,
        ]
        for candidate in candidates {
            let path = candidate?.appendingPathComponent(bundleName + ".bundle")
            if let path, let bundle = Bundle(url: path) { return bundle }
        }
        fatalError("unable to find bundle named Arazio_ArazioCore")
    }()
}
```

The exact candidate list has changed across toolchain versions, which is part
of why this is hard to pin down. The shape that matters has not changed: **a
short list of guesses, and then `fatalError`.**

And on some versions, one of those guesses — or the override baked in at
compile time — is an absolute path into the `.build` directory of the machine
that compiled the binary.

## Why it works for you and nobody else

Run the app from Xcode or from `swift run`, and the bundle is sitting right
next to the executable in `.build/release/`. Found on the first or second
candidate. Everything works.

Now assemble a real `.app`. The convention — and what `codesign` expects, and
what every bundling script does — is that resources go in
`Arazio.app/Contents/Resources/`. But `Bundle.main.bundleURL` is
`Arazio.app/`, not `Contents/Resources/`. So that candidate misses.

On your machine, the fallback into `.build` still resolves, because that
directory is right there on your disk. The app finds its resources through a
path that has nothing to do with the bundle it is supposedly running from. Your
own development tree is silently propping up the shipped application.

On a customer's machine that path does not exist. Neither does any other
candidate. So: `fatalError`, on the first line of
`applicationDidFinishLaunching`, with a crash report naming a directory on a
stranger's disk.

## Nothing in a normal pipeline catches this

This is the part worth sitting with. Every check I had was green:

- `swift build -c release` — succeeded.
- The full test suite — 378 tests, passing. They run on my machine, where the
  path exists.
- `codesign --verify --deep --strict` — valid.
- Notarization — accepted by Apple.
- `stapler validate` and `spctl --assess` — accepted.
- Installing from the DMG and using the app — worked perfectly.

Not one of those can see the problem, because every one of them runs on the
machine that has the `.build` directory. Apple's notary service checks that
your code is signed and free of known malware. It does not launch your app on a
clean machine and see whether it survives.

The only thing that catches this is a machine that is not yours.

## The fix

Stop using `Bundle.module` for anything that ships inside a `.app`. Write a
lookup that checks where resources actually live, in order, and returns `nil`
instead of trapping:

```swift
public enum ResourceBundle {

    /// Anchors `Bundle(for:)` to this module, which is how the bundle is found
    /// under a test runner: there `Bundle.main` is the xctest tool, and none of
    /// the app-shaped paths exist.
    private final class Anchor {}

    public static func named(_ name: String) -> Bundle? {
        let filename = name.hasSuffix(".bundle") ? name : name + ".bundle"
        let candidates: [URL?] = [
            // Where an .app keeps them, and where the bundling script puts them.
            Bundle.main.resourceURL?.appendingPathComponent(filename),
            // Where SwiftPM's own accessor looks, in case that becomes right.
            Bundle.main.bundleURL.appendingPathComponent(filename),
            // Beside the executable, where a plain `swift build` leaves them.
            Bundle.main.executableURL?
                .deletingLastPathComponent()
                .appendingPathComponent(filename),
            // Under a test runner, or anything else that is not our .app: the
            // bundle sits beside the binary that contains this code.
            Bundle(for: Anchor.self).resourceURL?
                .appendingPathComponent(filename),
            Bundle(for: Anchor.self).bundleURL
                .deletingLastPathComponent()
                .appendingPathComponent(filename),
        ]
        for case let url? in candidates
        where FileManager.default.fileExists(atPath: url.path) {
            if let bundle = Bundle(url: url) { return bundle }
        }
        return nil
    }
}
```

Two details that are easy to get wrong.

**`Bundle.main.resourceURL` goes first.** That is `Contents/Resources` inside a
real `.app`, which is the correct answer and the one SwiftPM does not try
first.

**The `Anchor` class is not optional.** My first attempt at this fix dropped
the `Bundle(for:)` candidates, on the reasoning that an app-shaped lookup is
what an app needs. That broke 273 tests instantly. Under a test runner,
`Bundle.main` is the `xctest` binary, and none of the app paths exist — the
bundle sits beside the *module's* binary, which is what `Bundle(for:)` finds.
Returning `nil` rather than trapping also means a caller can fail with a message
a person can act on, instead of a stack trace.

Note the deliberate absence of a `.build` fallback. If the resources are not in
one of those five real locations, the app is broken and should say so — not
quietly borrow them from a directory that only exists here.

## The part that actually matters

Fixing it is twenty minutes. Making sure it never happens again is the point,
and it is the part I would not have bothered with before this shipped broken.

A release cannot depend on me remembering to test on a borrowed Mac. So the app
grew a flag that loads everything it needs from its own bundle and exits,
without opening a window:

```swift
if CommandLine.arguments.contains("--verify-resources") {
    var failures: [String] = []

    if ResourceBundle.named("Arazio_ArazioCore") == nil {
        failures.append("Arazio_ArazioCore.bundle not found")
    }
    if ResourceBundle.named("Arazio_ArazioUI") == nil {
        failures.append("Arazio_ArazioUI.bundle not found")
    }
    do {
        let registry = try FormatRegistry.loadBundled()
        guard registry.allFormats.count > 1 else {
            throw FormatRegistry.LoadError.manifestMissing
        }
        print("  format manifest: \(registry.allFormats.count) formats")
    } catch {
        failures.append("FormatRegistry.loadBundled failed: \(error)")
    }
    // …every other resource the app needs to start

    exit(failures.isEmpty ? 0 : 1)
}
```

And the release script takes the prop away before running it — it moves the
build directory's resource bundles aside, asks the staged app to load
everything, then puts them back:

```bash
echo "==> Verifying the app stands on its own"
STASH="$BUILD/resource-stash"
BUILT_PRODUCTS="$(cd "$ROOT" && swift build -c release --show-bin-path)"
rm -rf "$STASH"; mkdir -p "$STASH"
shopt -s nullglob
STASHED=("$BUILT_PRODUCTS"/*.bundle)
shopt -u nullglob
for BUNDLE in "${STASHED[@]}"; do mv "$BUNDLE" "$STASH/"; done

restore_bundles() {
  shopt -s nullglob
  for BUNDLE in "$STASH"/*.bundle; do mv "$BUNDLE" "$BUILT_PRODUCTS/"; done
  shopt -u nullglob
  rmdir "$STASH" 2>/dev/null || true
}
trap restore_bundles EXIT

if ! "$APP/Contents/MacOS/Arazio" --verify-resources; then
  restore_bundles
  trap - EXIT
  echo "error: the app cannot find its own resources without this machine's"
  echo "       build directory. It would crash on launch for every customer."
  exit 1
fi
restore_bundles
trap - EXIT
```

The `trap` matters more than it looks. If the check fails, or the script is
interrupted between the two `mv` loops, the bundles have to go back — otherwise
a failed release leaves your working tree in a state where the *next* build is
also broken, for an entirely different reason.

This is, as far as I can tell, the only way to see what a stranger's Mac sees
without owning one.

## If you take one thing

The bug was never really `Bundle.module`. The bug was that **every check I had
ran on the machine that had the thing being tested for.**

That is worth turning into a standing question rather than a one-off fix. What
else is true on my machine that is not true on a customer's? A Homebrew library
on the link path. A font. A permission granted months ago and forgotten. A
filesystem — a later release of the same app refused every conversion on exFAT
drives, because `volumeAvailableCapacityForImportantUsage` returns zero on
anything that is not APFS, and I only own APFS volumes.

Each of those is invisible to a test suite that runs where you build. The fix
is not to test harder. It is to make the release take away the thing you are
standing on, and see whether the app is still upright.

---

*Written while building [Arazio](/), a Mac app that converts files from the
drag itself. The crash described here was version 1.0.0, fixed in 1.0.1, about
six hours apart.*
