Building a macOS menu bar app in Swift
Updated 2026 · a 9-minute read · for developers
Creating a status bar item is about four lines of Swift. Shipping a menu bar app that behaves correctly — no Dock icon, a hotkey that doesn't demand invasive permissions, an icon that doesn't vanish into the notch, and permissions that survive rebuilds — takes rather more, and most of it isn't in the documentation.
This is a field guide to the parts that cost us time while building Barkeep, a free menu bar organizer. All of it is public API; the source is on GitHub if you want a working reference.
1. The basic status item
The core is NSStatusBar. Keep a strong reference to the item — if it's deallocated,
your icon silently disappears, which is a classic first-run puzzle:
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
item.button?.image = NSImage(systemSymbolName: "chevron.left", accessibilityDescription: "Toggle")
item.button?.target = self
item.button?.action = #selector(clicked)
To handle left and right clicks differently, ask for both and inspect the current event:
item.button?.sendAction(on: [.leftMouseUp, .rightMouseUp])
@objc func clicked() {
if NSApp.currentEvent?.type == .rightMouseUp {
// show a menu
} else {
// primary action
}
}
2. No Dock icon, no menu bar menus
A menu bar utility shouldn't appear in the Dock or the ⌘-Tab switcher. Set the activation policy
to .accessory before the app finishes launching, and add
LSUIElement to your Info.plist:
NSApplication.shared.setActivationPolicy(.accessory)
One consequence worth knowing in advance: an LSUIElement agent's
NSLog output doesn't reliably reach the unified log. Debugging "did this run
at all?" becomes surprisingly hard. A cheap workaround is to write state into
UserDefaults with a timestamp and read it from the terminal:
defaults read com.you.yourapp
The timestamp matters — it's what tells you whether you're looking at a fresh result or a stale one from the previous build.
3. Global hotkeys without asking for Accessibility
This one materially affects how many people will install your app.
The obvious way to catch a global shortcut is
NSEvent.addGlobalMonitorForEvents. It works — and it silently requires
Accessibility permission. For a utility whose entire job is hiding icons, asking
for automation access just to catch ⌥⌘B is a bad trade; a meaningful share of users will decline
and never come back.
The alternative is Carbon's RegisterEventHotKey. It is old, it is C, and it is not
deprecated — and it needs no permissions at all:
var hotKeyRef: EventHotKeyRef?
var hotKeyID = EventHotKeyID(signature: OSType(0x424B5031), id: 1)
RegisterEventHotKey(UInt32(kVK_ANSI_B),
UInt32(cmdKey | optionKey),
hotKeyID,
GetApplicationEventTarget(),
0,
&hotKeyRef)
You install an event handler for kEventHotKeyPressed to receive it. More setup than
the Cocoa route, and worth every line for the permission you don't have to request.
4. Surviving the notch
Here's the one that will make you think your app is broken.
macOS places a new status item at the left end of the status area. On a MacBook with a notch and a reasonably full menu bar, that's exactly where the notch is — and macOS does not draw status items that land under the notch. Your item was created successfully, it reports a sensible frame, and it is completely invisible and unclickable.
The lever is a preference macOS reads when creating the item. Seed it in your own
UserDefaults before creating the status item:
let key = "NSStatusItem Preferred Position \(autosaveName)"
if UserDefaults.standard.object(forKey: key) == nil {
UserDefaults.standard.set(0, forKey: key) // claim a slot at the right end
}
Two non-obvious properties of that value:
- It reads as distance from the right edge, so a lower number sits further right — the opposite of most people's first guess.
- It's only consulted at creation time. It is not a live positioning API.
Seed it only when unset, as above. Otherwise you'll stomp the user's own ⌘-drag every launch, which is its own bug report.
5. The two things you cannot do
Worth knowing before you design a feature around either.
You cannot identify other apps' status items from the window list
CGWindowListCopyWindowInfo returns every status item with exact position and size —
and reports all of them as owned by Control Center, including your own. Modern
macOS renders status items out of that process, so there's no app attribution at that level. If
you need to know which icon belongs to which app, the Accessibility API
(AXExtrasMenuBar on each running app) is the only public route, and it requires the
Accessibility grant.
You cannot move another app's status item
There's no API for it. The saved order lives in the owning app's preferences and is only read when that app creates its item, so writing it doesn't take effect until that app relaunches.
Synthesising the ⌘-drag with CGEvent also does not work. We implemented it fully and
verified every link independently — permission active, event posting confirmed by reading the
cursor position back, correct target geometry, real modifier keydown, explicit click state, drag
slop, ~25 interpolated move events — and the item never moved. The window server appears to
require genuine HID input for that gesture. Budget your time accordingly.
6. Hiding icons needs no permissions at all
The technique every menu bar organizer uses is pure layout. Create a status item that draws nothing, then make it enormously wide:
spacer.length = collapsed ? 10_000 : 0
Because the status area packs right-to-left from the screen edge and clips the overflow, everything to the spacer's left is pushed off-screen. Nothing is deleted, no other process is touched, and it requires no entitlement or permission.
7. Shipping without an Xcode project
You can build a real .app with SwiftPM and a shell script — handy on machines with
only Command Line Tools installed. Compile the executable, then assemble the bundle by hand:
MyApp.app/
Contents/
Info.plist # CFBundleExecutable, CFBundleIdentifier, LSUIElement
MacOS/MyApp # the compiled binary
Resources/AppIcon.icns
Then sign it. This is where a subtle trap lives: ad-hoc signing
(codesign -s -) changes the app's designated requirement on every build,
because with no certificate to anchor to it's derived from the code hash. macOS ties permission
grants to that requirement, so every rebuild silently revokes your Accessibility grant while the
Settings toggle still looks enabled.
The fix is a self-signed certificate — no paid Apple account needed. What matters is that the certificate is stable, not that anyone trusts it. Sign with it and your designated requirement becomes bundle-ID-plus-certificate, which survives rebuilds. We wrote that up in detail in why macOS permissions vanish every time you rebuild.
8. Launch at login
On macOS 13 and later, SMAppService is the supported route and needs no helper
bundle for a simple agent:
try? SMAppService.mainApp.register() // enable
try? SMAppService.mainApp.unregister() // disable
It can throw if the app isn't in a location macOS trusts, so handle the failure and consider a LaunchAgent plist as a fallback rather than leaving the toggle silently broken.
Checklist
- Keep a strong reference to your
NSStatusItem. .accessorypolicy +LSUIElement; expect logging to be awkward.- Carbon
RegisterEventHotKeyfor shortcuts — no permission required. - Seed
NSStatusItem Preferred Position(lower = further right) only when unset. - Don't design around moving or identifying other apps' items from the window list.
- Sign with a stable self-signed certificate, never ad-hoc, if you need any TCC permission.
- Delete stray build copies of your app so you don't get duplicate Privacy entries.
Related: Why macOS permissions vanish every time you rebuild · Why no app can rearrange your menu bar icons · Why menu bar apps ask for Accessibility