DEV Community

Cover image for Wrapping It in a Menu Bar App (and Three Things That Broke)
Paul Contreras
Paul Contreras Subscriber

Posted on

Wrapping It in a Menu Bar App (and Three Things That Broke)

Part 4 of a series on building a native Razer mouse controller for macOS.


The protocol works from a command line tool. Now I want it living in the menu bar: a little mouse icon, click it, set DPI and color, no Terminal. SwiftUI has MenuBarExtra for exactly this, so it should be quick.

It was not quick. Three separate things broke, none of them about the mouse. This is the part where the OS fights you.


The Crash Before Anything Renders

The starting point. An accessory app (menu bar only, no Dock icon) usually sets that with:

@main
struct RazerControlApp: App {
    init() {
        NSApp.setActivationPolicy(.accessory)
    }
    var body: some Scene { ... }
}
Enter fullscreen mode Exit fullscreen mode

Launched it. Instant crash:

Fatal error: Unexpectedly found nil while implicitly unwrapping an Optional value
RazerControlApp.swift:15
Enter fullscreen mode Exit fullscreen mode

NSApp is NSApplication!, an implicitly unwrapped optional. It's nil this early. The app object's init() runs before the shared application is set up, so NSApp is still nothing and the force-unwrap explodes.

This bites harder on newer projects because the default actor isolation is MainActor, and the timing of init() lands before NSApp exists. You can fight the lifecycle, or you can not use code for this at all.

The fix is a plist key. LSUIElement = YES (in build settings, INFOPLIST_KEY_LSUIElement). It's read by the launch machinery before your App struct is ever created, so there's no race and no NSApp to unwrap. Delete the init() entirely.

INFOPLIST_KEY_LSUIElement = YES
Enter fullscreen mode Exit fullscreen mode

No code, no crash, no Dock icon. The thing I was writing code to do was a setting the whole time.


macOS Won't Let the App Touch the Mouse

App launches now. Connects to the device. Immediately:

TCC deny IOHIDDeviceOpen
RazerControl: device not found
Enter fullscreen mode Exit fullscreen mode

IOHIDDeviceOpen on a mouse needs Input Monitoring permission. The command line tool worked the whole time because it ran from Terminal, and Terminal already had Input Monitoring. A fresh signed .app has nothing.

macOS won't pop the permission dialog on its own. You have to ask, explicitly, with the right call:

let access = IOHIDCheckAccess(kIOHIDRequestTypeListenEvent)
if access == kIOHIDAccessTypeUnknown {
    IOHIDRequestAccess(kIOHIDRequestTypeListenEvent)
} else if access == kIOHIDAccessTypeDenied {
    // user said no earlier — send them to System Settings
}
Enter fullscreen mode Exit fullscreen mode

kIOHIDRequestTypeListenEvent is imported as a plain constant, not a Swift enum case, so it's kIOHIDRequestTypeListenEvent and not .listenEvent. That cost me a compile error and a confused minute.

First launch now shows the system prompt. If the user denied it before, the prompt won't come back, so the app needs to detect that case and point them at System Settings rather than silently failing.


MenuBarExtra Has Two Personalities

The popover opened, but the layout was wrong. Everything stacked vertically like a dropdown menu. My HStack of buttons rendered as separate rows. The color picker wouldn't open. Frame widths were ignored.

MenuBarExtra has two render styles. The default is .menu, which puts your content inside a real NSMenu, so every view becomes a menu item and normal layout doesn't apply. The other is .window, which renders your SwiftUI in an actual floating panel where layout works like everywhere else.

MenuBarExtra("RazerControl", systemImage: "computermouse.fill") {
    MenuBarView()
}
.menuBarExtraStyle(.window)
Enter fullscreen mode Exit fullscreen mode

One modifier. Suddenly HStacks are horizontal, widths apply, the popover looks like a popover. If your menu bar layout is behaving strangely, this is almost certainly why.


The Color Picker That Wouldn't Open

Last one. SwiftUI's ColorPicker renders a little well; click it and the system color panel opens. Except in my app, clicking did nothing.

ColorPicker relies on NSColorPanel. An accessory app (the LSUIElement thing from earlier) can't reliably bring up that panel, because it isn't allowed to become active the normal way, and the menu bar window dismisses the moment focus moves. So the panel either opens behind everything or never shows. The "selection" never happens, so the color never changes. One root cause, two symptoms.

I could have fought the activation policy. Instead I asked what a menu bar RGB control actually needs, and the answer is not a full color wheel. It's a handful of good colors, one tap.

So I replaced the picker with a row of preset swatches:

private let palette: [Color] = [
    .red, .orange, .yellow, .green, .cyan, .blue, .purple, .pink, .white
]
// tap a swatch -> apply immediately, ring the selected one
Enter fullscreen mode Exit fullscreen mode

No NSColorPanel, no activation problem, and honestly better for a quick menu. Tap red, the logo's red. Done. The "fix" was less code and a nicer interaction than the thing that was broken.


What's Next

Part 5 is the button remapping, which is the part I was most sure would be easy and turned out to be the hardest. I spent an evening trying to reverse-engineer the firmware command for it, got the device to accept my writes, and watched nothing happen. Then I gave up on the firmware entirely and solved it a different way. That story is its own post.

Top comments (0)