肇鑫的技术博客

业精于勤,荒于嬉

macOS 13之后启动项实现的变化

有时,我们需要在用户开机之后,自动启动某个应用。这在之前,实现起来是比较麻烦的,但是macOS 13简化了它。

我之前写过两篇文章,分别对应macOS 13之前的版本,和macOS 13。

macOS应用登录时启动的实现方式
The Login Item in macOS Ventura

但是其中macOS 13的实现方式存在一些问题。比如我经常习惯的方式是,用户登录后运行某个应用,同时这个应用启动后,自动将自身从Dock移除,然后隐藏到系统菜单栏。但是使用第二篇文章的方式,应用本身并没有正确隐藏,而是显示窗口,同时应用也没有自动从Dock中移除。

当时我的理解是,macOS 13就是这么设计的。但是随着应用的增多,每次登录都要点击多次关闭按钮,让我觉得挺麻烦的,于是打算深入研究这个问题。

之前的实现代码

private func setAutoStart() {
    #if !DEBUG
    let shouldEnable = Defaults[.autoLaunchWhenLogin]
    
    if #available(macOS 13.0, *) {
        if shouldEnable {
            try? SMAppService().register()
        } else {
            try? SMAppService().unregister()
        }
    } else {
        if !SMLoginItemSetEnabled("com.parussoft.Stand-Reminder-Launcher" as CFString, shouldEnable) {
            fatalError()
        }
    }
    #endif
}

对比新旧两段代码,我们发现新代码并没有指定"com.parussoft.Stand-Reminder-Launcher",经过阅读SMAppService的文档,我发现,原来等效的代码应该是这个。

private func setAutoStart() {
    #if !DEBUG
    let shouldEnable = Defaults[.autoLaunchWhenLogin]
    
    if #available(macOS 13.0, *) {
        if shouldEnable {
            try? SMAppService.loginItem(identifier: "com.parussoft.Stand-Reminder-Launcher").register()
        } else {
            try? SMAppService.loginItem(identifier: "com.parussoft.Stand-Reminder-Launcher").unregister()
        }
    } else {
        if !SMLoginItemSetEnabled("com.parussoft.Stand-Reminder-Launcher" as CFString, shouldEnable) {
            fatalError()
        }
    }
    #endif
}

检验结果

是不是使用等效的代码就可以了呢?让我们验证一下。经过多次重启,以及注销-重新登录的操作,我发现等效的新代码,在macOS 13中并不是次次都能生效。有的时候,会出现主程序并没有被成功呼唤出来的现象。

当时我怀疑是不是并不是每次辅助程序都成功运行了。可是经过我的进一步测试,实际上辅助程序每次都是成功运行的,但是辅助程序调用主程序的时候,并不是每次都能成功调用。

所以我们需要修改的是调用的代码。

旧版

@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
    func applicationDidFinishLaunching(_ aNotification: Notification) {
        UserDefaults.shared.set(true, forKey: UserDefaults.Key.startFromLauncher.rawValue)
        
        let pathComponents = Bundle.main.bundleURL.pathComponents
        let mainRange = 0..<(pathComponents.count - 4)
        let mainPath = pathComponents[mainRange].joined(separator: "/")
        try! NSWorkspace.shared.launchApplication(at: URL(fileURLWithPath: mainPath, isDirectory: false), options: [], configuration: [:])
        NSApp.terminate(nil)
    }
}

改进代码如下,当调用主程序遇到问题时,等待两秒再重试,直到成功调用后,退出辅助程序。

新版

@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
    func applicationDidFinishLaunching(_ aNotification: Notification) {
        UserDefaults.shared.set(true, forKey: UserDefaults.Key.startFromLauncher.rawValue)
        
        let pathComponents = Bundle.main.bundleURL.pathComponents
        let mainRange = 0..<(pathComponents.count - 4)
        let mainPath = pathComponents[mainRange].joined(separator: "/")
        let url = URL(fileURLWithPath: mainPath, isDirectory: false)
        let configuration = NSWorkspace.OpenConfiguration()
        launchMainApp(url, with: configuration)
    }
    
    private func launchMainApp(_ url:URL, with configuration:NSWorkspace.OpenConfiguration) {
        NSWorkspace.shared.openApplication(at: url, configuration: configuration) { app, error in
            if error == nil {
                NSApp.terminate(nil)
            } else {
                DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { [unowned self] in
                    launchMainApp(url, with: configuration)
                }
            }
        }
    }
}

再次测试,发现一切正常,问题解决。

更简单的实现方式

传统的实现方式步骤是这样的:

  1. 创建主程序
  2. 创建辅助程序
  3. 在主程序中注册辅助程序为启动项
  4. 经过一些编译设置,达到如下的效果
    1. 用户登录之后,辅助程序先启动
    2. 辅助程序启动后调用主程序
    3. 辅助程序退出

新版也支持上面的方式。但是新版同时支持了更简单的实现方式。

private func setAutoStart() {
    #if !DEBUG
    let shouldEnable = Defaults[.autoLaunchWhenLogin]
    
    do {
        if shouldEnable {
            try SMAppService.mainApp.register()
        } else {
            try SMAppService.mainApp.unregister()
        }
    } catch {
        print(error)
    }
    #endif
}

只需要将loginItem改为mainApp,就能够实现主应用在用户登录后自动启动。不过这种方式相比于原来的方式,比较粗糙,主程序只能有一种状态,只能隐藏或者不隐藏,而不能根据启动的方式不同来执行不同的任务。

这种方式的好处是简单,只需要有这段代码就可以了。不需要做额外的设置。

理解macOS 13的登录项设置

macOS 13刚出的时候,大家都对于登录项这个设置很陌生,不理解为什么它会分成两栏。根据我这段时间的测试,我目前已经理解了它两栏的含义。

login

如图是我目前系统中的登录项的设置。它有两项,一个叫登录时打开,一个叫允许在后台。很多人不理解它们的区别,下面我来解释一下。

登录时打开

登录时打开,只的是登录时,直接打开主应用。也就是说,应用的代码中应该要包含类似如下的代码:

try SMAppService.mainApp.register()

如果你的应用中不含这样的代码,就算你手动添加应用,添加完成之后,被添加的应用也不会在列表中出现。

允许在后台

允许在后台则是另外的情况,你的应用中需要包含类似如下的代码:

try? SMAppService.loginItem(identifier: "com.parussoft.Stand-Reminder-Launcher").register()

即你的应用中包含了辅助应用。辅助应用被系统在后台唤醒,然后辅助应用调用主应用。根据范围的不同,辅助应用可以分为Login Item,Login Agent以及Login Daemons。

What are the differences between LaunchAgents and LaunchDaemons?

调试时需要的注意事项

在开发者的电脑里,可能同时存在应用的多个版本,比如开发版,正式版等,它们存在于电脑中的多个位置。这可能导致在用户登录后,你看到的现象并非来至于你目前在调试的版本。

解决办法是用聚焦来进行搜索,删除掉应用的所有版本,然后在Xcode中重新运行当前版本,这样就能保证系统目前只有一个版本,方便调试。

允许在后台的删除

允许在后台包含了三种情况,其中Login Item对应的是应用程序本身,删掉应用就会自动删除。另外两种情况则需要找到对应的文件夹,删掉自动添加的配置文件才可以。

实例项目

owenzhao/LoginItem-Sample

OptionSet与NSPredicate

在Swift中,我们习惯了使用contains来比较OptionSet,这个方法使用起来十分简单,就不赘述了。某些情况下,我们必须使用NSPredicate来进行比较OptionSet,由于Objective-C不支持contains,所以比较的方法有所不同。特别的,我们有时需要考虑组合后的特性,因为不是数学简单的相等关系,有时候理解起来存在一定的困难,容易出错。

照片库搜索遇到的问题

我打算搜索照片库,需要排除掉实况照片和截图,我的代码一开始是这么写的:

let notLivePhotoPredicate = NSPredicate(format: "mediaSubtypes != %d", PHAssetMediaSubtype.photoLive.rawValue)
let notScreenshotPredicate = NSPredicate(format: "mediaSubtypes != %d", PHAssetMediaSubtype.photoScreenshot.rawValue)
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [notLivePhotoPredicate, notScreenshotPredicate])

这段代码运行之后,结果为空。我不是很理解,但是我还是想办法更改了代码:

let livePhotoPredicate = NSPredicate(format: "mediaSubtypes == %d", PHAssetMediaSubtype.photoLive.rawValue)
let screenshotPredicate = NSPredicate(format: "mediaSubtypes == %d", PHAssetMediaSubtype.photoScreenshot.rawValue)
let notLivePhotoPredicate = NSCompoundPredicate(notPredicateWithSubpredicate: livePhotoPredicate)
let notScreenshotPredicate = NSCompoundPredicate(notPredicateWithSubpredicate: screenshotPredicate)
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [notLivePhotoPredicate, notScreenshotPredicate])

这段代码运行的结果倒是符合预期。这是为什么呢?难道这两段代码不应该是等价的吗?

于是我重新查看苹果的文档,结果我发现,PHAssetMediaSubtype不是enum,而是OptionSet。在NSPredicate中,比较OptionSet不能用相等,而应该使用:

let livePhotoPredicate = NSPredicate(format: "(mediaSubtypes & %d) != 0", PHAssetMediaSubtype.photoLive.rawValue)
let screenshotPredicate = NSPredicate(format: "(mediaSubtypes & %d) != 0", PHAssetMediaSubtype.photoScreenshot.rawValue)

上面的代码运行同样符合预期。那如果将代码改成如下呢?

let notLivePhotoPredicate = NSPredicate(format: "(mediaSubtypes & %d) == 0", PHAssetMediaSubtype.photoLive.rawValue)
let notScreenshotPredicate = NSPredicate(format: "(mediaSubtypes & %d) == 0", PHAssetMediaSubtype.photoScreenshot.rawValue)
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [notLivePhotoPredicate, notScreenshotPredicate])

又不工作了。这是?

这是因为类型是OptionSet,比较不是简单的是否相等的算术关系。比如不工作的最后那段代码,如果是Set,那么结果永远是空集。

最终我的代码:

let combinedTypes:PHAssetMediaSubtype = [.photoLive, .photoScreenshot]
let combinedTypesPredicate =  NSPredicate(format: "(mediaSubtypes & %d) != 0", combinedTypes.rawValue)
let notCombinedTypesPredicate = NSCompoundPredicate(notPredicateWithSubpredicate: combinedTypesPredicate)

参考

NSPredicate syntax for PHFetchOption keys

An Error Causing by IndexSet

I got an error of 3301 of PHPhotosError, when using insertAssets(_:at:).

The Limits of Photo Library

The first mistake I found was that my result was reordered by date, but there were two photos with the same date. That meant I had inserted two indexes with the same value to the indexSet, which meant the count of indexSet was smaller than the companied array.

So I created a placeholder date to replace the date matched the item of the array, that made the count of the indexSet and array equal.

But the error was still shown. So I had to investigate again. This time I found the two photos with the same date were the same photo. So when inserting the photos, when the second copy of the same photo was inserted, the photo library had no change and the indexes behind the second copy were all mismatched.

The fix was to excluded the photos from the inserted album that existed in the container, like:

var albumPhotoSet = ...
let containerPhotoSet = ...
albumPhotoSet.substract(containerPhotoSet)
let albums = Array(albumSet)
...

The error still existed.

IndexSet Behaviors Differently from Set

After reading the document of insertAssets(_:at:), I found that IndexSet is a set, but behaviors differently.

let set:Set<Int> = [1, 3, 2, 4, 5]
print(set) // [3, 1, 2, 4, 5], [1, 4, 3, 5, 2], result is radom of 1...5

let indexSet:IndexSet = [1, 3, 2, 4, 5]
print(indexSet) // 5 indexes
print(indexSet.map {$0}) // [1, 2, 3, 4, 5], always

Since the IndexSet is sorted automatically instead of the order it is given. The array it is companied must have the same order.

var albums = Array(albumSet)
albums.sort() // by date
...

This time everything worked.

Conclusion

When inserting photos to an album, there are three steps.

  1. Sorted the photos of the album the same order you wanted for the final result.
  2. Exclude the photos already existed in the album from the inserting photos.
  3. Reorder the inserting photos.
  4. Merge the photos in the album and the inserting photos, reorder the result, and get the indexes of the inserting photos.
  5. Insert the inserting photos to the album.