使用 SiriKit Intents 以支持 macOS 快捷指令操作

MyerTidy 是什么 app?可以见这里的介绍

因为一个用户的邮件反馈:

Hello, thank you so much for this app. You saved my hours.
Is it possible to add a shortcut icon for the current folder option in the top finder window? Without drag & drop, I want to make the process on the current folder.
Also, if possible, adding the Shortcuts or the Automator action can be suitable for unique processes.
Scan and delete the empty folders in the current folder.
I added a 5-star review, and I hope more tools will come.
Best regards

MyerTidy 初衷是一个提供快速文件整理的工具,给定一个文件夹,你可以对里面的子文件进行不同的整理操作:按照一定的规则进行分组并放到新文件夹或者进行删除。确实,相比起打开 app 然后通过拖动或者手动选取的方式打开文件夹,直接在 Finder 进行操作的话效率会有不少的提升。基于这个反馈,打算对 MyerTidy 做进一步的对 macOS 更深入的整合支持。

更多产品的介绍,见:

MyerTidy × macOS 快捷指令,加速你的文件整理工作流

技术调研和考量

目前支持这些行为的 Framework 是 App Intents(2022 年推出)以及 SiriKit Intents

前者只能支持 iOS 16 以及 macOS Ventura 之后的系统,对 SwiftUI 以及纯 Swift 支持比较好。

目前 MyerTidy 支持的 macOS 版本是 macOS 12 及以上,考虑到 macOS 13 Ventura 最近才发布,因此 App Intents 先不直接支持(事实上 Xcode 支持 Siri Intents 跟 App Intents 共存,并提供一键转换的功能),先使用 SiriKit Intents 来实现。

支持 Shortcut

Shortcut 原本是 iOS 上的东西,后来在 2020 年使用 SwiftUI 重写并开始支持 macOS,从此老派的 Automator 就逐渐被 Shortcut 替代了。

正如上述提到的,我们需要使用 SiriKit Intents 来实现以支持 macOS 13 以下的系统。对于未来或者进阶需求,可以考虑使用 Xcode 提供的一键转换功能转成 App Intents(提一句,说是「转」,但实际上效果是两者共存,对于 macOS 13 或以上会走 App Intents 的形式,老系统会使用 SiriKit)。

下文会简单介绍一下流程以及「坑」,对于太细节的、官方文档已经说清楚的点,就不说太多了。

画了一个图,希望能帮助到你理解:

使用 SiriKit Intents 的一些步骤:

首先新增 Siri Intents Definition File 用来描述 Intents。

关于里面的东西就不多介绍了,可以参考官方文档

如果要支持国际化的话,可以在 File inspector 里勾选对应的语言:

但是需要特别注意:

  • 在勾选的时候,就会自动生成对应的翻译文件
  • 此翻译文件只会生成一次,后续你更新参数的时候,并不会自动更新翻译文件 —— 这意味着,如果后续你新增或者修改了参数,请最好使用文本编辑器打开此 .intentdefinition 文件,然后手动做 diff 来更新
  • 多语言本身依然是使用 Key-Value 的方式来组织,但是这里的 Key 并不是我们定义的,是 Xcode 定义的诸如 “Cd3hg7” 这样形式的,可读性非常不好
  • Definition 里面的 Summary 在 Debug 版本下国际化显示有问题,也就是说,假如你的 Base 语言是英文,那么不管你的系统、Shortcut 以及你的 app 如何设置显示的语言,在 Shortcut 里显示的 Summary 始终是英文 —— 但是发布到 App Store 后就没此问题了

多语言问题我认为是 SiriKit Intents 的痛点 —— 而 SiriKit 这种偏向语言的人机交互框架偏偏没处理好这点,实在让人困惑。

要想解决多语言问题,那么建议使用 App Intents—— 它的多语言处理方式跟 SwiftUI 本身差别不大,可以直接读 Localizable.strings 的内容。

在定义好 Intents 后,你需要编译一次 App,然后会生成一个或多个以 Handling 结尾,以你的 Intent 名字为开头的文件,你需要继承它,然后实现里面的方法:

class GroupFilesInAppIntentHandler: NSObject, GroupFilesInAppIntentHandling {
    static let userActivityID = "com.juniperphoton.handleFolder"
    
    func confirm(intent: GroupFilesInAppIntent) async -> GroupFilesInAppIntentResponse {
        let activity = NSUserActivity(activityType: GroupFilesInAppIntentHandler.userActivityID)
        let urls = [intent.folder?.fileURL].filter { u in
            u != nil
        }
        
        AppLogger.defaultLogger.log("confirm GroupFilesInAppIntentHandler, urls count \(urls.count), \(intent.folder?.fileURL?.absoluteString ?? "nil url")")

        activity.userInfo!["folderURLs"] = urls
        return GroupFilesInAppIntentResponse(code: .continueInApp, userActivity: activity)
    }
}

在这里,我选取了需要通过我的 App 打开的例子,注意返回的时候需要指定 .continueInApp,并通过 userActivity 的 userInfo 来设定你要传递的参数。

接着,你需要在 AppDelegate 的方法里来分发事件到你的 Handler。如果你的 SwiftUI app 没有启用 Adaptor,那么请先启用:

@main
struct MyerTidyApp: App {
    #if os(macOS)
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    #elseif os(iOS)
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    #endif
    
    var body: some Scene {
        #if os(macOS)
        WindowGroup {
            ScaffoldView()
        }.windowStyle(.hiddenTitleBar)
        #else
        WindowGroup {
            ScaffoldView()
        }
        #endif
    }
}

因为 iOS 和 macOS 的 AppDelegate 是不一样的,因此需要分别指定,并使用宏来进行编译时条件隔离。

application(_:handlerFor:) 里进行分发:

func application(_ application: NSApplication, handlerFor intent: INIntent) -> Any? 
    AppLogger.defaultLogger.log("about to handle intent")
    
    if intent is DeleteEmptyFoldersIntent {
        return DeleteEmptyFoldersIntentHandler()
    } else if intent is GroupFilesInAppIntent {
        return GroupFilesInAppIntentHandler()
    } else if intent is GroupFilesByIntent {
        return GroupFilesByIntentHandler()
    } else {
        return nil
    }
}

想要接收上述 .continueInApp 里传递的事件到 App,那么需要实现 application(_:continue:restorationHandler:)

func application(_ application: NSApplication,
                 continue userActivity: NSUserActivity,
                 restorationHandler: @escaping ([NSUserActivityRestoring]) -> Void) -> Bool {
    let handler = createUserActivityHandler(activityType: userActivity.activityType)
    return handler.handle(userActivity: userActivity)
}

createUserActivityHandler 为我自己封装的方法,本质是读取 userActivity 的 Type 然后分发到不同的处理器来进行处理。

那么现在 Intents 定义文件有了,App 的事件传递有了,Handler 有了,那么是否就可以了呢?还不行,你还需要在 Xcode 里添加你的 Intent 定义:

特别注意:你的 UserActivity 的 activityType 也需要出现在此列表上,否则 application(_:continue:restorationHandler:) 将不会回调。

这样,运行 Shortcut app,你定义的 Intents 就能出现了:

对于转换使用 App Intents ,笔者只是初步尝试了一下,后续有进展后做更仔细的介绍。

另外,你可以在这里看到源码

分发 Handler 的一些坑

一些背景:

  • SwiftUI 支持了抛去过往 AppDelegate 的方式,使用 App API 即可表示一个 SwiftUI app 并配置它的行为
  • 如果有些事件在 SwiftUI App 里没有提供,那么需要使用 Adaptor 的方式来启用老的 AppDelegate

那么,在分发来自 Shortcut action 过程中,遇到以下问题:

其他的一些坑

  • 在 Debug 过程中的一些时候,你什么代码都没修改,但是发现运行 Shortcut 的时候 application(_:handlerFor:) 突然没有回调了, 这个时候怀疑是触发了系统的某些限制,你所需要的就是等待 —— 如果可以,试着 clean 一下项目重新编译运行
  • 正如上文提到的,Definition 里面的 Summary 在 Debug 版本下国际化显示有问题,也就是说,假如你的 Base 语言是英文,那么不管你的系统、Shortcut 以及你的 app 如何设置显示的语言,在 Shortcut 里显示的 Summary 始终是英文 —— 但是发布到 App Store 后就没此问题了