[macOS翻译] SwiftAutomation:教程介绍

39 阅读13分钟

本文由 简悦 SimpRead 转码,原文地址 hhas.bitbucket.io

本章提供了使用 Swift 和 SwiftAutomation 编写应用程序脚本的实用体验。后期:......

本章提供了使用 Swift 和 SwiftAutomation 编写应用脚本的实用体验。后面几章将介绍 SwiftAutomation 使用的技术细节,在此主要是略过。

下面的教程使用 SwiftAutomation、TextEdit 和命令行 swift 程序来执行一个简单的 "Hello World "练习。

注意事项 建议在本教程中不要在 TextEdit 中打开任何其他文档,因为很容易意外更改现有文档,而且可能无法挽回。

注意事项

在理想情况下,下面的练习会在 Swift 的交互式命令行 REPL 中执行;遗憾的是,虽然 REPL 可以顺利执行应用程序命令,但只要返回 Specifier 对象,显示的结果就完全无法阅读,这要归功于 LLDB 转储了数千行内部对象结构,而不是像正常的 REPL 那样只打印其 debugDescription 字符串。关于这个问题有一个 bug filed;请随时帮助将其提升到优先级列表。

(Playgrounds 就更不合适了,因为每当一行发生变化时,它们都会重新执行_all_语句,因此在执行诸如 setmakedelete 等改变应用程序状态的命令时,既非常恼人又极不安全)。

现在,下面的练习应该用纯文本/代码编辑器写成非交互式的.swift"脚本",其中包含以下第一行:

#!/usr/bin/swift -target x86_64-apple-macosx10.12 -F /Library/Frameworks

脚本可在终端中单独运行(运行 chmod +x NAME.swift 使 shell 脚本可直接执行),如果代码编辑器支持,也可直接从代码编辑器中运行。例如,Bare Bones Software 提供的免费 TextWrangler 编辑器有一个 #! ➝ Run 菜单选项,可以直接执行 "shell 脚本",避免了在编辑器和 shell 之间来回跳转。

目标文本编辑器

第一步是为 TextEdit 导入 Swift 胶水文件:

#!/usr/bin/swift -target x86_64-apple-macosx10.12 -F /Library/Frameworks

import SwiftAutomation; import MacOSGlues

MacOSGlues.framework "包含 macOS 提供的许多 "AppleScriptable "应用程序的即用胶水,包括 Finder、iTunes 和 TextEdit。每个胶水文件都定义了 Swift 类,让你可以使用人类可读代码控制某个特定的应用程序。可以使用 SwiftAutomation 的 aeglue 工具为其他应用程序创建胶水;详情请参见第 4 章。

接下来,创建一个新的 Application 对象,用于控制 TextEdit:

let textedit = TextEdit()

默认情况下,新的 Application 对象将使用创建胶水的应用程序的捆绑标识符;在本例中为 "com.apple.TextEdit"。识别应用程序的其他方式包括名称(如 "TextEdit")、完整路径("/Applications/TextEdit.app"),甚至远程 URL("eppc://my-other-mac.local/TextEdit"),但对于大多数任务而言,默认行为已经足够。

现在键入以下一行并运行脚本:

try textedit.activate()

这将使 TextEdit 成为当前活动(最前端)的应用程序。(激活 "是所有应用程序都应响应的标准命令)。如果目标应用程序尚未运行,SwiftAutomation 将在发送命令前自动启动它。

所有应用程序命令在失败后都会抛出错误,因此请确保在命令前添加 try 关键字,否则 Swift 将拒绝编译该命令。

获取 TextEdit 文档

[待办事项:此处需要一句话,说明大多数 "可脚本 "应用程序都有一个分层的 "对象模型"。\]

textedit.documents

[要获取当前打开的 TE 文档列表,请向 TextEdit 发送 get 命令,请求该引用。\]

print(try textedit.documents.get())

运行该脚本将产生如下输出(假设 TextEdit 在启动时打开了一个新的空文档):

[TextEdit().documents["Untitled"]]

[这里的结果是一个 Swift 数组,其中包含一个 对象指定符。将指定符视为一个简单的一级查询,与 XQuery 路径并无二致,但它是由嵌套对象而不是斜线分隔的字符串组成的。]

[稍后我们将进一步介绍 get 的用法,但首先让我们看看如何创建新对象:]

创建一个新的 TextEdit 文档

在深入探讨文档对象之前,我们先来看看如何创建新对象。在传统的 DOM 风格 API 中,创建一个新的 "Node "类实例,必要时将新对象赋值给一个临时变量,同时设置其状态,然后将新对象插入现有 "Node "实例中的 Array<Node>中。但是,苹果事件 IPC 处理的是远程对象,而不是本地对象,这意味着你无法在自己的代码中创建或存储对象,因为它们存在于一个完全独立的进程中。

[需要一句话来说明通过操作对象图来改变应用程序的状态,而不是我们更熟悉的 Cocoa 方法(实例化类并将对象插入数组)]。

首先,通过创建一个新的 document 对象来创建一个新的 TextEdit 文档。这是使用 make 命令完成的,传递给它一个命名参数 new:,表示要创建对象的类型;在本例中是 TED.document

try textedit.make(new: TED.document)

如果应用程序尚未运行,它将在你第一次发送命令时自动启动。

成功后,TextEdit 的 make 命令会返回一个 object specifier,用于标识新创建的对象,例如:

TextEdit().documents["Untitled"]

这个特定的对象指定符表示 TextEdit 的主应用程序对象与名为 "Untitled.txt "的文档对象之间的一对一关系。(用 AppleScript 术语来说,名为 "Untitled.txt "的文档对象是名为 "TextEdit "的应用程序对象的一个 元素)。

[待办事项:如何最好地显示一对多关系的示例?让用户写 "TextEdit().documents.paragraph "可能是个不错的选择,因为它强调了查询是如何描述抽象关系而不是字面包含关系的。]

该指定符可以分配给常量或变量,以便于重复使用。使用 make 命令创建另一个文档,这次将结果赋值给一个名为 doc 的变量,如图所示:

let doc = try textedit.make(new: TED.document) as TEDItem

声明命令的返回类型 (TEDItem)并非必要,但却大大提高了可用性和可靠性。如果不声明,Swift 编译器会将 doc 变量的类型推断为 Any,从而允许它保存应用程序可能返回的任何值。但是,在将返回值转换为更具体的类型之前,您无法使用返回值的属性和方法。直接转换命令的返回值不仅可以让 Swift 编译器推断出 doc 变量的确切类型,还可以让 SwiftAutomation 在解压缩之前将应用程序返回的任何值强制转换为该类型,否则,如果不支持转换,就会抛出错误。例如,如果应用程序命令返回一个整数,您通常会将其转换为 Int;但您也可以将其转换为 String,在这种情况下,SwiftAutomation 将自动执行强制,并返回一个字符串。这在 Apple 事件的弱动态类型系统和 Swift 的强静态类型系统之间提供了稳健而灵活的类型安全桥接。

[待办事项:不要说 TEDItem 到底是什么意思......什么时候才能说清楚?]

上面的 make 命令只是在 TextEdit 中创建了一个新的空白文档,并返回对它的引用(TextEdit().documents["Untitled"])。要创建带有自定义内容的新文档,可使用 make 命令的可选参数 withProperties: 为文档对象的一个或多个属性指定初始值:

let doc = try textedit.make(new: TED.document, withProperties: [TED.text: "Hello World!"]) as TEDItem

在这里,我们告诉 TextEdit 创建一个包含文本 "Hello World!"的新文档对象。[要做的事:SDEF 在这里会有所帮助]

[要做:如果将上述 make 命令扩展为包含 withProperties: [TED.text: "Hello World!"]]. 接下来的任务是get()该文档的文本,这就说明了对象指定符只能用于构造_查询_;要真正从应用程序中获取值,必须使用命令,例如get()。(这有点像把描述某些数据位置的文件路径(如"/Users/jsmith/" + "TODO.txt")放在一起,以及把该路径传递给 read() 命令以从该位置实际获取数据之间的区别)。一旦获取完成,"set() "示例就可以演示如何将内容更改为其他内容。此外,"get() "示例还可以解释为简洁起见,允许使用命令的直接参数作为主语的速记形式,即 textedit.get(doc.text)->doc.text.get()

[要做:在脚本编辑器中打开 TextEdit 的 SDEF 文档,并总结其内容和组织结构;或者,在 AppleScriptToSwift 中捆绑 MacOSGlues sdefs,并提供一个菜单和字典查看器,以便在此查看它们]

[要做:注意 get/set 通常不在应用程序字典中记录]

获取文件内容

使用 get() 命令获取文档文本:

try doc.text.get()
// "Hello World

[待办事项:重写/替换] 这似乎有违直觉,因为对字面引用进行求值会返回该引用所标识的 value。但是,SwiftAutomation 只使用面向对象的引用来构建对象指定符,而不是解析它们。请务必记住,对象指定符实际上是一个一流的查询对象,因此虽然语法看起来与面向对象引用相似,但其行为却截然不同。例如,在评估字面引用时

textedit.documents[1].text

的结果是一个对象指定符 TextEdit().documents[1].text,而不是指定的值("Hello World")。要获取指定的值,必须将对象指定符作为直接参数传递给 TextEdit 的 get() 命令:

try textedit.get(doc.text)
// "Hello World!

和以前一样,SwiftAutomation 提供了其他方便的形式,可以更简洁地编写上述命令:

try doc.text.get()

设置文档内容

下一步是将文档内容设置为字符串 Hello World。每个 TextEdit 文档都有一个属性 text,表示文档的全部文本。该属性既可读又可写,允许您以未样式化文本的形式检索和/或修改文档的文本内容。

属性值的设置是通过应用程序的 "set() "命令完成的,该命令在 "TextEditGlue.swift "文件的 "TextEdit "类中表现为一个实例方法。set() 命令需要两个参数:一个直接参数(未命名)和一个命名参数 to:。直接参数必须是一个对象指定符(由 TextEdit 胶水的 TEDItemTEDItems 类表示),用于标识要修改的一个或多个属性,而 to: 参数则提供要分配给该属性的新值--在本例中是一个 String

由于我们已经在 doc 变量中存储了目标文档的对象说明符,因此我们将使用它来构建一个新的对象说明符,以标识该文档的 text 属性: doc.text。对这个表达式进行求值,结果将是

在本例中,直接参数是标识新文档 text 属性 doc.text 的对象指定符,而 to: 参数是字符串 Hello World

try textedit.set(doc.text, to: "Hello World")

现在,前面的 TextEdit 文档应该包含文本 "Hello World"。

由于上面的表达式写起来有点笨重,SwiftAutomation 允许将其作为一个特例,以更优雅的类似于 OO 的格式来编写,其中 set() 命令是根据文档的对象规范调用的:

try doc.text.set(to: "Hello World")

SwiftAutomation 会在内部将第二种形式转换为第一种形式,因此最终结果完全相同。SwiftAutomation 支持几种这样的特例,应用命令一章将对它们进行介绍。使用这些特例可以生成更优雅、可读性更强的源代码,因此推荐使用。

更多关于安全使用命令类型的信息

根据对象指定符所标识的属性类型,get() 可能返回一个原始值(数字、字符串、列表、dict 等),也可能返回另一个对象指定符或对象指定符列表,例如

try doc.text.get()
// "Hello World!"

try textedit.documents[1].get()
// TextEdit().documents["Untitled"]

try textedit.documents.get()
// [TextEdit().documents["Untitled"], TextEdit().documents["Untitled 2"]]

try textedit.documents.text.get()
// ["Hello World", ""]

关于 make() 的更多信息

上述练习使用两条命令创建了一个包含文本 "Hello World "的新 TextEdit 文档。通过 make() 命令的可选 withProperties: 参数传递新文档的 text 属性值,也可以单独使用 make() 命令执行这两个操作:

[TO DO: 重写并在本节中插入 "由于 document 对象是根 application 类的元素,TextEdit 等应用程序通常可以推断出新的 document 对象应出现的位置。在其他情况下,您需要提供一个 at 参数来指示所需的位置。]

try textedit.make(new: TED.document, withProperties=[TED.text: "Hello World"])
// TextEdit().documents[1]

[TO DO: TextEdit 现在返回以名称命名的文档指定符;请相应更新本段内容] 顺便提一下,你可能会注意到每次使用 make() 命令时,它都会返回文档 1 的对象指定符。TextEdit 根据窗口的堆叠顺序来识别文档对象,文档 1 位于最前面。当窗口堆叠顺序发生变化时,无论是脚本命令还是基于图形用户界面的交互,其对应的 document 对象的顺序也会发生变化。这意味着以前创建的对象指定符(如 TextEdit().documents[1])现在可能指定了与以前不同的 document 对象!有些应用程序倾向于返回通过名称或唯一 ID 而不是索引来标识对象的对象指定符,以减少或消除混淆的可能性,但这是一个你应该注意的问题,尤其是对于长期运行的脚本,因为在这种情况下,意外的第三方交互会有更大的机会给工作带来麻烦。

有关操作 text 的更多信息

除了通过对text属性应用get()set()命令来获取和设置文档的整个文本外,还可以直接操作文档文本的选定部分。TextEdit 的 text 属性包含一个 text 对象,该对象又包含 character, wordparagraph 元素,所有这些元素都可以使用各种命令进行操作,如 get(), set(), make(), move, delete 等。例如,要将正面文档每个段落第一个字符的大小设置为 24pt:

try textedit.documents[1].text.paragraphs.size.set(to: 24)

或在文档末尾插入一个新段落:

try textedit.make(new: TED.paragraph,
                   at: TEDApp.documents[1].text.paragraphs.end,
             withData: "Hello Again, World\n")

[待办事项:添加注释:与 AS 不同,Swift 对参数顺序很敏感,因此命名的参数必须以与 glue 中相同的顺序出现]

编写独立的 "脚本

[待做:添加使用以下 hashbang 编写和运行 "脚本 "的说明]

#!/usr/bin/swift -target x86_64-apple-macosx10.11 -F /Library/Frameworks

例如,将下面的 Swift "脚本 "文件保存到磁盘并使其可执行(chmod +x /path/to/script)时,会返回最前面的 Finder 窗口中显示的文件夹的路径(如果有的话): [待办事项:重新表述为逐步练习]

#!/usr/bin/swift -target x86_64-apple-macosx10.12 -F /Library/Frameworks

// Output path to frontmost Finder window (or a selected folder within).

import Foundation
import SwiftAutomation
import MacOSGlues

public struct StderrStream: TextOutputStream {
  public mutating func write(_ string: String) { fputs(string, stderr) }
}
public var errStream = StderrStream()

do {
  let finder = Finder()
  let selection: [FINItem] = try finder.selection.get()
  let frontFolder: FINItem
  if selection.count > 0 {
    let item = selection[0]
    frontFolder = [FIN.disk, FIN.folder].contains(try item.class_.get()) ? item : try item.container.get()
  } else if try finder.FinderWindows[1].exists() {
    // TO DO: this doesn't work if Computer/Trash window
    frontFolder = try finder.FinderWindows[1].target.get()
  } else {
    frontFolder = finder.desktop
  }
  let fileURL: URL = try frontFolder.get(requestedType: FIN.fileURL)
  print(fileURL.path)
} catch {
  print(error, to: &errStream)
  exit(1)
}

www.deepl.com 翻译