Status Bar App

(This post is based on https://www.raywenderlich.com/98178/os-x-tutorial-menus-popovers-menu-bar-apps), expanded and updated for Swift 3. This seems to be the only tutorial in town – at least I’ve seen a couple of other tutorials that were suspiciously similar, right down to the architecture and the location of the event monitoring code.)
Xcode 8.0, macOS 10.11

1) Start simple:

a) Create a new Mac app. If you’re using .xib files, go to the main menu, select the window, and untick ‘visible at launch’ (you can also delete it and the reference to it in the app delegate if you are building an app that will only show a menu). If you’re using storyboards, uncheck ‘initialController’ for the provided WindowController.

b) add an icon to your asset catalogue – this will appear in the menu bar. Some sources recommend using only black-and-white (Dropbox does this, for instance), but I am running apps with merry multi-coloured icons which at least stand out; I have not seen any problems with the dark menubar setting, though you should make sure that the icon is still distinguishable at the smallest size. (My current one is not. Using shape as well as colour seems a good idea.)

c) Add the following to the appDelegate:

let statusItem = NSStatusBar.system().statusItem(withLength: NSSquareStatusItemLength)

Add this to applicationDidFinishLaunching:

if let button = statusItem.button {
button.image = NSImage(named: "StatusBarButtonImage")
button.action = #selector(AppDelegate.buttonWasPressed)
}

This will fire the specified function every time you click on the menu bar symbol. Hurray!

2) Menu

To create a menu, add the following to applicationDidFinishLaunching:

let menu = NSMenu()

menu.addItem(NSMenuItem(title: "Action 1", action: #selector(AppDelegate.action1(sender:)), keyEquivalent: "e"))
menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "Action 2", action: #selector(AppDelegate.action2(sender:)), keyEquivalent: "k"))

statusItem.menu = menu

Add IBActions called action1 and action2 to your AppDelegate class.

If you build and run now, clicking on the icon will cause the menu to drop down and the previous action will be ignored. As a good citizen, you should remove the action from the button if your app uses a menu. (Here, you should merely comment it out, we’ll want it back in a moment.)

3) Popover window

Most of the menu bar apps I use at the moment choose this option though you can add considerable information to a menu these days. (For an example, see http://footle.org/WeatherBar/. In fact, go look at this anyway, because it keeps the relevant code out of the AppDelegate, which is a better architecture)

a) If you’re using Xib files, you need to create a subclass of NSViewController – XibBasedController.swift – and its associated .xib file with the same name.
b) If you’re using storyboards, things are a little more complicated; but this is a perfect location for a factory method on the class which returns an instance instantiated from the storyboard. (ObjectiveC uses this pattern a lot – a class method that returns an instancetype; Swift very rarely does. Mainly, I guess, because Swift’s initialisers are so much more flexible.)

class func loadFromStoryboard() -> StoryboardBasedController{
let storyboard = NSStoryboard(name: "Main", bundle: nil)
return storyboard.instantiateController(withIdentifier: "storyboardBasedController") as! StoryboardBasedController
}

Set the class of the viewController from the generic ‘ViewController’ to ‘StoryboardBasedController’ and make sure that you give it the same ID string in the Identity Inspector that you use in your method.

While you’re here, add some content – a label or button – to your viewController’s view so that you’ll recognise it when you see it.

4) Connecting the popover

a) Add a property to the AppDelegate:
let popover = NSPopover()
and set the contentController of the popover in applicationDidFinishLaunching

popover.contentViewController = StoryboardBasedController.loadFromStoryboard()
or
popover.contentViewController = XibBasedController(nibName: "XibBasedController", bundle: nil)

b) Remove the menu code from applicationDidFinishLaunching and replace the button’s action with
button.action = #selector(AppDelegate.togglePopover(sender:))

c) write the togglePopover(sender:) function since you’ve just promised one and the compiler won’t like it if you don’t:

func togglePopover(sender: AnyObject?) {
if popover.isShown {
closePopover(sender: sender)
} else {
showPopover(sender: sender)
}
}

and provide the two functions in question:

func showPopover(sender: AnyObject?) {
if let button = statusItem.button {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY)
}
}

func closePopover(sender: AnyObject?) {
popover.performClose(sender)
}

Build and run. Your app will now show a popover when you click on its icon in the menu, and hide the popover when you click the icon again.

5) Quit the app

Since menu bar apps do not have proper menus, there is no File menu with a Quit item. You need to provide this functionality somewhere. For an app with a popover, this is usually implemented in a ‘settings’ menu. Here, for the sake of brevity, well simply add a ‘go away’ button to our window and an appropriate action to the corresponding NSViewController subclass:

@IBAction func quitApp(_ sender: AnyObject) {
NSApplication.shared().terminate(sender)
}

6) Don’t quite make the popover go away
Right now, the popover appears when you click on the menu icon, and disappears when you click it again. It does not behave like popovers elsewhere which are closed when you click outside the popover.

a) Non-functioning option 1:
Add
popover.behavior = .transient

to applicationDidFinishLaunching. Now the popover will close if you click inside the window first and then click outside it. It will not go away until you’ve clicked inside the window.

b) Non-functioning option 2:
remove the behavior setting and replace it with

NotificationCenter.default.addObserver(self, selector: #selector(AppDelegate.closePopover(sender:)), name: NSNotification.Name.NSApplicationDidResignActive, object: nil)

Spoiler: it won’t work either, but the documentation for event monitoring says event monitors should be used only when there is no other way to solve your problem and suggests monitoring NSApplicationDidResignActiveNotification first. Sadly, the result is the same as the .transient setting.

7) Set up an event monitor to make the popover go away

Following the tutorial mentioned above, I am going to do this in a separate class – it’s reusable and neat code, though for the sake of readability I have not bothered with permission levels.

a) create the EventMonitor class

NSEvent has the ability to create two types of eventMonitor: a global one, that listens for events outside your application (keystrokes can only be captured if your app has accessibility permissions) and local, which listens to events inside your app and can pre-process them.

mask is an array of items your app wants to listen to. NSEventMask is a curious beast: you can pass both a single argument (.leftMouseUp) as well as an array ([.leftMouseUp, .rightMouseUp]) as an argument here. The full list can be found in the NSEventMask documentation.

handler is the code you want to run when you’ve detected an event you’re interested in. monitor, finally, is the [global] eventMonitor returned by NSEvent; you need to keep a reference to it so you can dismiss it when you no longer need it. (If you don’t, you get a – tiny – memory leak.)

import Cocoa

class EventMonitor {
var mask: NSEventMask
var handler: (NSEvent?) -> ()
var monitor: Any?

The initialiser is straightforward:

init(mask: NSEventMask, handler: @escaping (NSEvent?) -> ()){
self.mask = mask
self.handler = handler
}

As of ElCapitan, you no longer need to deregisters event notification observers, but the global monitor still needs to be removed. The best place to do this is in the deinit method – we simply call stop().

deinit{
stop()
}

The start function creates the globalMonitor, which is of type Any?, hence the variable signature above.

func start(){
monitor = NSEvent.addGlobalMonitorForEvents(matching: mask, handler: handler)
}

wpid-Warning-2014-08-3-17-49-2016-12-6-12-49.jpg
The stop function stops monitoring by removing the monitor. monitor! is important – if you’re passing in an optional, you’re not fulfilling the method signature, and you’ll get a runtime crash. This is the sort of thing that ideally the compiler should warn you about – usually this works, but here it’ll accept the optional happily and goes belly-up later. Learn from my mistakes.

func stop(){
if monitor != nil {
NSEvent.removeMonitor(monitor!)
monitor = nil
}
}
}

b) set up the event handler

Add a variable to the AppDelegate class
var eventMonitor: EventMonitor?

set it up inside applicationDidFinishLaunching

eventMonitor = EventMonitor(mask: [.leftMouseUp, .rightMouseUp]) { [unowned self] event in
if self.popover.isShown {
self.closePopover(sender: event)
}
}

and amend the show and close functions:

func showPopover(sender: AnyObject?) {
if let button = statusItem.button {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY)
eventMonitor?.start()
}
}

func closePopover(sender: AnyObject?) {
popover.performClose(sender)
eventMonitor?.stop()
}

Build and run! You now have a fully functional menu bar application shell.

And more…

Usage Example

Being a menu bar app – or having a menu bar interface – is best suited for small utilities that you want to call up every now and again during the day, but which you don’t want spend much time with. They’re also most useful as apps that run constantly in the background.

Alternatives

Notification widgets. iPhone apps. Watch apps. Push notifications. A lot of the time, menu bar apps provide small snippets of information – they’re not necessarily best suited to the desktop, and might work better if users can consult them across platforms.

Extensions

Some menu bar apps stand on their own, others work as helper apps – it depends where you want to fit your menubar app into your general ecosystem. There seems to be no technical limitation on how big and complex such an app can be, but ‘a single window plus preferences’ seems to provide a natural limit. The moment you want a menu or several screens, you probably should go with a fully-fledged desktop app.