WWDC Quick Look 💓 By SwiftGGTeam
Meet Shortcuts for macOS

Meet Shortcuts for macOS

观看原视频

Highlight

Apple 将 Shortcuts 带到 macOS,并让 AppKit 与 Mac Catalyst 应用通过 Intents 框架提供自定义 action,用户可以在 Shortcuts、菜单栏、键盘快捷键、Spotlight、AppleScript 和命令行中运行这些自动化能力。

核心内容

Mac 用户一直有自动化工具。AppleScript 可以控制应用,Shell Script 可以处理文件,Automator 可以把动作串成工作流。

问题是入口分散。一个普通用户想把待办 App、图片编辑 App、信息 App 串起来,往往要知道脚本、文件格式、应用事件。开发者也要为不同自动化入口写不同适配。

WWDC 2021 的变化是 Shortcuts 来到 macOS。用户打开 Mac 版 Shortcuts,会看到从 iPhone 同步过来的快捷指令。Shortcuts 编辑器也为 Mac 重做,支持 Mac 专属 action。

对开发者来说,关键变化是 Mac 应用可以像 iOS 应用一样提供 action。一个待办 App 暴露“创建任务”“查找任务”“删除任务”等操作后,用户可以把它接到 Notes、Messages、Files 或其他 App 后面,组成多步工作流。

Apple 没有要求开发者放弃旧的 Mac 自动化体系。Shortcuts 支持运行 AppleScript 和 Shell Script,也能导入大多数 Automator workflow。已有脚本可以继续用,新 action 可以接进同一个工作流。

从 App 功能到可组合 action

一个 action 最好是小的、明确的操作。演讲里的示例是协作任务管理 App。它的核心对象是 task,字段包括标题、截止日期、分享链接。围绕 task,可以提供创建、编辑、删除、查找、显示等动词。

这样拆分之后,用户可以从 Notes 选中文字,自动调整格式,输入截止日期,再调用 Create Task action。任务创建后,action 返回分享链接,后续步骤可以把链接发给别人。

从 iOS 到 Mac 的同一套 intent

macOS Monterey 让 Mac Catalyst App 使用同一套 Intents API。已有 iOS App 如果曾经在 Mac 版本中编译掉 Intents 集成,需要重新检查代码,把这部分能力在 macOS Monterey 上打开。

如果 App 同时发布 iOS 和 Mac 版本,Apple 建议两端编译同一个 intent definition 文件,并保持 intent 名称和参数一致。用户在一个平台创建的 shortcut,才能在另一个平台继续运行。

详细内容

定义 intent:把 action 当成函数设计

09:56)实现 Shortcuts action 的第一步是在 Xcode 中创建 intent definition 文件。这个文件会随 App target 一起编译,Xcode 会根据它生成 Swift 类型和协议。

演讲里的 Create Task intent 有两个输入参数:titledueDate。它还定义了一个 Task 类型,包含系统自动提供的 identifierdisplayString,以及 App 自己添加的 dueDateURL

// Intent definition 生成的类型会在构建后出现在代码中
let intent = CreateTaskIntent()
intent.title = "Review pull request"
intent.dueDate = DateComponents(calendar: .current, year: 2021, month: 6, day: 8, hour: 15)

关键点:

  • CreateTaskIntent 来自 .intentdefinition 文件,不需要手写类型声明。
  • title 是创建任务所需的文本参数。
  • dueDate 使用 Date Components 类型,对应演讲中为截止日期选择的参数类型。
  • 构建项目后,Xcode 会生成 Task 类型和 CreateTaskIntent 类型。

13:17)Shortcuts app 区域还要配置 input parameter 和 summary。演讲中把 title 设为 input parameter。这样用户把上一段文字接到 Create Task action 时,标题会自动填入。

Summary: Create task with due date
Input parameter: title
Required parameters: title, dueDate

关键点:

  • Summary 是用户在 Shortcuts 编辑器里看到的动作摘要。
  • Input parameter 决定上一步输出默认填到哪个参数。
  • 必填参数放进摘要后,用户不用展开 action 就能看到关键输入。

分发 intent:让 macOS 把 action 交给 App

15:15)Mac App 处理 intent 有两种方式。第一种是在 App 内处理,intent 会送到 App delegate。第二种是建立 Intents extension,用轻量独立进程处理。

Apple 建议大多数 App 从 in-app intent handling 开始。这样 action 可以直接修改 App 状态。只有当启动完整 App 成本太高时,再考虑 extension。

import SwiftUI
import Intents

@main
struct SouperTaskApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

class AppDelegate: NSObject, NSApplicationDelegate {
    func application(_ application: NSApplication, handlerFor intent: INIntent) -> Any? {
        if intent is CreateTaskIntent {
            return IntentHandler()
        }
        return nil
    }
}

关键点:

  • import Intents 引入 SiriKit 所在的 Intents 框架。
  • @NSApplicationDelegateAdaptorAppDelegate 接入 SwiftUI App 生命周期。
  • application(_:handlerFor:) 是 macOS Monterey 中 NSApplicationDelegate 可用的 intent 分发入口。
  • intent is CreateTaskIntent 判断当前 intent 是否由 Create Task action 触发。
  • IntentHandler() 是负责处理该 intent 的对象。
  • return nil 表示 App 不处理其他 intent。

解析参数:在执行前要求用户补齐输入

18:32)Intent handler 要遵守 Xcode 生成的协议。Create Task intent 对应 CreateTaskIntentHandling。协议会包含 resolve、provide options、confirm、handle 四类方法。

resolve 用来检查单个参数。Create Task 有两个必填参数,标题和截止日期,所以生成两个 resolve 方法。

class IntentHandler: NSObject, CreateTaskIntentHandling {
    func resolveTitle(for intent: CreateTaskIntent, with completion: @escaping (INStringResolutionResult) -> Void) {
        guard let title = intent.title, !title.isEmpty else {
            return completion(.needsValue())
        }
        return completion(.success(with: title))
    }

    func resolveDueDate(for intent: CreateTaskIntent, with completion: @escaping (CreateTaskDueDateResolutionResult) -> Void) {
        guard let dateComponents = intent.dueDate else {
            return completion(.needsValue())
        }
        return completion(.success(with: dateComponents))
    }
}

关键点:

  • IntentHandler 遵守 CreateTaskIntentHandling,这是 intent definition 生成的协议。
  • resolveTitle 检查 intent.title 是否存在且非空。
  • .needsValue() 告诉系统需要向用户询问这个值。
  • .success(with: title) 表示标题参数通过检查。
  • resolveDueDate 检查截止日期是否存在。
  • CreateTaskDueDateResolutionResult 是截止日期参数对应的解析结果类型。

自定义校验:拒绝过去的日期

19:37)演讲中的 App 要求任务截止日期必须在未来。开发者先在 intent definition 文件中添加自定义错误 invalidDate,再在 resolve 方法里返回 unsupported。

func resolveDueDate(for intent: CreateTaskIntent, with completion: @escaping (CreateTaskDueDateResolutionResult) -> Void) {
        guard
            let dateComponents = intent.dueDate,
            let dueDate = Calendar.current.date(from: dateComponents)
        else {
            return completion(.needsValue())
        }
        if dueDate < Date() {
            return completion(.unsupported(forReason: .invalidDate))
        }
        return completion(.success(with: dateComponents))
    }

关键点:

  • intent.dueDate 取出用户输入的 Date Components。
  • Calendar.current.date(from:) 把 Date Components 转成可比较的 Date
  • guard 失败时返回 .needsValue(),让用户补充日期。
  • dueDate < Date() 判断日期是否早于当前时间。
  • .unsupported(forReason: .invalidDate) 返回 intent definition 中定义的错误原因。
  • .success(with: dateComponents) 表示日期可以用于执行 action。

执行 action:创建任务并返回输出

20:34handle 方法执行真正的业务逻辑。演讲中它读取标题和截止日期,创建任务,构造响应对象,再把创建出的 task 放进响应。

class IntentHandler: NSObject, CreateTaskIntentHandling {
    func handle(intent: CreateTaskIntent, completion: @escaping (CreateTaskIntentResponse) -> Void) {
        let title = intent.title!
        let dueDate = intent.dueDate!

        let task = createTask(name: title, due: dueDate)

        let response = CreateTaskIntentResponse(code: .success, userActivity: nil)
        response.task = task
        completion(response)
    }
}

关键点:

  • handle 会在参数解析完成后调用。
  • intent.title!intent.dueDate! 读取已通过 resolve 的参数。
  • createTask(name:due:) 代表 App 内部创建任务的业务逻辑。
  • CreateTaskIntentResponse(code: .success, userActivity: nil) 构造成功响应。
  • response.task = task 把新建任务作为 action 输出。
  • completion(response) 把结果交还给 Shortcuts,后续 action 可以读取 task 的属性,例如分享链接。

文件型 App:用 file parameter 处理文档

23:24)iOS 15 和 macOS Monterey 的 intent file parameter 允许用户选择文件,并把文件传给 App 的 action。文档型 App 可以围绕文件设计 action。

演讲给出的例子是电子表格 App。它可以提供打开文档的 action,也可以提供向某个文件的一行添加数据的 action。Sound Analysis session 也展示了第三方 Shortcuts 如何处理文件。

func resolveDocument(for intent: AddRowIntent, with completion: @escaping (INFileResolutionResult) -> Void) {
    guard let file = intent.document else {
        return completion(.needsValue())
    }
    return completion(.success(with: file))
}

关键点:

  • intent.document 表示 Shortcuts 传入的文件参数。
  • INFileResolutionResult 用来返回文件参数的解析结果。
  • .needsValue() 要求用户选择文件。
  • .success(with: file) 表示文件可以传给后续处理逻辑。
  • 这类 action 适合文件存在磁盘上的文档型 App。

从 AppleScript 运行 Shortcuts

25:39)Shortcuts 暴露了 scripting interface。AppleScript 可以通过 Shortcuts Events 进程按名称运行一个快捷指令。

tell application "Shortcuts Events"
	run the shortcut whose name is "Make GIF"
end tell

关键点:

  • Shortcuts Events 是 Shortcuts 的脚本接口进程。
  • run the shortcut 触发一个已有快捷指令。
  • whose name is "Make GIF" 按用户创建的 shortcut 名称查找目标。
  • 旧 AppleScript 工作流可以用这种方式调用新 Shortcuts。

从 Swift 或 Objective-C 运行 Shortcuts

25:49)Mac App 可以用 Scripting Bridge 调用同一个 Shortcuts Events 进程。沙盒 App 还需要添加 com.apple.security.scripting-targets entitlement,并加入 com.apple.shortcuts.run target。

import ScriptingBridge

@objc protocol ShortcutsEvents {
    @objc optional var shortcuts: SBElementArray { get }
}
@objc protocol Shortcut {
    @objc optional var name: String { get }
    @objc optional func run(withInput: Any?) -> Any?
}

extension SBApplication: ShortcutsEvents {}
extension SBObject: Shortcut {}

guard
    let app: ShortcutsEvents = SBApplication(bundleIdentifier: "com.apple.shortcuts.events"),
    let shortcuts = app.shortcuts else {
    print("Couldn't access shortcuts")
    return
}

guard let shortcut = shortcuts.object(withName: "Make GIF") as? Shortcut else {
    print("Shortcut doesn't exist")
    return
}

_ = shortcut.run?(withInput: nil)

关键点:

  • import ScriptingBridge 引入 Scripting Bridge 框架。
  • ShortcutsEvents 协议声明 shortcuts 集合。
  • Shortcut 协议声明 namerun(withInput:)
  • SBApplication(bundleIdentifier: "com.apple.shortcuts.events") 连接到 Shortcuts Events。
  • app.shortcuts 取得用户已有的 shortcut 列表。
  • shortcuts.object(withName: "Make GIF") 查找指定名称的快捷指令。
  • shortcut.run?(withInput: nil) 运行快捷指令,并传入空输入。

核心启发

  • 做什么:给任务管理 App 增加“从选中文本创建任务”action。 为什么值得做:演讲中的 Create Task action 证明,Shortcuts 可以把上一段文本自动填入 intent input parameter。 怎么开始:创建 intent definition,定义 titledueDate 参数,把 title 设为 input parameter,实现 CreateTaskIntentHandling 的 resolve 与 handle。

  • 做什么:给文档 App 增加“对指定文件执行处理”action。 为什么值得做:iOS 15 和 macOS Monterey 支持 intent file parameter,用户可以从 Files 或 Finder 流程中把文件传给 App。 怎么开始:为 action 添加文件参数,在 handler 中用 INFileResolutionResult 校验文件,再在 handle 中读取文件并返回新文件。

  • 做什么:给图片或音频 App 提供一组可组合的批处理 action。 为什么值得做:Shortcuts 可以把第三方 action 接到系统 action 后面,例如选择文件、处理内容、分享结果。 怎么开始:先挑 3 个原子操作,例如“应用滤镜”“导出文件”“生成分享链接”,分别建成 intent,并为每个 response 定义可被后续步骤使用的输出。

  • 做什么:在 Mac App 里提供“运行用户指定 shortcut”的入口。 为什么值得做:Shortcuts 提供 Shortcuts Events scripting interface,App 可以列出并运行用户已有快捷指令。 怎么开始:引入 ScriptingBridge,用 SBApplication(bundleIdentifier: "com.apple.shortcuts.events") 连接 Shortcuts Events,按名称找到 shortcut 后调用 run(withInput:)

  • 做什么:让 iOS App 的 Shortcuts action 同步支持 Mac。 为什么值得做:macOS Monterey 的 Mac Catalyst App 可以使用同一套 Intents API,用户在 iPhone 创建的 shortcut 可以在 Mac 继续使用。 怎么开始:检查 Mac target 是否编译同一个 intent definition,确认 intent 名称和参数在 iOS 与 Mac 版本中保持一致。

关联 Session

评论

GitHub Issues · utterances