Skip to content
C.W.K.
Stream
Lesson 04 of 06 · published

Menu-Bar Utilities and the Icon Nobody Can See

~15 min · macos-citizen, menu-bar, nsstatusitem, lsuielement, diagnostics

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Ask who owns a window before you count it."

An App Without a Dock Icon

Most of the family's utilities live in the menu bar: a text transformer, a dictation tool, launchers for the voice and quest engines. They share one shape. LSUIElement = true in Info.plist (or setActivationPolicy(.accessory) in code, which does the same after launch) keeps the app out of the Dock and the app switcher and gives it no main menu. An NSStatusItem from NSStatusBar.system is its whole visible surface, with a template image so the icon follows light, dark and tinted menu bars, and an NSMenu that opens from it.

Two details come with that shape. An accessory app is not the active app just because its window exists, so a Settings window opened from the menu can appear behind whatever was frontmost. Order the window front and call NSApp.activate(), available since macOS 14. That activation is cooperative, so macOS can decline it while someone is working in another app. The AppKit header marks the older activate(ignoringOtherApps:) for deprecation and names this call as its replacement. And keep one NSMenu for the app's lifetime, repopulating its items in place: assigning a fresh menu to item.menu from menuWillOpen leaves the menu that is already tracking stale.

Running, Healthy, and Invisible

The voice launcher ran perfectly on a notched MacBook. launchd showed it running, its log was clean, it reached its engine. There was no icon. An external probe with CGWindowListCopyWindowInfo found zero status-item windows for its pid, which pointed at "the item was never created" and sent the investigation through launch paths and construction timing. Every one of those was eliminated by experiment. Three facts were stacked instead:

  1. On macOS 26, a status item's window belongs to Control Center, not to the app. The probe filtered on the app's pid, so it could not have found the item. Its zero was a property of the filter. Measured on macOS 26.6, Control Center's layer-25 windows carry each item's bundle identifier as the window name, with a frame.
  2. A menu-bar manager was running, and it adopts new items into its hidden section, parked far offscreen.
  3. With the manager quit, the items it had stranded still pushed the layout, and macOS placed the new item under the notch. isVisible was true, and the frame sat in the middle of a 1728-point-wide display, exactly where no pixel exists.

The diagnostic ladder that finally worked: process and launchd state, then the external window probe, then the app's saved NSStatusItem Visible preference, then a bare ten-line status-item test app, then the app printing its own item's frame. Only the last rung, as the rungs were run, could see the answer.

Make the Invisible Case Greppable

The fix that stayed was not a code change to the icon. It was one log line at birth: two seconds after creating the item, the app prints isVisible and the frame of the item's own window. "Running but no icon" is now a grep, and the frame tells you whether to look under the notch, in a menu-bar manager, or at a user who hid the item.

Code

main.swift for a menu-bar utility that reports where its own icon landed·swift
import AppKit

@MainActor
final class StatusBarController: NSObject {
    // Hold the item: you need it to change the icon, to remove it, and to ask where it landed.
    private let item: NSStatusItem
    private let menu = NSMenu()
    private let settingsWindow: NSWindow

    init(settingsWindow: NSWindow) {
        self.settingsWindow = settingsWindow
        item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
        super.init()
        item.button?.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Spark")
        item.button?.image?.isTemplate = true   // follows light, dark and tinted menu bars

        let settings = NSMenuItem(title: "Settings…", action: #selector(showSettings), keyEquivalent: ",")
        settings.target = self
        menu.addItem(settings)
        menu.addItem(.separator())
        menu.addItem(NSMenuItem(title: "Quit Spark", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
        item.menu = menu

        // The item's window belongs to Control Center, so a probe filtering on this pid finds nothing. Report it here.
        Task { [weak self] in
            try? await Task.sleep(for: .seconds(2))
            guard let self else { return }
            let frame = self.item.button?.window?.frame
            print("spark: status item isVisible=\(self.item.isVisible) frame=\(frame.map { "\($0)" } ?? "none")")
        }
    }

    @objc private func showSettings() {
        settingsWindow.makeKeyAndOrderFront(nil)
        NSApp.activate()   // an accessory app is not frontmost until it asks
    }
}

let app = NSApplication.shared
app.setActivationPolicy(.accessory)   // the code twin of LSUIElement = true in Info.plist
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 420, height: 280),
                      styleMask: [.titled, .closable], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
let statusBar = StatusBarController(settingsWindow: window)
app.run()

External links

Exercise

Build the menu-bar app above as a single main.swift under Swift 6 and run it. Record the frame it prints and compare it with the screen's frame. Extend the birth log so it also reports whether the item's frame intersects the notch gap, computed from NSScreen.auxiliaryTopLeftArea and auxiliaryTopRightArea, and print "no notch" when either is nil. Finally, from a separate script, list windows with CGWindowListCopyWindowInfo, check whether any entry belongs to your app's pid, then find the Control Center window named with your bundle identifier and compare its frame with the one the app printed.
Hint
The gap runs from the left area's maxX to the right area's minX. Both frames are in screen coordinates with the origin at the bottom left of the main display. A standalone script can call CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) and filter on kCGWindowOwnerPID. The window name is kCGWindowName, and Control Center status-item windows sit at kCGWindowLayer 25.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.