Skip to content

A Strange Bug Caused by SwiftUI + macOS 26

Published: at 12:00 AM

A record of a bizarre issue caused by a SwiftUI design problem on macOS 26.

TL;DR

If your macOS app meets all three of the following conditions:

  1. It uses SwiftUI’s MenuBarExtra as its only visible entry point.
  2. It calls NSApp.setActivationPolicy(.accessory) at launch to hide the Dock icon.
  3. Its main Window scene does not automatically show under certain states.

Then, once the user turns off your app’s toggle under “System Settings → Control Center → Menu Bar,” the entire app process will fail to launch and terminate immediately — leaving no crash log, and not even Sentry can catch it.

The fix: abandon MenuBarExtra, manage an NSStatusItem manually in AppDelegate, and fall back to .regular to show the Dock icon when isVisible == false.

A Strange Crash Report

OrbitRing is my macOS ring launcher. It lives in the menu bar, and pressing a trigger key pops up the ring panel. One user reported:

Ever since I turned off OrbitRing’s menu bar icon in System Settings, the whole app won’t open anymore. Double-clicking in Finder does nothing, the trigger key does nothing, and I can’t find the process in Activity Monitor.

My first instinct was a crash, but Sentry had no records, and the local ~/Library/Logs/DiagnosticReports/ was empty too.

Even more puzzling: Typeless, which is also menu-bar-only, kept running normally after its menu bar icon was hidden at the system level — no icon, no Dock icon, but the trigger key could still activate the window, and the process stayed alive and well.

In other words: this is achievable, and I had done something wrong.

A Rejected Hypothesis

My first guess was: the process is actually alive, it just has no visible UI. This hypothesis flowed nicely:

The fix under this hypothesis would be to add a reopen handler so the user could reactivate the window.

But the user immediately provided a counterexample:

The OrbitRing process is completely absent from Activity Monitor.

That one sentence rejected the hypothesis. The process not being there means it never survived at all — it’s not that there’s no UI, it’s genuinely dead. The problem needed to be re-modeled.

The Implicit Contract of SwiftUI’s MenuBarExtra

Back to the code. OrbitRing’s scene tree is minimal:

@main
struct OrbitRing: App {
  var body: some Scene {
    Window("OrbitRing", id: "main") { ... }
    MenuBarExtra("OrbitRing", image: "planet") { ... }
  }
}

And AppDelegate immediately, at launch:

NSApp.setActivationPolicy(hideDockIcon ? .accessory : .regular)

The key observation: SwiftUI’s MenuBarExtra binds “the menu bar icon exists” into the scene lifecycle. It doesn’t offer semantics for “the icon is hidden but the scene stays alive,” unlike the isVisible property exposed by the traditional NSStatusItem.

The “Allow in Menu Bar” toggle introduced in macOS 26 essentially forces NSStatusItem.isVisible = false at the system level:

ImplementationBehavior after system hides it
Manual NSStatusItemObject is created successfully, just not displayed. AppDelegate is intact, the process keeps running.
SwiftUI MenuBarExtraThe underlying NSStatusItem creation is rejected/made invisible by the system, and the scene enters an “unable to display” state.

And in a specific combination like OrbitRing’s:

ConditionState
MenuBarExtra sceneCannot mount
Window sceneOnboarding already complete, won’t auto-show at launch
setActivationPolicy(.accessory)Removes the Dock icon immediately

The SwiftUI app can’t find any valid scene to sustain its lifecycle, so the process is quickly reclaimed. This path is neither an abort() nor a signal — it’s a graceful exit after the framework internally decides “there’s no displayable scene,” which is why Sentry can’t catch it and there’s no local crash log.

The side effect is subtle: because it’s a graceful exit, the user can’t even sense the difference from a crash — they just feel “double-clicking does nothing.”

Typeless avoided this landmine because it creates its status item manually in AppDelegate via NSStatusBar.system.statusItem(...). The survival of the NSStatusItem object itself doesn’t depend on isVisible at all, so the process is safe.

Migrating to a Manual NSStatusItem + Visibility Fallback

There’s only one fix path: abandon MenuBarExtra. Concretely, do four things.

1. Hold the NSStatusItem in AppDelegate

class AppDelegate: NSObject, NSApplicationDelegate {
  // Manually managed menu bar icon, replacing SwiftUI's MenuBarExtra.
  // Reason: when "Allow in Menu Bar" is turned off at the system level, MenuBarExtra
  // causes the entire app process to fail to launch (no crash log). A manual
  // NSStatusItem simply becomes isVisible=false when hidden by the system,
  // and the process stays alive.
  private var statusItem: NSStatusItem?

  func applicationDidFinishLaunching(_ notification: Notification) {
    NSApp.setActivationPolicy(hideDockIcon ? .accessory : .regular)
    setupStatusItem()
    // ... rest of the launch logic
  }

  private func setupStatusItem() {
    let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
    if let button = item.button {
      let image = NSImage(named: "planet")
      // Template mode lets the icon auto-invert with the system's light/dark appearance
      image?.isTemplate = true
      button.image = image
    }
    item.menu = makeStatusMenu()
    statusItem = item
  }
}

2. Translate the SwiftUI menu buttons into an NSMenu

Each SwiftUI Button in the original MenuBarExtraContent is translated into an NSMenuItem with an @objc response method. Localized strings are obtained through the project’s existing LocalizedString(_:) global function, so you can directly reuse the translation keys in Localizable.xcstrings without re-translating.

private func makeStatusMenu() -> NSMenu {
  let menu = NSMenu()

  let settingsItem = NSMenuItem(
    title: LocalizedString("Settings"),
    action: #selector(handleOpenSettings),
    keyEquivalent: ","
  )
  settingsItem.target = self
  menu.addItem(settingsItem)

  // ... other menu items

  return menu
}

@objc private func handleOpenSettings() {
  OrbitRing.showSettingView()
}

3. Remove MenuBarExtra from the SwiftUI scene tree

var body: some Scene {
  Window("OrbitRing", id: "main") { ... }
  // No more MenuBarExtra
}

4. Detect isVisible and fall back

After launch completes, with a small delay (empirically 0.5s, to let the system finish its visibility determination), check statusItem.isVisible. If it’s false: automatically switch back to .regular to show the Dock icon, and post a local notification telling the user why the Dock icon suddenly appeared and how to restore things.

DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
  self?.handleStatusItemVisibilityFallback()
}

private func handleStatusItemVisibilityFallback() {
  guard let statusItem, statusItem.isVisible == false else { return }

  NSApp.setActivationPolicy(.regular)

  let content = UNMutableNotificationContent()
  content.title = LocalizedString("menu_bar_icon_hidden_title")
  content.body = LocalizedString("menu_bar_icon_hidden_body")
  let request = UNNotificationRequest(
    identifier: "menu_bar_icon_hidden_fallback",
    content: content,
    trigger: nil
  )
  UNUserNotificationCenter.current().add(request)
}

This step ensures that even if the user turns off the menu bar toggle, there’s at least a Dock icon as a fallback entry point, plus a notification explaining the cause and how to recover — avoiding the “won’t open” dead end again.

Lessons Learned

  1. The cost of SwiftUI’s abstraction: MenuBarExtra is a beautiful declarative API, but it binds “icon visible” into the scene lifecycle. Once the system intervenes on that visibility, the entire scene becomes invalid. AppKit’s NSStatusItem gives you the isVisible escape hatch; SwiftUI doesn’t. When building menu-bar-only apps, the old AppKit road is actually more stable.
  2. A menu-bar-only app must keep a fallback entry point: even if you believe the icon will always be there, assume it will disappear one day. A Dock icon, a global shortcut, a URL scheme — keep at least one, so users don’t walk into a dead end.
  3. No crash log doesn’t mean no crash: when the SwiftUI framework itself decides whether the app should live, it takes a non-exceptional exit path. Tools like Sentry can’t catch this kind of “silent death” — you can only reason from symptoms. If a process is absent after launch yet there’s no crash log, your suspicion should point toward “the framework terminated it deliberately” rather than “the code crashed.”
  4. macOS 26’s menu bar control is dynamic: the user just needs to toggle it once and restart the app to trigger it. If your macOS app uses SwiftUI’s MenuBarExtra, run through this path manually on macOS 26 and above — chances are high that you’ve stepped into the same trap.