CyanTalks
首发于CyanTalks

还在用 Alfred?macOS 内置生产力神器 JSA 了解一下

Once upon a time,我还在用 AppleScript,当我尝试了 JSA 以后,AppleScript 就是个垃圾!

其实 AppleScript 真的是一个很老的技术,早在 Mac OS 7 的时候,它就已经诞生了,不过这个语言放到现在感觉依旧很强,拥有几近自然语言的语法和强大的 OOP 支持。

使用 AppleScript 编写的一段自动化脚本

然鹅!我发现有很多人说 AppleScript 很垃圾!emmm,其实对于我们程序员来说写着这个古老的又像 VB 又像 SmallTalk 的奇葩语言确实比较难接受。但是谁说 macOS 自动化脚本只能用 AppleScript 来写?自 OS X Yosemite 10.10 开始,Script Editor 就开始支持 JavaScript 了,Apple 称这个特性为 JavaScript for Automation(以下简称 JSA)。

其实不管是 AppleScript 还是 JSA,他们也都隶属于 macOS 的 OSA 体系,只是语言不同,很多概念都是相通的。

Hello World in JSA

用 JSA 写一个 Hello World 真的比 AppleScript 简单多了,尤其是对前端 er 们来说:

console.log('Hello, world!');

没错,JSA 也内置了 console 等常见的对象,我们还不妨看看 JSA 有什么别的内置对象:

是不是很齐全?其实 JSA 使用的也是 macOS 内置的 JavaScriptCore(如果你对 Cocoa 开发不了解的话,你只要知道它是 Safari 的 JavaScript 引擎就好了)来解释并执行的,目前已经支持了 ES7:

不过别看可以使用 async/await,但是 OSA 体系里没有多少 API 是返回 Promise 的,所以没啥大用 = =

对了,Script Editor 超级难用,没有 REPL 也没有自动补全,就连语法高亮都要先编译一下。好在由于 JavaScriptCore 的加持,我们可以用 Safari 的开发者工具来调试代码和当 REPL。食用方法是启用 “开发” - “的 MacBook Pro” - “自动显示 JSContext 的网页检查器” 选项,然后运用你的 JavaScript 知识打一个断点:

OSA 基本概念

OSA 体系中绝大多数的概念都是面向对象的,我们最常打交道的对象时 Application,它表示了一个应用程序。比如如果我们要操作 Google Chrome,就这样:

const chrome = Application('Google Chrome');

OSA 的绝大多数对象都是 native code,你可以用 new 的方式创建一个对象实例,也可以直接调用构造方法。

返回的对象仍旧是一个 native code,而且属性都是动态解析的,所以如果你想知道 chrome 这个变量里都有什么属性和方法,你就需要查手册了。打开 Script Editor 的 “窗口” - “资源库”,选择你想查的应用(比如 Google Chrome):

这玩意呢其实很难看,我一开始是懵逼的。首先我们的 chrome 变量是一个 Application 类的实例,然后在这个窗口顶部最右边的一栏有它的所有属性。

然后第一个坑就来了,为什么我拿不到 window 这个属性?当你尝试写 chrome.window 的时候,你会发现并不能拿到你想要的东西,其实这个 window 与 Application 是一个 contains 的关系,当你往下看的时候就会发现:

所以应该写 chrome.windows。

其次当你观察它的返回值的时候,你会发现它是一个 ArraySpecifier,wtf is this?这其实又是一个 OSA 的概念,OSA 中有很多 Specifier,简单说就是一个真实对象的外层封装,你可以通过一个 Specifier 获取到另一个 Specifier,emmm。

而对于 chrome.windows 的返回值,你可以通过 byId、byName 或者数组下标的方式拿到里面的内容,你可以把它想象成一个特殊的数组(不能用 for...of 语法,可以用 for...in)。

第二个坑是,别看这里 Application 里只有那么一点属性和方法,这只是一个 Suite,Suite 是 OSA 的另一个概念,一个应用里不同类的功能会被放在不同的 Suite 里,不同 Suite 里可能有相同的类,但这就跟 Objective-C 的 Category 一样,所有 Suite 的属性和方法聚集在一起就是一个完整的类了。所以如果你在一个类里找不到你想要的东西,不妨换一个 Suite 看看,如果再不行,那就是真不支持了...


接下来我们试试操作一下 Chrome,比如创建一个标签页,先上一段代码:

const chrome = Application('Google Chrome');
const tab = chrome.Tab({ url: 'https://youtube.com' });
chrome.windows[0].tabs.push(tab);

简单解释一下。第一行就不说了。第二行是创建一个标签页对象,为什么这样写呢?由于每个应用都有自己类,为了不污染全局空间,应用特有的类的构造函数存在于应用自己的 Application 类中,所以你在文档中查到的 Chrome 中的 Tab 类就存在与 chrome 这个变量中。同样,创建一个 OSA 的类不需要 new,直接将属性以对象的方式传进去即可。最后一行是将新创建的标签页加到当前窗口中。

是不是有点反应式的感觉?由于 tabs 并不是一个普通的数组,而是一个 native code,所以它的行为就可以跟很多前端 MVVM 框架中的反应式对象一样,直接修改对象就可以反映到应用的行为上。

前面我们谈到了 Object Specifier 的问题,OSA 返回的对象很多属性并不是真实的值,而是一个 Object Specifier,比如:

chrome.windows[0].tabs[0].name;  // [object ObjectSpecifier]
chrome.windows[0].tabs[0].name();  // 写文章

一般来讲,把一个 ObjectSpecifier 当做函数调用就可以获取其真实的值。

你可能会问 Object Specifier 还有什么用呢?它提供了许多种获取其他对象的方法,比如 Array Specifier 可以通过下标拿到一个对象,你还可以直接调用它的元素的属性,就像:

chrome.windows[0].tabs.name;  // [object ObjectSpecifier]

你没有指定下标,而是指定了一个属性名,这时候会返回另一个 ObjectSpecifier 对象,而调用它你就能拿到所有 tabs 的标题数组。所有 Specifier 都支持这种声明式的用法。

Objective-C Bridging

JSA 最厉害的其实是与 Objective-C 交互的支持。全局环境中有一个 ObjC 对象,你可以通过这个对象来实现 Framework 的加载和 OC 类的创建。举个简单的例子:

ObjC.import('Cocoa');  // 加载 Cocoa.framework,提供 NSWindow 等类

const win = $.NSWindow.alloc.init;
win.makeKeyAndOrderFront($());

$ 与 ObjC.$ 是等价的,用于获取一个 OC 或 C(你没看错,C 的函数也可以调用,简直就是 libffi 有木有,如果你写了一个 C 的动态链接库,也可以用 ObjC.import 加载进来)的符号。

对于 OC 的类而言,调用方法可以直接用点语法,如果方法没有参数你甚至可以省略 ( )。

对于需要传 nil 的地方,你可以写 $() 也可以直接写 null。

比较蛋疼的是长 selector 名的方法调用,写过 OC 的同学知道,OC 的方法名可以是非常长的,而且参数名也是方法名的组成部分。给没写过的人看一个 OC 的方法名:

outputImageProviderFromBufferWithPixelFormat:pixelsWide:pixelsHigh:baseAddress:bytesPerRow:releaseCallback:releaseContext:colorSpace:shouldColorMatch:

via LongestCocoa

but,JSA 并没有像 JSPatch 那样提供 : 到 _ 的转义,所以如果你想调用一个 selector 名中带 : 的方法,就只能用 key 下标的方法了,然后各个参数按顺序传入即可。

使用脚本

  1. 与 Automator 结合。在 Automator(现在改名为“自动操作”了,额)中创建一个工作流或者服务(每个服务都可以被赋予一个全局的快捷键哦),添加 AppleScript 模块即可。在工作流中你甚至可以获取到外面传入的值(比如很多服务都是可以接受一个文本或文件的),有关 Automator 的使用又是另外一个话题了,这里仅抛砖引玉一下。
  2. 与 Alfred 结合。如果你购买了 Powerpack,你也可以在 Workflow 里调用 AppleScript。
  3. 与现有 Cocoa 应用结合。如果你的应用有需求要使用 AppleScript,可以用 NSAppleScript 这个类来实现,不过 NSAppleScript 并不支持 JavaScript,需要我们先用 osacompile -l JavaScript 命令将 .js 文件编译为 .scpt 文件,就可以用 NSAppleScript 加载并使用了。

实战一下

const chrome = Application('Google Chrome');
const tabs = chrome.windows.tabs;
const snapshot = [];
for (const tabId in tabs) {
	const title = tabs[tabId].title();
	const url = tabs[tabId].url();
	snapshot.push({ title, url });
}

ObjC.import('Foundation');

const fm = $.NSFileManager.defaultManager;
fm['createFileAtPath:contents:attributes:'](
	'/tmp/snapshot.json',
	JSON.stringify(snapshot, null, '  '),
	$()
);

一段简单的脚本,就可以保存 Chrome 中所有标签页的快照。至于如何从快照中恢复标签页就交给大家去实现啦~


(本文完)


References:

  1. Introduction to JavaScript for Automation Release Notes
  2. Introduction to AppleScript Language Guide
  3. jxa@apple-dev.groups.io | Wiki
编辑于 2018-04-30

文章被以下专栏收录