Skip to main content

macOS Menu Bar Apps: SwiftUI Popovers and WindowManagement APIs

 Building a "Status Bar Only" application (agent app) on macOS seems deceptively simple: set a flag in Info.plist, use a MenuBarExtra, and you are done.

However, as soon as you attempt to add complex interactivity—text inputs, intricate state management, or custom window sizing—the abstraction leaks. You encounter the classic "Agent App" lifecycle problems:

  1. The popover doesn't automatically close when clicking the desktop.
  2. Text fields refuse to accept focus because the app never officially "activates."
  3. MenuBarExtra in .window style lacks fine-grained control over positioning relative to the screen edge.

To build a production-grade utility that feels like native macOS Control Center modules, we must bypass the MenuBarExtra wrapper and orchestrate NSStatusItemNSPopover, and the NSApplication activation policy manually.

The Root Cause: Application Activation Policy

The core issue lies in how macOS treats applications defined as LSUIElement: 1 (Application is agent).

Standard macOS apps run with an activation policy of .regular. They appear in the Dock, have a menu bar, and the Window Server automatically grants them key focus when a window is clicked.

Agent apps run with an activation policy of .accessory.

  • No Dock Icon: They are invisible to the Cmd+Tab switcher.
  • Passive Activation: The Window Server does not automatically promote the app to the "active" state when a user clicks the menu bar icon.
  • Focus Rejection: Since the app isn't "active," NSWindow objects belonging to it often refuse to become the keyWindow, rendering TextField inputs useless.

Furthermore, NSPopover relies on the responder chain to detect clicks outside its bounds to trigger closure. If the app isn't active, it doesn't receive the global mouse events required to know the user clicked away.

The Solution: The NSStatusItem & NSPopover Bridge

We will implement a robust solution using the NSApp lifecycle. We need three components:

  1. Info.plist Configuration: To hide the Dock icon.
  2. AppDelegate: To manage the NSStatusItem.
  3. PopoverManager: To encapsulate NSPopover logic, event monitoring, and force-activation.

Step 1: Info.plist Configuration

In your target settings (under the Info tab), add or modify the following key:

  • Key: Application is agent (UIElement)
  • Value: YES

Step 2: The Application Entry Point

We abandon the standard WindowGroup in favor of a pure NSApplicationDelegate approach. This prevents SwiftUI from attempting to create a default window.

import SwiftUI

@main
struct MenuBarUtilityApp: App {
    // Connect the AppDelegate to the SwiftUI lifecycle
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
    var body: some Scene {
        // We do not return a WindowGroup here.
        // The UI is entirely managed by the AppDelegate and NSPopover.
        Settings {
            EmptyView()
        }
    }
}

Step 3: The Popover Manager

This class is the engine. It wraps NSPopover and handles the critical NSApp.activate call that fixes the input focus issues. It also sets the behavior to .transient, which tells macOS to close the popover when the user interacts with another part of the UI.

import SwiftUI
import AppKit

class PopoverManager: NSObject {
    private var popover: NSPopover!
    private var statusBarItem: NSStatusItem!
    
    override init() {
        super.init()
        
        // 1. Initialize the Popover
        self.popover = NSPopover()
        self.popover.behavior = .transient // Critical: Closes when clicking outside
        self.popover.animates = true
        
        // 2. Embed the SwiftUI View
        // We use NSHostingController to bridge SwiftUI into AppKit
        let contentView = ContentView()
        self.popover.contentViewController = NSHostingController(rootView: contentView)
        
        // 3. Create the Status Bar Item
        self.statusBarItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
        
        if let button = self.statusBarItem.button {
            // Use a system symbol or asset
            button.image = NSImage(systemSymbolName: "command.circle.fill", accessibilityDescription: "Menu Bar App")
            button.action = #selector(togglePopover(_:))
            button.target = self
        }
    }
    
    @objc func togglePopover(_ sender: AnyObject?) {
        guard let button = self.statusBarItem.button else { return }
        
        if self.popover.isShown {
            self.popover.performClose(sender)
        } else {
            show(button: button)
        }
    }
    
    private func show(button: NSStatusBarButton) {
        // 1. Show the popover anchored to the status bar button
        self.popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
        
        // 2. CRITICAL: Force activation
        // Without this, text fields inside the popover will not accept focus
        // because "Agent" apps do not automatically become the active application.
        NSApp.activate(ignoringOtherApps: true)
        
        // 3. Ensure the window becomes key so it can handle keyboard events immediately
        self.popover.contentViewController?.view.window?.makeKey()
    }
}

Step 4: The App Delegate

The AppDelegate simply retains our manager. If we don't hold a strong reference to PopoverManager, it will be deallocated immediately after launch.

import Cocoa

class AppDelegate: NSObject, NSApplicationDelegate {
    var popoverManager: PopoverManager?

    func applicationDidFinishLaunching(_ notification: Notification) {
        // Initialize the manager when the app launches
        popoverManager = PopoverManager()
    }
}

Step 5: The SwiftUI View (with State Management)

To prove the solution handles focus and state correctly, here is a view with an interactive TextField. In a broken implementation, clicking this field would result in no cursor appearing.

import SwiftUI

struct ContentView: View {
    @State private var inputText: String = ""
    @State private var isToggleOn: Bool = false
    
    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            HStack {
                Text("System Status")
                    .font(.headline)
                Spacer()
                Circle()
                    .fill(Color.green)
                    .frame(width: 8, height: 8)
            }
            
            Divider()
            
            TextField("Enter command...", text: $inputText)
                .textFieldStyle(.roundedBorder)
                .frame(minWidth: 250)
            
            Toggle("Enable Background Processing", isOn: $isToggleOn)
            
            HStack {
                Spacer()
                Button("Execute") {
                    print("Executing: \(inputText)")
                    // Optional: Close popover on action
                    NSApp.sendAction(#selector(NSPopover.performClose(_:)), to: nil, from: nil)
                }
                .buttonStyle(.borderedProminent)
            }
        }
        .padding()
        .frame(width: 300, height: 200) // Explicit frame prevents resizing glitches
        .background(VisualEffectView(material: .popover, blendingMode: .behindWindow))
    }
}

// Helper for native blur background
struct VisualEffectView: NSViewRepresentable {
    let material: NSVisualEffectView.Material
    let blendingMode: NSVisualEffectView.BlendingMode
    
    func makeNSView(context: Context) -> NSVisualEffectView {
        let view = NSVisualEffectView()
        view.material = material
        view.blendingMode = blendingMode
        view.state = .active
        return view
    }
    
    func updateNSView(_ nsView: NSVisualEffectView, context: Context) {
        nsView.material = material
        nsView.blendingMode = blendingMode
    }
}

Why This Works

1. The transient Behavior

By setting self.popover.behavior = .transient, we leverage AppKit's built-in event monitor. The system automatically creates a local event monitor that detects mouse down events outside the popover's window frame. This creates the expected "lightweight" feel of a menu bar app.

2. Force Activation

The line NSApp.activate(ignoringOtherApps: true) is the difference between a read-only display and a functional app. When LSUIElement is true, the OS assumes your app is a background helper. By explicitly calling activate, you force the Window Server to shift keyboard focus to your application process. Without this, the TextField in SwiftUI is technically visible, but the key-down events are still being sent to the previous app (e.g., Xcode or Chrome).

3. Lifecycle Integrity

By using @NSApplicationDelegateAdaptor and retaining the PopoverManager manually, we prevent SwiftUI from managing the window lifecycle. MenuBarExtra tries to manage this for you, but in current macOS versions, it often tears down the view hierarchy too aggressively when the menu closes, leading to lost state (@State properties resetting to default). With NSPopover, the contentViewController remains in memory even when the popover is hidden, preserving the text inside your TextField.

Conclusion

SwiftUI is excellent for rendering the content of your menu bar app, but MenuBarExtra is not yet mature enough for complex, interactive "agent" applications. By dropping down to AppKit for the window management layer (NSPopover) while keeping SwiftUI for the view layer (NSHostingController), you get the best of both worlds: modern declarative UI with robust, native macOS window behavior.