肇鑫的技术博客

业精于勤,荒于嬉

双击打开文件SwiftUI版本的实现(基础篇)

我们知道,在使用AppKit的时候,我们通过NSApplicationDelegate的几个open方法来实现双击打开文件。

@MainActor optional func application(_ application: NSApplication, open urls: [URL])
@MainActor optional func application(_ sender: NSApplication, openFile filename: String) -> Bool
@MainActor optional func application(_ sender: NSApplication, openFiles filenames: [String])

但是在SwiftUI下,情况就有所不同了。我们这里所说的SwiftUI,指的是应用以SwiftUI的框架为主,而不是以AppKit为主,混合使用SwiftUI的情况。后者,和上面的情况没区别。

SwiftUI应用带来的变化

首先是,NSApplicationDelegate的这三个方法都发生了改变,后两个方法直接不会被调用了。而第一个方法经常会返回一个空的数组。这让人非常疑惑。

Handle multiple URLs from Open With in SwiftUI on Mac

经过查询,我知道,原来在SwiftUI下,SwiftUI的View本身可以处理一个打开的URL,而多余的,则需要用

@MainActor optional func application(_ application: NSApplication, open urls: [URL])

来进行处理。

吐个槽:这么做实在有些弱智。

所以我们所需要的正确做法就是:

  1. 在SwiftUI的应用中,使用onOpenURL(perform:)处理单个文件的双击打开。
  2. 如果是多个文件被双击打开,则通过NSApplicationDelegateapplication(_:open:)方法处理剩下的文件。

进阶篇

双击打开文件SwiftUI版本的实现(进阶篇)

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

The Login Item in macOS Ventura

Apps Crash on Ventura

Before Ventura, if I wanted some app to launch when a user logged in, I used this code:

private func setAutoStart() {
    let shouldEnable = Defaults[.autoLaunchWhenLogin]
    
    if !SMLoginItemSetEnabled("com.parussoft.Stand-Reminder-Launcher" as CFString, shouldEnable) {
        fatalError()
    }
}

However, in macOS Ventura, SMLoginItemSetEnabled(_:_:) global function is deprecated, and what's more, it takes no effects so it returns false and fatalError() is executed. So every app of mine that used auto launch when logged in crashed on macOS Ventura.

How to Solve

To solve the issue is easy, we must update the code and use the new API in macOS Ventura and later.

private func setAutoStart() {
    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()
        }
    }
}

Other Related Issues

In Ventura, you may find that many versions of the same app that created login items all launched when you logged in. Once I had 3 "Stand Reminder" and 2 "Poster 2" in different versions auto launched when I logged in.

I found that the extra apps were debug versions that I had tested with Xcode. So there must be some relations with the new API.

Workaround

You can simply manually remove the debug version apps to solve the issue. But this method takes to much time as you always have new debug versions.

Or you can stop this feature on debug versions after you fully tested it.

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
}