ANTIGRAVITY LABJP
Articles/App Development
App Development/2026-04-18Advanced

Building a macOS Menu Bar App with Antigravity — SwiftUI × StatusItem × Shortcuts Integration Master Class

A hands-on guide to building a macOS menu bar app from scratch using Antigravity and SwiftUI. Covers MenuBarExtra, StatusItem, Apple Shortcuts, LaunchAtLogin, Notarization, and Universal Binary distribution.

macOSSwiftUI9Antigravity332MenuBarShortcutsApple Silicon2

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.app

The --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/MyApp

You 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.a

Choosing 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.


Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

App Dev2026-05-02
Building a SwiftUI Multimodal AI App with Antigravity and Gemma 4 — Camera, Photo Library, and Text Input in One Complete Guide
A complete implementation guide for building a multimodal AI app in SwiftUI using Antigravity and Gemma 4. Covers Core ML integration, camera and photo library handling, error management, and App Store privacy compliance.
App Dev2026-04-03
Antigravity × SwiftUI Live Activities & Dynamic Island: An Implementation Guide for iOS 26
A practical guide to building Live Activities and Dynamic Island with Antigravity AI: ActivityKit design, Dynamic Island UI, APNs background updates, plus production lessons from a 50M-download indie app portfolio.
App Dev2026-03-19
Antigravity × Xcode 26 × iOS 26 — iOS Developer Guide Before WWDC 2026
Advance iOS development with Antigravity IDE combined with Xcode 26 and iOS 26 SDK. Learn Swift 6.1, new UI frameworks, and Gemini 3.1 Pro AI-assisted development best practices.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →