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:
- It uses SwiftUI’s
MenuBarExtraas its only visible entry point. - It calls
NSApp.setActivationPolicy(.accessory)at launch to hide the Dock icon. - Its main
Windowscene 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:
MenuBarExtrawas hidden by the system, but the SwiftUI app was still running.AppDelegatedidn’t implementapplicationShouldHandleReopen(_:hasVisibleWindows:), so double-clicking in Finder no longer summoned any window.- The user felt it “wouldn’t open,” but the process was still there.
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:
| Implementation | Behavior after system hides it |
|---|---|
Manual NSStatusItem | Object is created successfully, just not displayed. AppDelegate is intact, the process keeps running. |
SwiftUI MenuBarExtra | The 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:
| Condition | State |
|---|---|
MenuBarExtra scene | Cannot mount |
Window scene | Onboarding 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
- The cost of SwiftUI’s abstraction:
MenuBarExtrais 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’sNSStatusItemgives you theisVisibleescape hatch; SwiftUI doesn’t. When building menu-bar-only apps, the old AppKit road is actually more stable. - 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.
- 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.”
- 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.