肇鑫的技术博客

业精于勤,荒于嬉

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

阅读此篇之前,建议先阅读基础篇。双击打开文件SwiftUI版本的实现(基础篇)

本篇,我们要实现类似“Welcome to Xcode”这样的欢迎界面。当用户直接打开应用时,显示欢迎界面,而如果用户双击指定类型的文件,则不显示这个欢迎界面,而直接显示应用的主界面。

welcome

问题分析

首先想到的就是,开机直接显示欢迎界面,然后在其中处理文件的打开。测试显示,此路不通。

规则1: openURL的调用会自动选择接受该参数的WindowGroup

Window("WelcomeViewWindow", id: "WelcomeViewWindow") {
    WelcomeView(showFileImporter: $showFileImporter)
}
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentSize)

WindowGroup(for: URL.self) { $fileURL in
    MainView(fileURL: $fileURL)
        .environment(\.managedObjectContext, persistenceController.container.viewContext)
}

虽然两个View都支持openURL,但是之后第二组的openURL会被调用,因为传递来的URL参数,只有第二组支持。

同时,也出现了一个问题。就是虽然只有第二组的openURL会被调用,但是欢迎窗口总是打开。

规则2: 位于首位的WindowGroup总是被打开

这是因为规则2的存在。解决办法也很简单,将两个WindowsGroup掉个个即可。同时,欢迎界面的openURL也可以删掉了。因为根本不会调用到。

遇到苹果API的bug

这样,打开文件的问题就解决了。不过,我们最初希望的是用户直接打开应用的时候,显示欢迎界面,而现在显示的是空白的主界面。这不是我们想要的。

解决办法也很简单,我们在主界面的onAppear阶段,检测fileURL,如果它是nil,就证明是直接打开的应用,我们就调用欢迎界面,然后通过dismiss关闭主界面。

.onAppear {
    if fileURL == nil {
        openWindow(id: "WelcomeViewWindow")
        dismiss()
    } 
}

可问题是,实际测试时,主界面每次都不能成功关闭。通过阅读dismiss的文档,我发现,dismiss能关闭窗口的前提是,ScenePhase必须是.active的状态。经过检测发现,主界面当前的ScenePhase却是.background的状态。

DismissAction

这明显是苹果API出了bug,于是我和苹果提交了反馈。有bug,生活还得继续。既然dismiss关闭不了,我们可以直接关闭窗口。我的解决方案是绑定Window,然后直接关闭这个Window。

private func quit() {
    if let window {
        window.close()
    } else {
        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100), execute: DispatchWorkItem(block: quit))
    }
}

SwiftUIWindowBinder

解决openURL和onAppear调用顺序的问题

需要注意的事,SwiftUI中,二者是先onAppear,然后才调用openURL。所以,如果我们需要改变这个顺序,就需要将onAppear运行的代码,加入到DispatchQueue.main.async中,然后推后一个周期。

双击打开文件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