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:
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 validateandspctl --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:
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:
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:
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.