Building macOS Menu Bar Apps with Swift in 2026
π Everything here
is battle-tested on
OpenWhisper β a
voice dictation app
I built and ship
as open source.
A menu bar app is, I think, the perfect side-project format. The surface area is tiny: one icon, one popover, zero windows to manage. It runs all day in the corner of the screen, doing exactly one thing. Users barely notice itβs there until they need it β and then itβs right there.
I built OpenWhisper β a push-to-talk voice dictation app that uses on-device Whisper β as a menu bar app, and the journey taught me more about macOS internals than any documentation ever could. Dark corners of AppKit, the Accessibility permission dance, CGEventTap callbacks, panels that refuse to steal focus. All of it.
This post is a thorough brain-dump of everything I know. Whether youβre building a clipboard manager, a quick-timer, a focus mode toggle, or something I havenβt imagined, youβll hit the same walls. Let me show you how to break through them.
Popular menu bar apps for reference: Bartender, Dato, Rectangle, CleanMyMac, 1Password mini, and β yes β OpenWhisper. All of them follow the same architectural skeleton underneath their polished UIs.
Architecture overview
Before writing a single line of code, it helps to see the mental model. A menu bar app is a stack of three or four OS-level objects that sit on top of each other. Click any layer below to explore it.
The minimal setup β five key areas
Letβs build from zero. Iβll walk through each decision point with real, annotated code.
1 β App protocol vs AppDelegate
SwiftUI 3+ lets you skip AppDelegate entirely for simple cases. For menu bar apps I recommend a hybrid: use the App protocol as the entry point but keep a reference to your AppDelegate for status item management.
import SwiftUI @main struct OpenWhisperApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var delegate var body: some Scene { // An empty WindowGroup is required by the compiler // but LSUIElement = YES ensures it never shows. Settings { EmptyView() } } } class AppDelegate: NSObject, NSApplicationDelegate { var statusItem: NSStatusItem! var popover = NSPopover() func applicationDidFinishLaunching(_ note: Notification) { setupStatusItem() setupPopover() registerGlobalHotkey() } }
LSUIElement = YES is
the single line that
makes your app
invisible to the
Dock and App Switcher
β arguably the most
important line in
any menu bar app.
2 β Custom status bar icon
Always use an SF Symbol rendered as a template image. Template images automatically adapt to dark and light menu bars, and invert when your menu/popover is open.
func setupStatusItem() { statusItem = NSStatusBar.system .statusItem(withLength: NSStatusItem.variableLength) guard let button = statusItem.button else { return } // SF Symbol β use a 16pt weight that reads well at small sizes let config = NSImage.SymbolConfiguration( pointSize: 16, weight: .medium ) let img = NSImage( systemSymbolName: "waveform.circle", accessibilityDescription: "OpenWhisper" )!.withSymbolConfiguration(config)! img.isTemplate = true // β critical button.image = img button.action = #selector(togglePopover) button.target = self button.sendAction(on: [.leftMouseUp, .rightMouseUp]) }
3 β Popover management
Show on click, dismiss on click-outside. Deceptively tricky because NSPopover doesnβt tell you when it dismisses itself.
func setupPopover() { popover.contentSize = NSSize(width: 380, height: 480) popover.behavior = .transient // auto-dismiss on outside click popover.animates = true popover.contentViewController = NSHostingController(rootView: ContentView()) } @objc func togglePopover() { guard let button = statusItem.button else { return } if popover.isShown { popover.performClose(nil) } else { popover.show( relativeTo: button.bounds, of: button, preferredEdge: .minY ) // Bring to front so it receives key events immediately popover.contentViewController?.view.window?.makeKey() } }
4 β Window level (floating panel)
When you need the panel to float above all other apps:
func makeFloatingPanel() -> NSPanel { let panel = NSPanel( contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), styleMask: [ .nonactivatingPanel, // β magic: no focus steal .fullSizeContentView, .borderless ], backing: .buffered, defer: false ) panel.level = .floating panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] panel.isMovableByWindowBackground = true panel.backgroundColor = .clear panel.isOpaque = false panel.hasShadow = true panel.contentViewController = NSHostingController(rootView: PanelView()) return panel }
NSPanel with.nonactivatingPanel
is magic β clicks
inside donβt steal
focus from whatever
the user was typing
in. Perfect for
dictation apps.
5 β Global hotkey with CGEventTap
This is where most tutorials stop and most bugs live. Iβll cover it properly in its own section below.
The Accessibility challenge
The Accessibility
permission dialog
appears once.
If the user denies,
you must send them
to System Settings
β you cannot re-prompt.
Building a menu bar app that interacts with other apps β reading whatβs on screen, injecting keystrokes, monitoring global hotkeys β requires the Accessibility entitlement and usually the Input Monitoring entitlement too. Getting these wrong is the number one reason menu bar apps break after macOS updates.
Use the interactive checker below. Toggle which features your app needs and see which entitlements are required.
π Entitlement requirements checker
| Need? | Capability | Entitlement / Permission | Status |
|---|
Global key monitor β CGEventTap deep dive
CGEventTap callbacks
are C function pointers.
In Swift you bridge
them via a closure
stored in aUnmanaged context
or a global function.
This is the most technically dense part of any menu bar app, and itβs where OpenWhisper spent most of its early bug-squashing sessions. A CGEventTap lets you listen for (and optionally intercept) every key event system-wide β without the user having to click in your app first.
import CoreGraphics // The callback must be a C-compatible function. // In Swift we use a private global function as the bridge. private func eventTapCallback( proxy: CGEventTapProxy, type: CGEventType, event: CGEvent, refcon: UnsafeMutableRawPointer? ) -> Unmanaged<CGEvent>? { guard let refcon else { return Unmanaged.passRetained(event) } let delegate = Unmanaged<AppDelegate> .fromOpaque(refcon).takeUnretainedValue() if type == .keyDown { let keyCode = event.getIntegerValueField(.keyboardEventKeycode) if keyCode == 63 { // kVK_Function (FN key) DispatchQueue.main.async { delegate.startRecording() } return nil // consume the event } } if type == .keyUp { let keyCode = event.getIntegerValueField(.keyboardEventKeycode) if keyCode == 63 { DispatchQueue.main.async { delegate.stopRecording() } return nil } } return Unmanaged.passRetained(event) } func registerGlobalHotkey() { let mask: CGEventMask = ( (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue) ) let selfPtr = Unmanaged.passUnretained(self).toOpaque() guard let tap = CGEvent.tapCreate( tap: .cgSessionEventTap, place: .headInsertEventTap, options: .defaultTap, eventsOfInterest: mask, callback: eventTapCallback, userInfo: selfPtr ) else { print("β οΈ Failed to create event tap β Accessibility permission?") return } let source = CFMachPortCreateRunLoopSource(nil, tap, 0) CFRunLoop.main.add(source, forMode: .common) CGEvent.tapEnable(tap: tap, enable: true) }
CFRunLoop.main.
Re-registering after sleep is also essential. Wire it up in applicationDidFinishLaunching:
NotificationCenter.default.addObserver( forName: NSWorkspace.willSleepNotification, object: nil, queue: .main ) { [weak self] _ in self?.teardownEventTap() } NotificationCenter.default.addObserver( forName: NSWorkspace.didWakeNotification, object: nil, queue: .main ) { [weak self] _ in self?.registerGlobalHotkey() }
Popover vs Panel β Interactive comparison
Click on each approach to see its trade-offs in detail.
NSPopover
Attached arrow, auto-dismiss, traditional macOS feel
NSPanel
Free-floating, non-activating, draggable, persistent
NSPopover β the default choice
- Arrow automatically anchors to your status bar button
.transientbehavior dismisses on outside click β no event monitor needed- Captures keyboard focus when shown β great for forms and search
- Limitation: cannot be resized by the user; fixed
contentSize - Limitation: dismissed when the user switches Spaces or opens Mission Control
- SwiftUI hosted via
NSHostingControllerβ works perfectly
NSPanel β the power move
- Set
.nonactivatingPanelto prevent focus stealing β perfect for dictation - Set
level = .floatingto stay above all other app windows - Add
.canJoinAllSpacesto persist across Spaces - You manage show/hide yourself β dismiss on Escape with
NSEvent.addLocalMonitorForEvents - Complexity: you handle click-outside detection manually
- Best for: live transcript viewers, always-on timers, HUD overlays
Distributing without the App Store
Apple Siliconβs
Neural Engine makes
local ML models
viable in menu bar
apps in 2026 β
think on-device
Whisper, LLMs,
image classifiers.
Notarize and ship
direct β no App
Store tax.
Skipping the App Store means no 30% cut, no review delays, and no sandboxing restrictions that would cripple a hotkey monitor. The trade-off: you handle notarization yourself. Hereβs the full flow, with a live terminal animation showing the exact commands.
The four steps:
- Code-sign with your Developer ID Application certificate
- Notarize by submitting to Appleβs notarization service
- Wait for the JSON response (usually under 5 minutes)
- Staple the ticket so the app works offline
xcrun notarytool store-credentials to create a named profile, then reference it with --keychain-profile "YourProfile" instead of inline credentials. Never put passwords in shell scripts.
For GitHub Actions CI/CD, store your certificate as a base64-encoded secret and use the create-dmg action to produce a signed, notarized DMG automatically on every tagged release. OpenWhisperβs workflow does exactly this β the whole release pipeline is under 80 lines of YAML.
Common gotchas β flip to see the fix
Iβve hit every one of these. Flip the cards to see the solution.
YES in your Info.plist. This marks the app as a "UI Element" β no Dock icon, no App Switcher entry. Without this, every time your popover becomes key, a Dock icon flashes in.<true/>
AXIsProcessTrusted() at launch and prompt the user via AXIsProcessTrustedWithOptions with the prompt flag set to true.[kAXTrustedCheckOptionPrompt: true]
as CFDictionary)
LSSharedFileList API is deprecated. SMAppService.mainApp.register() persists across reboots without prompting.NSHostingController in applicationDidFinishLaunching, not lazily._ = popover.contentViewController?.view
CGEvent.tapEnable(tap:enable:true) β or tear down and re-create the tap entirely. Re-creating is more reliable.teardownEventTap()
registerGlobalHotkey()
Putting it all together β the OpenWhisper flow
Hereβs how all the pieces connect in a real app. The entire interaction loop for push-to-talk dictation:
// 1. FN key down β CGEventTap fires eventTapCallback // 2. AVAudioEngine starts capturing mic // 3. Status bar icon animates (pulsing red dot) // 4. FN key up β stop capture // 5. WhisperKit transcribes the audio buffer on-device // 6. Text is pasted into the frontmost app via CGEvent func pasteText(_ text: String) { // Write to pasteboard NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) // Synthesise Cmd+V let src = CGEventSource(stateID: .hidSystemState) let down = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: true)! let up = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: false)! down.flags = .maskCommand up.flags = .maskCommand down.post(tap: .cgAnnotatedSessionEventTap) up.post(tap: .cgAnnotatedSessionEventTap) }
Closing thoughts
Menu bar apps are the best kind of side project: small enough to ship in a weekend, useful enough to keep open all day, and technically deep enough that youβll learn something new every time you dig in.
I built OpenWhisper in roughly three weekends of focused work. The CGEventTap bugs alone took most of weekend two, which is partly why this post exists β so you donβt lose the same hours I did.
A few parting principles Iβve landed on:
- Do one thing. The icon takes up permanent space on every userβs screen. Justify the pixel.
- Respect focus. If you can use
.nonactivatingPanel, use it. Never steal the keyboard from the user. - Handle permissions gracefully. Guide the user to System Settings with a clear explanation, not just a crash.
- Warm up your SwiftUI views. The first-open flicker is the first impression. Donβt let it be a bad one.
- Re-register your event tap after sleep. Every time. No exceptions.
The full source for OpenWhisper β including the CGEventTap bridge, the WhisperKit integration, and the GitHub Actions notarization workflow β is on GitHub at github.com/malukenho/openwhisper. Go build something.
The whole OpenWhisper
codebase is under
2 000 lines of
Swift. Small is
a feature, not
a limitation.