Many apps start with system menu bar items. Some of them are not shown in Dock. Some of them are shown in Dock but are not in sandbox. In this article I will design a well-formed macOS menu bar application in sandbox.
Goals
An application which has below features.
- Launch itself when a user login.
- When auto launched, only the menu bar item shows.
- When a user launch the app, both menu bar item and app UI are shown.
- When left clicking on the menu bar item, the app shows/hides itself as well as shows and hides itself in Dock.
- When a user quits the app, the app disappears from Dock but the menu bar item stays.
- When a user right clicking/two finger touching on a trackpad, a menu is shown and the user could quit the app entirely.
Difficulties
Unless iOS, fewer developers are putting their attentions on macOS. And with the so long history, there are two many wrappers on how to manipulating a macOS application. What you need is patience on how to debugging the experience that you want.
Here is the basic states need to compare.
You should aware that isClosed
is not native and we can simplify it with NSWindowDelegate
's method windowShouldClose(_:)
, so the NSWindow will never close.
extension AppDelegate:NSWindowDelegate {
func windowShouldClose(_ sender: NSWindow) -> Bool {
hide()
return false
}
}
@objc private func mouseLeftButtonClicked() {
guard let window = self.window else {
showWindow()
return
}
var operated = false
if NSApp.isHidden {
unhide()
if !operated { operated = true }
}
if window.isMiniaturized {
window.deminiaturize(nil)
if !operated { operated = true }
}
if !NSApp.isActive {
NSApp.activate(ignoringOtherApps: true)
if !operated { operated = true }
}
guard window.isKeyWindow else { return }
if !operated {
hide()
}
}
LSUIElement
For start an application with only menu bar item, we should set LSUIElement
to true in Info.plist
. When set to true, this key has two effects:
- Main UI is not initialized automatically. You should create them yourself when needed.
- Main Window will release itself when it closes.
Dock
Use NSApp.setActivationPolicy(.regular)
and NSApp.setActivationPolicy(.accessory)
to show/hide your app in Dock.
Background Start, Foreground Start
Use UserDefaults
to transfer the state that whether the app is started by a user or the launcher app.
You will need to create a group to share the defaults.
Quit to Menu Bar Item
In order to quit to menu bar item, you should implemented applicationShouldTerminate(_:)
method of NSApplicationDelegate
. Besides, since macOS 10.6, Apple introduced Sudden Termination. It is a counter instead of a switcher. So you must use it balancingly.
When Sudden Termination is enable in Info.plist, which it is by default when you create a new macOS application, and a user quits your app,
applicationShouldTerminate(_:)
method ofNSApplicationDelegate
will be skipped and the app quits itself immediately.So you should call
ProcessInfo.processInfo.disableSuddenTermination()
when you wantapplicationShouldTerminate(_:)
method to be called.
Also, when applicationShouldTerminate(_:)
returns .terminateCancel
, this app will stop the system from logout, shutdown and reboot. So you must implemented those notifications.
extension AppDelegate {
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
// must delay this operation or the main menu will leave a selected state when the app shows next time.
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
self.hide()
}
return .terminateCancel
}
private func setupWorkspaceNotifications() {
let center = NSWorkspace.shared.notificationCenter
center.addObserver(self, selector: #selector(willSleep(_:)), name: NSWorkspace.willSleepNotification, object: nil)
center.addObserver(self, selector: #selector(willPowerOff(_:)), name: NSWorkspace.willPowerOffNotification, object: nil)
}
@objc private func willSleep(_ noti:Notification) {
quit()
}
@objc private func willPowerOff(_ noti:Notification) {
quit()
}
@objc private func quit() {
ProcessInfo.processInfo.enableSuddenTermination()
NSApp.terminate(nil)
}
}
Other Considerations
If you want to save some memory, you could nil the window property every time you hide you app.
Know Issue
When you debugging this app in Xcode, the menu will be not responsible at first. This is the issue of Xcode and the release app won't have this issue. You can switch to other apps and switch back to overcome this issue.