肇鑫的技术博客

肇鑫 / Owen Zhao

独立开发者,主要开发 iOS、watchOS、macOS 应用。

目前在维护 SleepTapRooster Time,以及 Markdown Writer 相关工具。

最新文章

微博账户过期提示算法的改进

通用

对于使用RestAPI的应用,微博强制用户必须每30天登录一次。我原本的思路是类似这样的:

设备1:@小明 x号登录,x+27天后开始提醒。
设备2:@小明 y号登录,y+27天后开始提醒。(x < y)

但是实际使用中我发现,当x+30之后,在不重新登录账户@小明的情况下,还是可以通过@小明的验证信息发送微博。

分析

表面上,设备1上的授权是过期的,但是为什么还能继续发微博呢?我使用微博提供的API查询了token的信息。结果显示,实际上的设备1上的授权时间,要比x+30要长。

我分析,可是这样的。我原本以为微博服务器会记录每次用户登录的授权,即

设备1:@小明 x号登录,x+30授权结束。
设备2:@小明 y号登录,y+30授权结束。 (x<y)

但实际上,为了方便,微博根本没有记录每次的过期时间,而是每次小明通过同一个应用的授权进行登录时,就自动延长了该授权对应的时间。所以实际上发生的,可能是这样的:

设备1:@小明 x号登录,x+30授权结束。
设备2:@小明 y号登录,y+30授权结束。 (x<y)
因为x<y,所以设备1和2上的@小明,都变成y+30授权结束。

结论

根据上面的分析,新算法就变成了,当x+27时,先向微博服务器进行查询,查看剩余时间是否不足3天,如果超出,则先不进行提示,而是更新下一个时点,这样用户就可以少登录几次了。

The Real Reason of Set NSMenuDelegate in Interface Builder Not Working

When implemented the "Open Recent..." menu item under "File" menu, I connected the menu of "Open Recent"'s delegate to "AppDelegate" class. However, the delegate functions never called.

nsmenudelegate_issue_with_interface_builder

I tried to connect the NSMenuDelegate with the main menu, the result was the same.

I looked this issue up in stackoverflow site. There are many questions on this. Someone says that "Menu delegates are not used that often, so Apple hasn't made them too easy to set up in Interface Builder. Instead, do this in awakeFromNib:".

I don't accept the theory. But the answer does imply that setting NSMenuDelegate in code works.

Then I looked up on how to implementing "Open Recent..." menu. I found this post, Respond to Open Recent clicks in NSMenu. I was glad that I could use NSDocumentController to get the feature of "Open Recent...".

But the routine approach is not suit for my app.

For apps like Pages or Numbers, the files they open varies every time on names. However, for Xliff Tool, the files it opens are exported by Xcode, and the names are fixed. So when using the default approach of NSDocumentController, the recent files shown may be the same.

open_recent_issue

As you can see in above picture, there are two files with the same name. In fact they are in different paths. I need to show the files in full paths instead of just filenames.

So the question is back again. I have to make functions to conform to NSMenuDelegate.

What the magic Apple does to make the "Open Recent..." menu working?

Apple must have implemented its own class that conforms to NSMenuDelegate protocol. Since in Interface Builder I could not find any, I would debug it on AppDelegate's applicationDidFinishLaunching(_:) function.

I connect a @IBOutlet of menu of "Open Recent..." to "AppDelegate", and found that when app runs, the openRecentMenu has been set a NSMenuDelegate called NSDocumentControllerSubMenuDelegate.

open_recent_menu_outlet

After another digging, I found that NSDocumentControllerSubMenuDelegate is a hidden class that should not be used by third-party developers.

Answer

The answer of NSMenuDelegate set in Interface Builder not working is that for mechanism of magic like "Open Recent...", Apple resets the NSMenuDelegate of all menus under an app's main menu.

Anyone who wants to use a NSMenuDelegate, should set it in code.

My Own Solution

After set the NSMenuDelegate, the rest is easy.

extension AppDelegate:NSMenuDelegate {
    func menuNeedsUpdate(_ menu: NSMenu) {
        let clearMenuMenuItem = menu.items.last!
        let urls = NSDocumentController.shared.recentDocumentURLs
        let menuItems = urls.map {
            NSMenuItem(title: $0.path, action: #selector(openFile(_:)), keyEquivalent: "")
        }
        
        menu.items = [
            menuItems,
            [NSMenuItem.separator(), clearMenuMenuItem]
        ].flatMap({$0})
    }
}

Others

The differences between @ojbc and @IBAction through an interesting bug/feature of Interface Builder.

The Differences between @ojbc and @IBAction through an Interesting Bug/feature of Interface Builder

macOS

Last week I started an open source app Xliff Tool. It was an app to help developers translate Xliff files exported by Xcode.

We know that for the main menu of an app, its menu items look up the responder chain to find actions to perform. So those functions need to be dynamic. For example, a "Open..." menu item under "File" menu, it looks up for a function called openDocument(_:), so I could create a function in AppDelegate.swift like this:

@objc func openDocument(_ sender: Any?) {
    ...
}

However, when I created another customized menu item of my own, I could not find the function name in first responder with Interface Builder.

@objc func openDatabaseDirectory(_ sender: Any?) {
    ...
}

The fix was easy, just changed @objc to @IBAction, and the function name would be shown.

@IBAction func openDatabaseDirectory(_ sender: Any?) {
    ...
}

Q1: Why the prior @objc function was shown but the latter wasn't?

The answer is the prior @objc function wasn't shown. What was shown in Interface Builder is another @IBAction from NSDocumentController.

/* The action of the File menu's Open... item in a document-based application. The default implementation of this method invokes -beginOpenPanelWithCompletionHandler:, unless -fileNamesFromRunningOpenPanel is overridden, in which case that method is invoked instead for backward binary compatibility with Mac OS 10.3 and earlier. If an array other than nil is obtained from that call, it invokes -openDocumentWithContentsOfURL:display:completionHandler: for each URL and, if an error is signaled for any of them, presents the error in an application-modal panel.
*/
@IBAction open func openDocument(_ sender: Any?)

So in Interface Builder the @IBAction function was shown and in app runtime, the dynamic function on the first responder is the @objc one.

Both @IBAction and @objc are dynamic/objective-c functions, one difference is that the prior can be shown in Interface Builder.

An interesting bug/feature of Interface Builder

There was a "Save as..." menu item under "File" menu, connecting to saveAs(_:), I firstly implemented that function

@objc func saveAs(_ sender: Any?) {
    ...
}

Then I changed the "Save As..." menu item to "Export Xliff File...", and refactored the name of the function as well.

@objc func exportXliffFile(_ sender: Any?) {
    ...
}

Then I ran the app, the @objc func exportXliffFile(_ sender: Any?) still worked.

interface_builder_issue

Let me explain this. Though in the above picture it was shown that the menu item "Export Xliff File..." is connected to "exportXliffFile:" function of first responder. As we explained in Q1, there was no corresponding @IBAction function named "exportXliffFile:", so no one could choose "exportXliffFile:" in Interface Builder at all.

Q2: Then why was "exportXliffFile:" connected in Interface Builder and it still worked?

The answer is the operations that I did.

  1. There is @IBAction in NSDocument.saveAs(_:). So @objc func saveAs(_ sender: Any?) worked.
  2. When refactored in Xcode, both the name of @objc func saveAs(_ sender: Any?) and the connection "saveAs:" in Interface Builder were changed.
  3. The name of @IBAction in NSDocument.saveAs(_:) was unchanged as it was in a readonly header.
  4. When the app ran, the menu item looked up for a function called "exportXliffFile:" and found.

You can not pick a @objc function in Interface Builder, but if you refactor its name from a @IBAction, it will still work.

Others

The Real Reason of Not Working NSMenuDelegate with Interface Builder

NSSavePanel Best Practice

macOS

Sometimes I will use NSSavePanel. However, every time I use it, I have to how to using it. Here is the best practice so next time I will not have to look the docs up.

@objc func exportXliffFile(_ sender: Any?) {
    let exportPanel = NSSavePanel()
    exportPanel.prompt = NSLocalizedString("Export", comment: "")
    exportPanel.allowedFileTypes = ["xliff"]
    let xliffURL = (NSApp.delegate as? AppDelegate)?.xliffURL
    exportPanel.directoryURL = xliffURL
    exportPanel.nameFieldStringValue = xliffURL!.lastPathComponent
    exportPanel.beginSheetModal(for: view.window!) { [unowned self] (response) in
        if response == .OK {
            self.save(to: exportPanel.url!)
        }
    }
}

About code is from my open source app Xliff Tool.

Others

https://developer.apple.com/documentation/appkit/nssavepanel/1534419-allowedfiletypes#

Experiences of Adapting Semaphore to Async/Await of Poster 2

AwaitKit

In 2.6.0, Poster 2 adopted its architecture from using Semaphore to Async/Await.

Semaphore

Poster 2 used to using semaphores in two parts when posting contents.

  1. Posting contents with multiple accounts.
  2. Posting multiple images when tweeting.

As all network operations are asynchronous, I used semaphores to order the operations one by one. But this architecture had a potential problem. When an error happened, the iterated operations wouldn't stop and the app freeze.

Besides, using semaphores with completions also made the code more complex to understand.

Async/Await

Poster 2 now adapts with Async/Await. It is a framework base on PromiseKit. When using Async/Await, the async code behaves like sync code. So you don't need to deal with completion handlers any more.

Benefits

  1. Async code runs as sync code. Easy to understand.
  2. Using do...try...catch, no completion handlers are needed.

Side Effects

  1. Async/Await blocks the main thread somehow. So you have to use async block for the very first calling or view animations won't work.
  2. Because of the previous reason, you have to deal with the cross-threading if you also using RealmSwift.
  3. Not all code could change to Async/Await. For example, some frameworks use delegates instead of completion handlers, like AVAudioPlayer.

Shortcuts with modify mask in macOS

macOS

For Poster 2 Mac, which has been released lately, one of the feedbacks I got, is if I could provide a method to quickly compose and share texts. For current users, the steps are:

  1. Move mouse/trackpad to click the menu bar item of Poster 2.
  2. Move mouse to click the Write button.
  3. Compose.

Also, the user may have to close the window when texts are sent.

I want to improve those experiences. The easiest idea is to bind composing with mouse click directly. But I don't want that. In my own experience, you must make your app as simple as possible, as you may not even know, that some of the users have never known of right click. If you app need to do something with right click, those functions would be seen as never worked.

A big difference from a macOS user to an iOS user is the former uses a physical keyboard. So I want to add shortcuts for composing and hiding app.

As the beginning, I was thinking adding a menu item to do the same with Write button. However, I found I could bind Write menu item with key enter, but I could not call it in my app. I got a beep sound and nothing happened. I could use modifier mask like command, but that was not what I wanted.

Responder Chain

You should know what is a responder chain. For a brief, a responder chain is something how your app responds to a user event. For example, if a user presses keyboard a. The first responder gets the a and looks for itself if it can react the event, if not, it will pass the event to the next responder, usually is the view which contains itself. Things will go on until the event is react or there is no further responder.

NSStandardKeyBindingResponding Protocol

However, there are always some other things you have to consider. Many subclasses of NSView adopt NSStandardKeyBindingResponding protocol, and some keys have been occupied already. The good thing is, if you want to use those keys, you should override the corresponding method.

Beep Sound

Some subclasses of NSView also implemented the default beep sound if the key is not register. You must exclude the key you want in NSResponder's keyDown(with:) method.

My Resolution

Press enter key for quick editing, and press esc key to hide window.

Enter Key

Subclassing NSWindow and override keyUp(with:) method to compose.

Esc Key

Override cancelOperation(_:) for esc key.

class CancelWindow: NSWindow {
    override func keyDown(with event: NSEvent) {
        if event.keyCode == 36 { // enter key

        } else {
            super.keyDown(with: event   )
        }
    }
    
    override func keyUp(with event: NSEvent) {
        if event.keyCode == 36 { // enter key
            (self.contentViewController as? NextMainViewControllerMac)?.writePost(nil)
        } else {
            super.keyUp(with: event)
        }
    }
    
    override func cancelOperation(_ sender: Any?) {
        (NSApp.delegate as? AppDelegate)?.hide()
    }
}

Enter Key for NSButton

There is also something need to mention. If you add enter to keyEquivalent of NSButton, the border become blue automatically. This is a feature which can't be changed.

A Well-formed macOS Menu Bar Application in Sandbox

macOS

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应用登录时启动的实现方式

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