肇鑫的技术博客

业精于勤,荒于嬉

A Well-formed macOS Menu Bar Application in Sandbox

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.

  1. Launch itself when a user login.
  2. When auto launched, only the menu bar item shows.
  3. When a user launch the app, both menu bar item and app UI are shown.
  4. When left clicking on the menu bar item, the app shows/hides itself as well as shows and hides itself in Dock.
  5. When a user quits the app, the app disappears from Dock but the menu bar item stays.
  6. 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.

basic related states

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
    }
}

basic related states simplified

@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:

  1. Main UI is not initialized automatically. You should create them yourself when needed.
  2. 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 of NSApplicationDelegate will be skipped and the app quits itself immediately.

So you should call ProcessInfo.processInfo.disableSuddenTermination() when you want applicationShouldTerminate(_:) 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.

Sample Project

LoginItem-Sample

Others Related

macOS应用登录时启动的实现方式

macOS应用登录时启动的实现方式

Mac版咕唧2移除了今日扩展,改为了在菜单栏常驻图标的方式。这是因为,今日扩展的方式,不方便使用表情键盘,一旦弹出点击表情键盘,今日栏就会自动关闭。

常驻图标,拥有一个开机启动才是最好的。实现登录时启动,有多种方式,不过随着macOS的发展,一些方式因为沙盒的缘故已经不能使用了。本文介绍的是目前最新的通用方式,适合macOS 10.6及以上,iOS 12.1及以上,wathcOS 5.1及以上的系统。

原理

原理是这样的,对于较新的苹果系统,应用可以通过ServiceManagementSMLoginItemSetEnabled(_:_:)函数注册和取消开机自启。

这个自启是针对当前账户级别的。即每个用户,都需要在开启应用时单独同意,才会在自己进入系统后,自动启动对应的应用。

下面我们来具体看一看这个函数,func SMLoginItemSetEnabled(_ identifier: CFString, _ enabled: Bool) -> Bool

函数的第一个参数是id,这个id就是要执行的应用的包的ID。并且这个应用,必须位于主应用相对路径为Contents/Library/LoginItems的位置。

函数的第二个参数是注册还是取消开机自启。是为开启,否为取消。

函数的返回值则是这个操作是否成功。操作成功返回是,操作失败返回否。

小结

苹果在系统中预定了一项服务叫ServiceManagement,它允许用户在编写主程序时,额外添加一个程序,用于登录时自启。这个程序在主程序中的位置是固定的,必须位于Contents/Library/LoginItems,然后主程序通过SMLoginItemSetEnabled(_:_:)来实现对于开机自启的注册和取消。

实现

知道了原理。实现就简单了。需要第二个应用,所以我们就需要创建它。因为它是服务类型的,不需要界面,所以要将其设定为后台应用。因为它是伴随着主应用安装的,所以它不需要单独安装等等。

这个步骤我就不详细说明了。需要的可以看看这篇文章:Modern Login Items

你创建的辅助应用,Xcode默认会使用最新的系统,而不是你在项目中限定的系统。比如你的项目支持macOS 10.14及以上,但是Xcode创建的辅助应用却是macOS 10.15的。你必须在目标的系统信息里删掉这个10.15,才会应用你的默认限制。

如果你不删除,就会发现你的应用在10.14的系统里无法伴随用户登录自动启动。并且找不到任何提示。你只有在Finder中主动解包,才会看到应用上面的不能执行的标记。

这个是Xcode的锅。

例子

如果你需要的是Objective C的实现,那么看上面的那个说明。
我自己参考Objective C版的,写了一个Swift版的。你可以在这里下载

其它

Register as Login Item with Cocoa?

A Well-formed macOS Menu Bar Application in Sandbox

NSPressGestureRecognizer在模态时失效问题的解决

最近在使用NSPressGestureRecognizer处理长按的时候发现了问题。如果弹出的视图控制器,是采用的show方式,则一切正常。但如果是使用modal的方式,则无法识别长按。

代码如下:

import Cocoa

class VC: NSViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let view = NSView(frame: NSRect(x: 100, y: 100, width: 100, height: 100))
        view.wantsLayer = true
        view.layer?.backgroundColor = NSColor.systemBrown.cgColor
        let longPress = NSPressGestureRecognizer(target: self, action: #selector(longPress(_:)))
        view.addGestureRecognizer(longPress)
        self.view.addSubview(view)
    }

    @objc func longPress(_ sender:Any) {
        print("long")
    }
}

分析

我自己弄了半天,没能找到解决方案,于是跑到SO上问。NSPressGestureRecognizer doesn't work in modal ViewController。一觉睡醒,发现已经有人回答了。

Interesting. What seems to be happening is that the recognizer state never changes from possible to "began" in the modal example.

class Recognizer: NSPressGestureRecognizer {

    override func mouseDown(with event: NSEvent) {
        super.mouseDown(with: event)
        self.state = .began
    }

}

解答的代码虽然不完整,但是至少提供了一个方向。按照解答的思路,我重新实现了NSPressGestureRecognizer

模拟的目标是实现在show方式下同样的状态改变。即在正常长按的情况下,依次实现possible、began、end。使用一个Timer,在满足长按时间的情况下,发送began,并且在用户抬起鼠标且有began的情况下,发送end。

import Cocoa

class VC: NSViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let view = NSView(frame: NSRect(x: 100, y: 100, width: 100, height: 100))
        view.wantsLayer = true
        view.layer?.backgroundColor = NSColor.systemBrown.cgColor
        let longPress = MyPressGestureRecognizer(target: self, action: #selector(longPress(_:)))
        view.addGestureRecognizer(longPress)

        let click = NSClickGestureRecognizer(target: self, action: #selector(click(_:)))
        view.addGestureRecognizer(click)

        self.view.addSubview(view)
    }

    @objc func click(_ sender:Any) {
        print("click")
    }

    @objc func longPress(_ sender:Any) {
        guard let gesture = sender as? NSGestureRecognizer else { return }

        switch gesture.state {
        case .ended:
            print("long")
        default:
            print(gesture.state)
        }
    }
}

class MyPressGestureRecognizer: NSPressGestureRecognizer {
    private weak var timer:Timer? = nil
    private var hasBegan = false
    private var hasCancelled = false

    override func mouseDown(with event: NSEvent) {
        super.mouseDown(with: event)

        timer = Timer.scheduledTimer(withTimeInterval: minimumPressDuration, repeats: false) { (timer) in
            defer {
                timer.invalidate()
            }

            DispatchQueue.main.async {
                self.state = .began
                self.hasBegan = true
            }
        }
    }

    override func mouseUp(with event: NSEvent) {
        if hasBegan {
            self.state = .ended
            self.hasBegan = false
        }

        super.mouseUp(with: event)
    }

    override func reset() {
        timer?.invalidate()
        super.reset()
    }
}

extension NSGestureRecognizer.State:CustomStringConvertible {
    public var description:String {
        switch self {
        case .possible:
            return "possible"
        case .began:
            return "began"
        case .changed:
            return "changed"
        case .ended:
            return "ended"
        case .cancelled:
            return "cancelled"
        case .failed:
            return "failed"
        @unknown default:
            return "default"
        }
    }
}

运行之后,发现我实现的代码,和苹果原本的NSPressGestureRecognizer,效果完全一样。也就是说,我的代码同样有模态方式下,无法识别长按的问题。

再次思考,我发现问题出现Timer上,在模态运行的视图控制器,Timer不会执行。我猜,苹果大概也是代码中使用了Timer,才会有同样的问题。

解决

最终的方案是不使用Timer,增加一个是否取消的参数进行判断。代码如下:

import Cocoa

class VC: NSViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let view = NSView(frame: NSRect(x: 100, y: 100, width: 100, height: 100))
        view.wantsLayer = true
        view.layer?.backgroundColor = NSColor.systemBrown.cgColor
        let longPress = MyPressGestureRecognizer(target: self, action: #selector(longPress(_:)))
        view.addGestureRecognizer(longPress)

        let click = NSClickGestureRecognizer(target: self, action: #selector(click(_:)))
        view.addGestureRecognizer(click)

        self.view.addSubview(view)
    }

    @objc func click(_ sender:Any) {
        print("click")
    }

    @objc func longPress(_ sender:Any) {
        guard let gesture = sender as? NSGestureRecognizer else { return }

        switch gesture.state {
        case .ended:
            print("long")
        default:
            print(gesture.state)
        }
    }
}

class MyPressGestureRecognizer: NSPressGestureRecognizer {
    private var hasBegan = false
    private var hasCancelled = false

    override func mouseDown(with event: NSEvent) {
        super.mouseDown(with: event)

        hasCancelled = false

        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(minimumPressDuration * 1000))) {
            if !self.hasCancelled {
                self.state = .began
                self.hasBegan = true
            }
        }
    }

    override func mouseUp(with event: NSEvent) {
        if hasBegan {
            self.state = .ended
            self.hasBegan = false
        } else {
            self.hasCancelled = true
        }

        super.mouseUp(with: event)
    }
}

extension NSGestureRecognizer.State:CustomStringConvertible {
    public var description:String {
        switch self {
        case .possible:
            return "possible"
        case .began:
            return "began"
        case .changed:
            return "changed"
        case .ended:
            return "ended"
        case .cancelled:
            return "cancelled"
        case .failed:
            return "failed"
        @unknown default:
            return "default"
        }
    }
}

总结

  1. Timer在模态时会失效。我们在使用时需要小心。
  2. 上面的代码只处理了鼠标左键,如果想处理其它按键的长按,也可以用同样的方式进行处理。

参考

NSPressGestureRecognizer doesn't work in modal ViewController