diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -1926,7 +1926,7 @@ /// because both cuts share a PostScript name (the original 0.7/0.8 /// title-rendering bug), so callers should prefer the cached /// descriptors. Called once at launch. private static func registerBundledFonts() { - let bundle = Bundle.module + let bundle = Bundle.appResources for name in ["ywft-processing-regular", "ywft-processing-bold"] { guard let url = bundle.url(forResource: name, withExtension: "ttf") else { NSLog("MenuBand: bundled font missing — \(name).ttf") diff --git a/slab/menuband/Sources/MenuBand/BravuraFont.swift b/slab/menuband/Sources/MenuBand/BravuraFont.swift --- a/slab/menuband/Sources/MenuBand/BravuraFont.swift +++ b/slab/menuband/Sources/MenuBand/BravuraFont.swift @@ -48,7 +48,7 @@ /// can find it even though it isn't installed system-wide. static func ensureRegistered() { guard !registered else { return } registered = true - guard let url = Bundle.module.url(forResource: "Bravura", + guard let url = Bundle.appResources.url(forResource: "Bravura", withExtension: "otf") else { NSLog("Bravura: Bravura.otf missing from bundle") return diff --git a/slab/menuband/Sources/MenuBand/JamWindow.swift b/slab/menuband/Sources/MenuBand/JamWindow.swift --- a/slab/menuband/Sources/MenuBand/JamWindow.swift +++ b/slab/menuband/Sources/MenuBand/JamWindow.swift @@ -76,7 +76,7 @@ // Colored-pencil banner — a communal notepat jam (generated in the // marketing stack, house style). Sets the come-hang-out tone above // the heading + links. - if let url = Bundle.module.url(forResource: "looking-for-players", + if let url = Bundle.appResources.url(forResource: "looking-for-players", withExtension: "png"), let banner = NSImage(contentsOf: url) { let bannerView = NSImageView() diff --git a/slab/menuband/Sources/MenuBand/MASBundleShim.swift b/slab/menuband/Sources/MenuBand/MASBundleShim.swift --- a/slab/menuband/Sources/MenuBand/MASBundleShim.swift +++ b/slab/menuband/Sources/MenuBand/MASBundleShim.swift @@ -20,3 +20,28 @@ extension Bundle { static var module: Bundle { Bundle.main } } #endif + +extension Bundle { + /// The bundle to load app resources (fonts, sheet.html, the keymaps PDF, + /// looking-for-players.png, verovio wasm, default.metallib) from. + /// + /// Prefers `Bundle.main` — the installed `.app` and the Mac App Store build + /// keep these in `Contents/Resources/`, which codesign seals cleanly. Only + /// falls back to `Bundle.module` (the SwiftPM nested `MenuBand_MenuBand.bundle`) + /// for `swift run` dev builds. + /// + /// Why not just `Bundle.module`: Swift 6.3's generated SwiftPM accessor for + /// this executable target resolves the nested bundle at `Bundle.main.bundleURL` + /// — i.e. the `.app` ROOT, NOT `Contents/Resources` — and anything at the + /// bundle root makes `codesign --strict` fail ("unsealed contents present in + /// the bundle root"), breaking notarization. Flattening the resources into + /// `Contents/Resources` and reading them via `Bundle.main` avoids that + /// entirely; `Bundle.module` is never touched in the installed app, so its + /// fatalError accessor never fires. + static var appResources: Bundle { + if Bundle.main.url(forResource: "Bravura", withExtension: "otf") != nil { + return Bundle.main + } + return Bundle.module + } +} diff --git a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift --- a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift @@ -1705,7 +1705,7 @@ @objc private func openKeymapsPaper(_ sender: NSButton) { // Prefer the copy bundled inside the app — opens in Preview offline, // no network round-trip — then fall back to the public hosted PDF if // the bundled resource somehow goes missing. - if let url = Bundle.module.url( + if let url = Bundle.appResources.url( forResource: "keymaps-social-software-26-arxiv", withExtension: "pdf") { diff --git a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift --- a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift +++ b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift @@ -526,7 +526,7 @@ @objc private func whyKeymapClicked(_ sender: NSButton) { // Same fallback chain as the popover's whyKeymapButton — // bundled PDF first (offline-friendly), then the public URL. - if let url = Bundle.module.url( + if let url = Bundle.appResources.url( forResource: "keymaps-social-software-26-arxiv", withExtension: "pdf") { diff --git a/slab/menuband/Sources/MenuBand/WaveformView.swift b/slab/menuband/Sources/MenuBand/WaveformView.swift --- a/slab/menuband/Sources/MenuBand/WaveformView.swift +++ b/slab/menuband/Sources/MenuBand/WaveformView.swift @@ -254,7 +254,7 @@ // ~10ms once on launch — invisible next to the popover // animation. Without this the visualizer ships solid black: // makeDefaultLibrary throws "no default library was found". let library: MTLLibrary - if let url = Bundle.module.url(forResource: "WaveformShaders", withExtension: "metal"), + if let url = Bundle.appResources.url(forResource: "WaveformShaders", withExtension: "metal"), let source = try? String(contentsOf: url, encoding: .utf8) { library = try device.makeLibrary(source: source, options: nil) } else { diff --git a/slab/menuband/bin/verify-bundle.sh b/slab/menuband/bin/verify-bundle.sh --- a/slab/menuband/bin/verify-bundle.sh +++ b/slab/menuband/bin/verify-bundle.sh @@ -55,7 +55,11 @@ say "verifying: $APP" local BIN="$APP/Contents/MacOS/MenuBand" local INFO="$APP/Contents/Info.plist" - local RES_BUNDLE="$APP/Contents/Resources/MenuBand_MenuBand.bundle" + # Resources are FLATTENED directly into Contents/Resources (read via + # Bundle.appResources → Bundle.main). This keeps them in a codesign-sealable + # location — the nested MenuBand_MenuBand.bundle is NOT shipped (Swift 6.3's + # accessor would want it at the unsealed .app root). + local RES_DIR="$APP/Contents/Resources" if [[ ! -f "$BIN" ]]; then err "missing binary: $BIN"; FAIL=1 @@ -63,27 +67,27 @@ fi if [[ ! -f "$INFO" ]]; then err "missing Info.plist: $INFO"; FAIL=1 fi - if [[ ! -d "$RES_BUNDLE" ]]; then - err "missing resource bundle: $RES_BUNDLE" - err " → on a fresh machine this app will crash with" - err " 'could not load resource bundle: from … or /Users//…'" - err " → install.sh must copy MenuBand_MenuBand.bundle into Contents/Resources/" - return 1 + # A stray nested bundle at the .app root is a sign of the old (unsealable) + # layout — flag it so it never ships. + if [[ -e "$APP/MenuBand_MenuBand.bundle" ]]; then + err "nested MenuBand_MenuBand.bundle present at the .app root — fails codesign --strict" + err " → install.sh must FLATTEN it into Contents/Resources instead" + FAIL=1 fi - ok "resource bundle present" local missing=0 for r in "${REQUIRED_RESOURCES[@]}"; do - if [[ ! -e "$RES_BUNDLE/$r" ]]; then + if [[ ! -e "$RES_DIR/$r" ]]; then err " missing: $r" missing=$((missing + 1)) fi done if (( missing > 0 )); then - err "$missing required resource(s) absent from MenuBand_MenuBand.bundle" + err "$missing required resource(s) absent from Contents/Resources" + err " → on a fresh machine this app will crash on Bundle.appResources lookups" FAIL=1 else - ok "all ${#REQUIRED_RESOURCES[@]} required resources present" + ok "all ${#REQUIRED_RESOURCES[@]} required resources present in Contents/Resources" fi # Scan the binary for hardcoded developer paths. We only care about @@ -150,21 +154,16 @@ local TMP TMP="$(mktemp -d -t menuband-fresh-XXXX)" cp -R "$APP" "$TMP/" local copied="$TMP/$(basename "$APP")" - if [[ -d "$copied/Contents/Resources/MenuBand_MenuBand.bundle" ]]; then - local missing_in_copy=0 - for r in "${REQUIRED_RESOURCES[@]}"; do - if [[ ! -e "$copied/Contents/Resources/MenuBand_MenuBand.bundle/$r" ]]; then - missing_in_copy=$((missing_in_copy + 1)) - fi - done - if (( missing_in_copy == 0 )); then - ok "fresh-path simulation: bundle resolves at $(dirname "$TMP")/.../$(basename "$APP")" - else - err "fresh-path simulation: $missing_in_copy resources missing after copy" - FAIL=1 + local missing_in_copy=0 + for r in "${REQUIRED_RESOURCES[@]}"; do + if [[ ! -e "$copied/Contents/Resources/$r" ]]; then + missing_in_copy=$((missing_in_copy + 1)) fi + done + if (( missing_in_copy == 0 )); then + ok "fresh-path simulation: resources resolve at $(dirname "$TMP")/.../$(basename "$APP")" else - err "fresh-path simulation: copied .app missing resource bundle" + err "fresh-path simulation: $missing_in_copy resources missing from Contents/Resources after copy" FAIL=1 fi rm -rf "$TMP" diff --git a/slab/menuband/install.sh b/slab/menuband/install.sh --- a/slab/menuband/install.sh +++ b/slab/menuband/install.sh @@ -201,8 +201,15 @@ # resource bundle of its own). PKG_BUNDLE_NAME="MenuBand_MenuBand.bundle" PKG_BUNDLE_SRC="$(dirname "${ARM_BIN}")/${PKG_BUNDLE_NAME}" if [[ -d "${PKG_BUNDLE_SRC}" ]]; then - rm -rf "${APP_RES}/${PKG_BUNDLE_NAME}" - cp -R "${PKG_BUNDLE_SRC}" "${APP_RES}/${PKG_BUNDLE_NAME}" + # FLATTEN the SwiftPM resource bundle's contents directly into + # Contents/Resources. The app reads them via Bundle.appResources → + # Bundle.main (see MASBundleShim.swift), so they live in a codesign-sealable + # location. We deliberately do NOT keep the nested .bundle: Swift 6.3's + # generated accessor resolves it at `Bundle.main.bundleURL` = the .app ROOT, + # and anything at the bundle root fails `codesign --strict` ("unsealed + # contents present in the bundle root") and breaks notarization. + rm -rf "${APP_DIR}/${PKG_BUNDLE_NAME}" "${APP_RES}/${PKG_BUNDLE_NAME}" + cp -R "${PKG_BUNDLE_SRC}/." "${APP_RES}/" fi # Sign with the best available identity.