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:
- The popover doesn't automatically close when clicking the desktop.
- Text fields refuse to accept focus because the app never officially "activates."
MenuBarExtrain.windowstyle 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 NSStatusItem, NSPopover, 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,"
NSWindowobjects belonging to it often refuse to become thekeyWindow, renderingTextFieldinputs 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:
- Info.plist Configuration: To hide the Dock icon.
- AppDelegate: To manage the
NSStatusItem. - PopoverManager: To encapsulate
NSPopoverlogic, 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.