A small app living in the macOS menu bar — always within reach, never in the way. Once you experience this UX, it's hard to go back. As an indie developer who has spent years shipping iOS apps, my motivation for exploring macOS menu bar apps was simple: covering both iOS and macOS expands your potential user base without requiring an entirely different skill set.
At first, the learning curve caught me off guard. I knew SwiftUI, but NSStatusItem, NSPopover, MenuBarExtra — macOS-specific APIs are scattered across documentation that often feels fragmented. Antigravity changed that. Instead of hunting for pieced-together answers, I had a collaborator who could say: "Given your architecture, here's how I'd connect these two pieces." That shift from searching to conversing made the difference.
In this article, I'll share everything I learned building a production-ready macOS menu bar app, complete with working code.
Why Menu Bar? The Case for This UX Pattern
There are two kinds of always-on desktop apps: Dock-resident and menu bar-resident. For utility and productivity tools, the menu bar wins for three concrete reasons.
Zero launch cost. Reaching the menu bar requires minimal mouse travel and no context switch. Your app is never more than one click away from any workspace.
Non-intrusive presence. Timers, weather widgets, clipboard managers, volume controls — these tools earn their place by staying out of the way until needed. A floating window or Dock badge creates visual noise. A menu bar icon doesn't.
Setapp economics. Setapp is a macOS-focused subscription marketplace where menu bar apps perform particularly well in terms of user retention. For indie developers, it offers a meaningful monthly revenue baseline without requiring App Store approval cycles for every update.
Setting Up Antigravity for macOS Development
Before writing a single line of Swift, place a PROJECT_CONTEXT.md file at your project root. This small step significantly improves the quality of Antigravity's suggestions.
# Project Context
## Platform
macOS 13+ (Universal Binary: Apple Silicon + Intel)
Swift 5.10 / SwiftUI + AppKit hybrid
Deployment target: macOS 13.0
## App Type
MenuBar-only app (no Dock icon)
LSUIElement = YES in Info.plist
## Key Frameworks
- SwiftUI (MenuBarExtra API)
- AppKit (NSStatusItem for advanced customization)
- ServiceManagement (LaunchAtLogin)
- AppIntents (Shortcuts integration)
## Architecture
MVVM. ObservableObject for state. No UIKit dependency.With this file in place, Antigravity's answers become immediately more concrete. When you ask "should I use MenuBarExtra or manage NSStatusItem directly?", it reasons from your deployment target and architecture rather than giving you a generic overview.
In my experience, Antigravity doesn't just provide code — it explains the tradeoff. That's what makes it useful for macOS, where the AppKit/SwiftUI boundary requires constant judgment calls.
SwiftUI × MenuBarExtra: Your First Running Menu Bar App
Introduced in macOS 13, the MenuBarExtra API brings native SwiftUI support to menu bar development. Here's the minimal working configuration:
// MyMenuBarApp.swift
import SwiftUI
@main
struct MyMenuBarApp: App {
var body: some Scene {
MenuBarExtra("MyApp", systemImage: "star.fill") {
ContentView()
}
.menuBarExtraStyle(.window) // or .menu for standard NSMenu style
}
}Two things to note: .menuBarExtraStyle(.window) gives you a free-form custom UI, while .menu renders a standard macOS dropdown menu. Most serious menu bar apps use .window.
Don't forget Info.plist. To hide the Dock icon — which is almost always what you want — set LSUIElement to YES:
<\!-- Info.plist -->
<key>LSUIElement</key>
<true/>Without this, your app shows up in both the menu bar and the Dock. I made this mistake on my first build. When I described the symptom to Antigravity ("why does my menu bar app have a Dock icon?"), it immediately pointed to LSUIElement and explained the WindowManager implications.
Custom Popover UI: SwiftUI + AppKit Hybrid Design
The .window style in MenuBarExtra has size constraints — roughly half the screen width at most. For tighter control over window dimensions, positioning, or behavior, the NSStatusItem + NSPopover combination gives you full flexibility.
// StatusBarController.swift
import AppKit
import SwiftUI
class StatusBarController: NSObject {
private var statusItem: NSStatusItem
private var popover: NSPopover
override init() {
statusItem = NSStatusBar.system.statusItem(
withLength: NSStatusItem.squareLength
)
popover = NSPopover()
popover.contentSize = NSSize(width: 320, height: 480)
popover.behavior = .transient // auto-close when clicking elsewhere
popover.contentViewController = NSHostingController(
rootView: ContentView()
)
super.init()
if let button = statusItem.button {
button.image = NSImage(
systemSymbolName: "star.fill",
accessibilityDescription: "MyApp"
)
button.action = #selector(togglePopover)
button.target = self
}
}
@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
)
popover.contentViewController?.view.window?.makeKeyAndOrderFront(nil)
}
}
}To wire this into your App, use @NSApplicationDelegateAdaptor. Ask Antigravity "how do I connect StatusBarController to my App struct" — it will adapt the answer to your project's specific architecture rather than giving you a one-size-fits-all snippet.
Sharing State Between SwiftUI and AppKit
The cleanest approach is an ObservableObject passed via @EnvironmentObject:
// AppState.swift
import Foundation
@MainActor
class AppState: ObservableObject {
@Published var isRunning: Bool = false
@Published var message: String = ""
func toggle() {
isRunning.toggle()
message = isRunning ? "Running" : "Stopped"
}
}// MyMenuBarApp.swift
@main
struct MyMenuBarApp: App {
@StateObject private var appState = AppState()
var body: some Scene {
MenuBarExtra(
"MyApp",
systemImage: appState.isRunning ? "play.fill" : "stop.fill"
) {
ContentView()
.environmentObject(appState)
}
.menuBarExtraStyle(.window)
}
}The menu bar icon itself reacts to appState.isRunning — a level of reactivity that's hard to achieve with NSStatusItem alone and one of the genuine advantages of the MenuBarExtra API.
Apple Shortcuts Integration: Fitting Into Existing Workflows
Starting with macOS Monterey, apps can expose actions to Apple Shortcuts. For a menu bar app, this means users can trigger your app's core behavior from Shortcuts automations, keyboard shortcut triggers, or even Siri — without touching your UI.
The implementation uses the AppIntents framework:
// Intents/ToggleAppIntent.swift
import AppIntents
struct ToggleRunningIntent: AppIntent {
static var title: LocalizedStringResource = "Toggle Running State"
static var description = IntentDescription("Starts or stops MyApp")
@Parameter(title: "Custom Message")
var customMessage: String?
func perform() async throws -> some IntentResult & ProvidesDialog {
await MainActor.run {
AppStateStore.shared.appState.toggle()
if let msg = customMessage {
AppStateStore.shared.appState.message = msg
}
}
let resultMessage = AppStateStore.shared.appState.isRunning
? "MyApp started"
: "MyApp stopped"
return .result(dialog: IntentDialog(stringLiteral: resultMessage))
}
}AppIntent is fully async-compatible. When you show this code to Antigravity and say "I want to add error handling and a parameter validation step," it generates the extended perform() body and adds the appropriate throws types without requiring you to read through the AppIntents documentation yourself.
Auto-Registration in Shortcuts App
struct MyAppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: ToggleRunningIntent(),
phrases: [
"Toggle \(.applicationName)",
"Start \(.applicationName)"
],
shortTitle: "Toggle",
systemImageName: "play.pause.fill"
)
}
}With AppShortcutsProvider implemented, your action appears in the Shortcuts app immediately after installation — no setup required from the user.
LaunchAtLogin, Global Hotkeys, and URL Schemes
LaunchAtLogin with ServiceManagement
macOS 13+ provides a clean API for this:
import ServiceManagement
func enableLaunchAtLogin() throws {
try SMAppService.mainApp.register()
}
func disableLaunchAtLogin() throws {
try SMAppService.mainApp.unregister()
}
var isLaunchAtLoginEnabled: Bool {
SMAppService.mainApp.status == .enabled
}A common mistake is calling register() without checking the current status first. If the app is already registered, calling register() throws an error. Always check status before toggling:
func toggleLaunchAtLogin() throws {
if SMAppService.mainApp.status == .enabled {
try SMAppService.mainApp.unregister()
} else {
try SMAppService.mainApp.register()
}
}URL Scheme for External Automation
Register your URL scheme in Info.plist, then handle incoming URLs:
func application(_ application: NSApplication, open urls: [URL]) {
for url in urls {
handleURL(url)
}
}
func handleURL(_ url: URL) {
guard url.scheme == "myapp",
let action = url.host,
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
else { return }
switch action {
case "toggle":
AppStateStore.shared.appState.toggle()
case "message":
let text = components.queryItems?
.first(where: { $0.name == "text" })?.value
AppStateStore.shared.appState.message = text ?? ""
default:
break
}
}Once you have this in place, n8n, Zapier, and shell scripts can all trigger your app with a simple URL call: open "myapp://toggle". It's an underrated integration surface for productivity apps.
Common Pitfalls: Sandbox, Notarization, and Distribution Traps
Pitfall 1: Sandbox File Access
Mac App Store submission requires sandboxing. Sandboxed apps can't access files outside their container without explicit user permission. The solution is NSOpenPanel for selection and Security-Scoped Bookmarks for persistent access:
func saveBookmark(for url: URL) throws -> Data {
return try url.bookmarkData(
options: .withSecurityScope,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
}
func restoreAccess(from bookmarkData: Data) throws -> URL {
var isStale = false
let url = try URL(
resolvingBookmarkData: bookmarkData,
options: .withSecurityScope,
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
guard \!isStale else { throw AppError.bookmarkStale }
// Must call startAccessingSecurityScopedResource before accessing the file
url.startAccessingSecurityScopedResource()
return url
}Tell Antigravity "apply this bookmark pattern across all file access in the project" and it will refactor your FileManager calls into a wrapper that handles bookmark lifecycle consistently.
Pitfall 2: Notarization Step by Step
After archiving in Xcode, run these commands in order:
# Export the archive
xcodebuild -exportArchive \
-archivePath MyApp.xcarchive \
-exportPath ./export \
-exportOptionsPlist ExportOptions.plist
# Submit for Notarization
xcrun notarytool submit ./export/MyApp.zip \
--apple-id "YOUR_APPLE_ID" \
--team-id "YOUR_TEAM_ID" \
--password "YOUR_APP_SPECIFIC_PASSWORD" \
--wait
# Staple the ticket to the app
xcrun stapler staple ./export/MyApp.appThe --wait flag on notarytool submit blocks until Apple's notarization service completes — typically 1–5 minutes. Without it, you'd need a separate status check command. Always staple after notarization; stapled apps can be verified offline, which matters for users without a continuous internet connection.
Pitfall 3: Popover Loses Focus
When NSPopover is set to .transient behavior, text fields inside the popover sometimes don't receive keyboard input. Fix it by explicitly activating the window after display:
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.popover.contentViewController?.view.window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}NSApp.activate(ignoringOtherApps: true) feels aggressive, but menu bar apps genuinely need it. Without activation, your app is technically in the background even when its popover is visible, and text fields won't capture keystrokes. Antigravity will suggest this pattern if you describe the symptom: "my popover text field won't accept keyboard input."
Universal Binary and Release Strategy
Building for Apple Silicon + Intel
Xcode generates a Universal Binary by default when you archive. To verify:
# Check the architectures included in your app binary
lipo -info MyApp.app/Contents/MacOS/MyAppYou should see arm64 x86_64. If a Swift Package dependency is single-architecture, you'll need to find a Universal-compatible version or use lipo to manually merge static libraries:
lipo -create arm64/SomeLib.a x86_64/SomeLib.a -output universal/SomeLib.aChoosing Your Distribution Strategy
- Mac App Store: Strong discoverability, trust signal from Apple review, but 30% commission, sandboxing required, and review delays for every update.
- Direct Sales (Gumroad, Paddle, Lemon Squeezy): Sub-10% commission, no review process, full pricing control. You own the customer relationship. Requires Notarization but not sandboxing.
- Setapp: Flat monthly developer payout regardless of install count, high-quality user base, editorial curation. Revenue predictability varies; solo developers often find it most valuable as a secondary channel.
My recommended approach for first-time macOS developers: ship via Direct Sales first. You can iterate on the same day feedback arrives. Once the app is stable and you understand the use case more deeply, then evaluate the Mac App Store or Setapp based on your growth goals.
What to Build Next
The hardest part of macOS menu bar development isn't any one API — it's knowing where the AppKit/SwiftUI boundary should sit in your specific project. That's where Antigravity adds the most value: not as a code autocomplete tool, but as a reasoning partner for architectural decisions that the official documentation doesn't make explicit.
If you're starting today, begin with a MenuBarExtra in .window style, display a simple counter in your ContentView, and get it running. Once you have something working, extend it incrementally with Antigravity — "add a preference window," "add Shortcuts integration," "make the icon reflect state." Small steps with a clear objective each time.
Menu bar apps tend to attract users who stick around. The retention characteristics are different from iOS — you're not competing in a browse-and-discard store; you're building something that lives quietly on someone's machine for years. That's a different relationship with your users, and in my experience, a more rewarding one.