likes
comments
collection
share

Raycast API 及其扩展插件是如何工作的

作者站长头像
站长
· 阅读数 12

原文链接:How the Raycast API and extensions work

原文作者:Felix Raab

前言及简单介绍:Raycast是Mac上的启动器App,类似于Alfred,但它的UI更加现代化,集成了AI能力,对于前端开发来说它的扩展的开发体验更好(Node + React + TypeScript);

本文作者主要介绍了Raycast API(开发扩展插件用到的API)是如何与Raycast主程序(Mac Native App)双向交互的,包括中间的一些技术选型的过程,以及整个扩展开发的流程进化等,使得开发者能对其整个运行过程有更好的了解,对于前端开发者来说开发一个混合原生及JS的app时也具有一定的参考作用。

以下为原文翻译:

子标题:了解更多关于我们是如何构建Raycast API以及它是如何在后台运行的相关信息。

自从我们发布Raycast API之后,很多开发者过来咨询我们这一整套东西内部到底是如何工作的。纠结一点的说这确实是个好问题,特别是因为我们有意的试着去隐藏这些在Raycast中当用户与扩展交互时的细节。

尽管从技术的角度来说,用户从我们的扩展商店里确实“安装”了扩展,但其实我们不应该有这样的直觉。我们一个早期的设计目标是我们应该避免在Raycast中用frame帧(类似iframe,或webview)来渲染一个第二类 (译者注:这里应该指的是非native的程序) 的类似“小程序”的方式,在这种实现中,开发者使用技术X和UI框架Y来开发,然后发布。对不起这可能让广大的webview粉丝失望了。

为什么我们不这样做呢?首先,Raycast是个完完全全的mac原生应用,并且我们把扩展也视为Raycast里的一等公民。对我们来说这意味着加上我们的定制能力后依然能做到UI的渲染一致性,因此当用户打开一个由扩展提供的Command(注:命令,Raycast里最重要的概念之一)时依然能感受到这是原生渲染出来的UI。

但是通常来说我们使用Swift来开发原生UI,然后用Xcode编译,所以整个系统到底是如何工作的呢?接下来我们不会以关系图和代码的形式来展开,而是以“技术层面+讲故事”的方式来深入我们的旅程,包括我们当前的解决方案,我们做出的决定、折中方案以及我们使用的具体的技术及架构。以下是基本摘要:

  • 早期的一些历史背景
  • 我们的第一个尝试:TypeScript + JSC
  • 我们的第二个尝试
  • 开发者体验
  • 功能平等性及新的领域

早期的一些历史背景

就在我们发布后不久,我们找到了我们的超能力:创建我们的社区

在2020年10月我们发布Raycast之后,我们开始探索应该如何为开发者创建一套API。我们的目标是让开发者能够自由的自定义Raycast的各项功能并且创建其他用户能够直接安装的可分享的扩展,就像app store一样。

这对我们的成功至关重要,因为我们无法自己构建所有的工具。旁注:如果你曾经试着为一些更复杂的工具例如Atlassian’s Jira开发扩展,你就能够感同身受🥲,我们想创建一个拥有集体智慧及创意的社区,在这里大家可以创建很酷及有用的东西,一些我们甚至压根没有想到过的功能。

一个扩展变成了包含一个或多个Command(命令)的通用术语

你可以打开commands来运行一个业务逻辑,通常是在Raycast中以UI展现的方式来运行。在command内,你可以在我们统一的遍布在app任何地方的action panel(操作面板)(注:可以理解为"更过操作")中通过actions来处理一些其他操作。对于一个command的可扩展性是没有限制的,你可以做任何想做的事情。如果你看过我们商店中的一些扩展的话,他们可以把这些扩展归类为基于我们的UX工具包开发的并且运行在Raycast之内的迷你app。

开始的时候我们认为对于一个可安装的包只能有一个command。我们完全没有料到人们会如此快速的推动平台的发展并且开发了如GitLab 和 Supernova这类功能齐全的扩展。因此我们花了蛮长的时间才有了现在这样的一个概念模型:一个extension扩展可以暴露一个或多个commands(哈哈,命名实在太难取了)。

盘点下我们技术选型的过程

自从我们处于桌面应用的时代后,我们没有一个可以直接拿来复制并实现的架构蓝图。而另一方面,在应用中支持扩展插件的能力也不是什么新鲜事,因此我们开始做一些能够让我们在桌面应用中实现动态加载,运行,及卸载扩展能力的基础技术调研。

回顾我们之前的各个技术选型的优缺点,你可以发现下面的一些时髦词汇:

  • Bundled Node Runtime(打包进app的Node运行时)
  • JavaScriptCore + Node Polyfills
  • WebKit
  • Deno
  • Lua
  • XPC
  • Web Server-Client

我可以继续但是你应该能理解我的意思😅,现在很流行支持各种插件的app例如VS Code很值得一看:

  1. 它是一个可以运行在macOS上的app
  2. 它有一个扩展商店及开发者社区
  3. 它是开源的

问题解决了吗?恐怕没有。那是因为VS Code是一个基于Electron的app并且运行了一个Node运行时。对于一些macOS的开发者来说这可能会触发一些潜意识里的抵触性,因为他们通常会为一些精雕细琢的原生app付费,而一个基于Electron的app很难引起他们的兴趣。

而且,难道Node不是一个强行让开发者使用一个有瑕疵的编程语言么?难道不是它的包生态有时因为安全问题导致整个web不能正常工作?加上许多其他的问题,所以它的发明者写了个新的牛逼的运行时 (注:应该指的是deno) 来解决所有这些错误。也有可能,从另一个角度说,根据Atwoods’s Law的说法,JS不会被抛弃,其他语言也能很好的被编译成JS,而且VS Code也运行的很好...

我们的第一个尝试:TypeScript + JSC

我们把JS当做我们的首要语言,TS当做我们开发扩展的主要语言。我们本可以选择一些杂而不精且简单的语言,如Lua。对于这个我们其实内心有很多疑点:

  1. 大家真的会用这个去开发扩展么?
  2. 它的生态是繁荣的么?选择一门不怎么流行的语言并说服其他人去学习它得有多难,所以我们是不是应该多利用大的开源生态来开发扩展?
  3. 我们最终会不会选择一个没有未来的语言生态作为默认的开发语言,然后一些很强的开发者后面开始质疑比如为什么我不能在扩展里使用虚幻引擎?

我们不知道,也没人知道...

开始运行

要在macOS app中运行JS首先我们想到的是苹果官方的JavaScriptCore,通过它自带的引擎或WebKit。扩展需要在独立的沙盒中运行,所以我们想到的是每个扩展都有一个独立的JavaScriptCore实例来动态的加载代码,运行,桥接到原生代码,然后还需要一些监控管理进程来处理加载,卸载及从程序错误中恢复。

理想状态下,我们不想Raycast的主进程被这些可能运行了恶意代码或运行崩溃的JS引擎严重影响到。所以我们想着我们应该通过XPC的方式让他们在主进程之外运行。XPC是苹果自己的一套进程内通信协议,是一个二进制协议。然后我们就是这么干的:一个Raycast主进程,一个XPC支持进程(扩展的宿主进程)以及很多运行在这个支持进程中的JavaScriptCore引擎。我们硬是把这套概念实现了,而且它运行的还不错...甚至我们自己都有点怀疑。

接下来我们看看UI渲染

看完了动态运行用户代码及在扩展运行时调用一些原生能力的不同技术挑战后,我们开始思考应该如何渲染用户界面。突然涌现在心头的一个问题是我们是使用如Java Swing 或 AppKit这类老一派的命令式编程方式还是使用一些更加声明式的编程方式,主要是在业界发出了对于 v = f(s) 这种写法时的"我靠,牛逼"的时刻之后。简单来说,视图就是用运行一个函数来表达一个应用的状态,也就是React的方式。并且如果我们要用声明式的形式,难道我们不应该在原生代码中使用SwiftUI么?

我们开始对于如何定义UI及如何使用我们基于AppKit的自定义UI组件来高效的渲染用户界面做了深入的调研。最终我们还是选择了不使用SwiftUI,因为我们对于减少app的崩溃率及性能问题没有太多的信心。所以我们还是选择了AppKit,一个UI生命周期方案及命令式UI代码的世界来作为我们的原生代码库。

在扩展里,我们使用JSON模型来定义组件,它表达了一个需要被渲染的组件树 (注:类似vdom) 。我们把这个JSON组件树翻译成原生的视图模型来渲染我们的组件。搞定。

但是在任意UI中你经常会需要管理UI状态。从使用的角度来说,这就有点开始变得不那么直观了,或者用一个更加现代化的说法来说就是:开发体验变糟糕了。所以我们为扩展专门引入了一个状态管理的库,看起来这是个解决办法。

我们从Alpha版本中学到了什么?

此时,我们已经能够在扩展和原生组件之间进行数据交换了,渲染一个基础组件,然后组件里有一些状态会更新,最终又会触发重新渲染UI.

人们常说“尽早发布测试,然后快速迭代”,我们也这么干了。我们创建了一个小型社区,社区里的用户可以使用测试我们的Alpha版本。他们开始开发一些基本的扩展插件 - 可能这就是所谓的双赢。但是有3件事很快变得明朗起来:

  1. 开发者对于他们不能使用他们喜欢的npm库不太满意
  2. UI与状态管理对于他们来说没有我们认为的好理解,基于函数式的响应式方案时不时的听到,而且“React”被提到的越来越多
  3. 有越来越多的API需要我们做polyfill

“Polyfill”是一个类似适配器模式的流行名词,主要就是使用一些代码使其能在其他不支持的环境下使用。使用polyfill通常在Web领域比较常见,主要是为了支持老的浏览器或不同的JS引擎,浏览器,生态及很多其他的情况。

所以呢ployfilling跟我们的API又有啥关系?

JavaScriptCore是一个纯粹的用来执行JS代码的引擎,但是它无法访问web API,或其他系统级别的功能。我们的宿主,Raycast,需要为网络请求、文件读写等重要功能提供灵活的API给扩展使其能够发起网络请求或读取文件。

如果我们想让开发者使用他们最爱的库,我们需要引入额外的polyfill来做到兼容性。所以如果我们想为开发者提供所有可能用到的API(“既然你已经暴露了HTTP请求API,现在我还需要sockets能力,及运行一个可执行文件的API”),这不仅仅可能是个永远无法完成的工作,而且这也意味着混乱的polyfill开发及更多的bug。

我们的第二个尝试(得考虑产品商业化了)

我们的第一个尝试已经带我们走的比较远了。我们甚至已经有了一个你很可能需要的能实现大多数文件IO的可工作的API了。我们邀请了更多的开发者来体验,试着了解他们使用API的体验,有没有其他需求以及如果让他们来做他们会怎么去实现等。我们提供了JavaScript/TypeScript API,但是开发者只能使用有限的库。这导致了一些认知上的误解,或者可能这就是错误的期待以及一些失望。我们无法让所有人满意,对吧?

但是假设事实就是如果有些人无法使用OctoKit库来方便的与Github API 交互,他们可能就不会来选择我们的平台,因为我们承诺了JavaScript,并利用JS生态里的库来开发扩展。并且如果开发者不使用我们的平台来开发扩展,我们为整个扩展而付出的努力将付诸东流。我们并不想通过扩展来获得收入,但是这个似乎是个产品及商业化上致命的隐患。所以这敦促我们去选择其他的解决方案。

我们需要在Node.js上运行React, 并使它能与Raycast交互

所以如果我们想要实现:

  1. 使用JavaScript/TypeScript,
  2. 让我们的下半生不要忙碌于提供及polyfill系统级的API,
  3. 更容易的利用整个JS生态,
  4. 满足开发者对于声明式UI及状态管理的期望,

鉴于此我们得出了在Node上运行React,并使其能与Raycast交互的方案。

没有银弹(注:完美且简单的解决方案)

正如你所知的软件开发,你不可能真的认为我们找到了银弹,对吧?😉首先,我们如何把node运行时带到用户端?其次,我们如何确保扩展没有运行恶意代码,因为现在他们可以完全访问Node的API了?首先回答第一个问题:我们决定不去把大量的C++代码嵌入到Raycast因为这样会使得app的大小过大。相反的,我们选择使用一个外部的运行时,但是通过Raycast来“管理”。

管理的意思是我们会自动下载对应的Node运行时,理想的情况是用户在他第一次打开一个扩展之前都不会注意到我们下载了Node运行时。并且当我们要在Raycast中启动它时,我们会做完整性检查来确保达斯·维德 (注:星战人物) 没有把二进制文件给替换掉😂。这使得我们获得了一个额外的可以通过Raycast管理的进程并且我们可以让它为我们加载一个扩展包(后面会有更多的说明)。使用这种模式的另一个好处是当Node进程崩溃时不会把Raycast也搞崩(大多数情况下不会)

一些把运行时沙盒化的选项

一个往运行时加入限制的方式是沙盒化,在我们这种情况下可以简单的描述为:我们的API给了开发者配置哪些是一个扩展被允许做的选项,当终端用户运行它们时通过一个系统来试着强行控制这些运行权限范围。

我们考虑过这种沙盒化但是拒绝了,对于我们的运行时我们主要有2种沙盒方案,但是都不太令人满意。

  1. 在进程维度做沙盒化

我们需要为每个扩展运行一个进程,但是这样的话扩展一多会变得很重。其次,苹果系统自带的沙盒工具sandbox-exec不支持第三方开发。所以我们需要找到另一种方案,比如自己写一个工具当做Node进程的安全代理。Node 19已经有一个实验性质的权限功能但是这个只在进程维度工作。甚至一些新的运行时比如Deno,也需要你在每个进程维度设置权限。

  1. 在JS引擎维度做沙盒化

还有的就是在一个额外的虚拟层上运行JS代码,但是这种方案众所周知的会降低性能且有局限性。有点搞笑的是,我们之前弃用的第一个使用JavaScriptCore的尝试反而能够直接给我们提供沙盒能力,但是这会引发一堆其他的问题,影响到我们整个产品的发展。

从考虑沙盒化到扩展开源配合代码审核

沙盒化不仅仅会使得开发更复杂;你还需要通知并向用户解释这些权限到底是干嘛的。从某些层面来说,用户一般会直接忽略这些索要权限的弹窗。

我们更希望的是一个恶意的扩展压根不应该被安装到用户的机器上,这样的话沙盒也就不需要做保护机制(即使做的话也有可能会因为上面提到的用户体验的问题而无法做到)。以VS Code打个比方,它维护了一份扩展的“黑名单”,一旦在黑名单里VS Code会自动为用户把这个扩展删除。

因此目前我们遵循的方法是 所有 的扩展需要开源并且通过代码审核才能上架。总的来说不是个完美的解决方案,但是也给到了我们一些额外的好处比如:

  • 确保UX用户体验处在比较高的水准
  • 分享各种解决方案
  • 对于一个扩展所做的事情具有完全透明性
  • 还有很多其他的好处

仅仅开源及社区审核对于那些对隐私政策要求更高的企业级应用来说是不够的,针对这些团队我们也调研并提供了对哪些扩展可以安装、它们应该如何更新以及当被标记为恶意程序后自动删除等额外的控制权

一个Raycast主进程和一个支持整个扩展包世界的进程

既然扩展运行在一个子进程上,它也会继承Raycast父进程的环境及沙盒。所以如果一个扩展需要访问系统安全设置里的一些权限,会弹出系统弹窗来确认是否允许。其次,为确保Bob的服务器管理扩展不会影响到Alice的账户相关扩展,我们使用一层额外的技术层面上的隔离。

从Node 12开始,Node支持了“worker threads”,本质上是v8做了隔离(v8是Node的JS引擎)。这给了我们一个额外的JS引擎及其事件循环以至于我们获得了一层额外的各个扩展运行时的隔离能力。我们还可以给扩展运行所需的堆设置内存限制(如果扩展运行时使用了过多的内存会被直接停掉),在需要的时候创建并销毁worker线程,与node主进程进行双向交互,最终再与Raycast主进程交互。

一个worker可能会挂掉或把内存耗尽,我们会捕获并显示这些扩展的错误,但是Raycast本身不会受多大影响。总的来说,workers线程对于我们来说还是运行的不错的(除了一些晦涩的异常问题及堆栈追踪处理)。尽管做了隔离,但还是会有一些能影响到Raycast的潜在风险,比如,如果扩展需要调用一个native的API,但是这个API本身有bug,然后崩了。

从这个Node workder线程里,我们应该如何与Raycast进行双向交互?

我们现在处在进程内通信(IPC)领域,对于这个问题有几种方式可以解决。我们选择了在标准文件描述符上使用流机制(在Swift中通过DispatchIO框架),这个使得我们可以进行双向交互。接下来的问题是在这个流上我们通信的内容是什么样子的?我们选用了JSON-RPC协议,因为:

  • 它具有直观与轻量级的特性
  • 通过开源软件库具有良好的支持(我们原生的实现部分基于苹果的语言服务实现)
  • 对于我们需要实现的目标来说性能足够快
  • 得益于它的纯文本属性,debug起来也会非常简单

扩展通过暴露的API向Raycast只发送注册过的消息(”渲染“、”写入剪贴板“等等),这也意味着扩展不可能向Raycast做任意的native调用。我们通过临时的会话ID来在Raycast进程中的原生实现及扩展workers之间进行映射匹配,当一个扩展加载时我们会生成一个ID,这样在native那边我们就知道应该引用哪个扩展。这也让我们能够同时运行多个扩展实例。💪

执行的顺序与速度也至关重要

好了我们现在已经可以在2个进程间交换结构化消息了,但是我们应该如何确保消息之间的顺序及避免遇到并发问题?在Node这边,worker是单线程的,所以出现消息错乱的可能性会比较小。

在Swift中,我们通过顺序队列以及缓存流来确保消息来去有序。而且在这里如果消息需要在后台处理的话我们还可以开一个额外的队列来处理,或者在主顺序队列上来更新UI。不幸的是这种办法还是没法完全避免2个进程间的处理顺序问题。但是我们用一些自定义逻辑很好的处理了这个问题。

鉴于需要访问Node API以及IPC模型的需要,这也是我们放弃使用React Native的原因。你可能觉得这是不是有点小题大做了:从一个扩展里到worker端口,到Node父进程,再通过RPC到Raycast进程。但是令人惊讶的是这套流程下来速度依然很快,即使我们还需要处理下面即将要说的很重要的部分👇

协调与创建渲染树

在扩展中,开发者使用React及我们通过API提供的自定义组件来定义UI。他们可以使用React Hooks来处理状态管理,当状态变化时又会触发重新渲染,最终到Raycast来更新native的UI。

之所以这个方案是可行的最终还要归功于我们自定义了React的一个模块,而这个模块叫reconciler(协调器),简单来说,这个协调器把所有待修更新的内容翻译成目标平台使用的渲染更新技术。在web领域,这个就是浏览器的DOM。在Raycast中,是AppKit。当你在浏览器使用React时,你会直接使用自带的协调器,也就是React-DOM。但是在我们的场景下我们需要实现自己的协调器,把一些东西从Node进程传到Raycast进程,Raycast进程需要理解如何更新,然后高效的更新原生UI。Nice。

我们花了不少时间及多次迭代才让我们自己的协调器能正常工作,主要那时这块的文档也比较”缺乏“。我们的办法是为每一个组件创建一个JSON数据结构来表示,然后在协调器里组装成我们所说的”渲染树“。好了,现在我们已经有了一个完整的可以描述在每次渲染之后的数据结构,我们就可以跟上一次渲染的结果进行比较,我们使用标准的JSON Patch来做这个事情。如果没有补丁,说明没有东西需要修改。所以native那边可以啥都不用做因为没啥可更新的👌

在生成渲染树之后,我们在想是否可以通过压缩来获取一些额外的性能提升。我们找到了一个临界值,在这个临界值上,gzip压缩所节省的时间大于压缩/解压缩这个步骤消耗的时间。一旦我们知道了需要渲染什么以及更新了什么,我们就会把这些数据传到native那边,构建轻量级的Swift视图模型并且把补丁翻译成二进制数据集类型。所以在任何时候我们都知道更新了什么以及一个组件的数据结构表示是怎样的。

使用这种视图模型层也为native的组件从渲染树的改变来更新native UI提供了一层保护。我们把相关数据一路传到自定义的AppKit组件来让它们做必要的UI更新操作。对于每一次渲染都会循环运行这个流程,当React意识到了一些状态更新并且需要更新UI,v = f(s) 这个表达式遍布在各个进程的很多地方。

技术但是比技术更重要的是:开发体验DX

目前为止所有上面提到的是扩展与Raycast之间双向交互的总览,以及使用的一些技术。但是更重要的是:理想情况下开发者可不想按下编译按钮,然后等着看到他们最新的修改,搜索费解的错误信息,修复bug,然后一整天都在干这个事情。等等,这怎么有点像macOS上的开发方式?😛

让我们进入”开发体验“(DX),一个正在苏醒的流行词汇。也可以理解为开发者需要工具辅助提升开发效率而不是一整天都在浪费时间在等待上,他们需要更多的专注于应用本身功能的开发。开发体验包括2方面,第一是”内部DX“ - 开发为其他开发者使用的工具的体验,然后是”外部DX“ - 外面的开发者能不能无痛的为你的平台开发好用的扩展。

命令行工具的回归

ray CLI是我们的命令行工具,对于2边的开发体验都至关重要。在内部,我们使用它来开发与构建它本身的API;在外部,开发者使用它来创建扩展,debugging,在Raycast中实时看到代码改动。

但是从技术层面来说它是如何工作的呢?我们命令行工具是一个Go语言编写的程序,兼容Darwin (macOS) 和 Linux(主要用来在github actions上面做持续集成的自动化任务)。我们使用一个cobra框架来处理命令行参数解析以及注册一些命令来做一些如构建扩展的任务。这个库很重要的一点是它集成了esbuild来把TS编译成JS代码。

对于我们内部的开发体验,我们可以在开发模式下启动CLI,监听源文件改动然后自动把他们编译打包到Raycast中(API是直接打包进我们的app的)。为了给我们团队不做API开发的其他成员做更进一步的简化,我们在Xcode上增加了额外的一个构建步骤。它会自动下载编译后的CLI,然后编译最新版本(如果有需要的话)的API,作为主app编译步骤的一部分。通过这种方式,团队中的每一个开发者都可以在debug模式下启动Raycast,并且用最新的API来运行扩展程序。

同时我们也发布了内部的API包,是从Github CI上自动构建并发布到私有的包管理器上。通过这个我们就可以把包link到扩展程序来做一些API(还未官方发布的一些API)的早期测试,

对于外部DX我们会提供些额外的功能

我们把主要的CLI命令包装到了"npm run"脚本中,这样开发者就可以在扩展源代码目录内运行npm run dev来进入开发模式。之后我们会监听文件改动,编译,热部署到Raycast,然后重新加载扩展程序,这样开发人员就可以实时的看到改动点了。

在Raycast里我们没有运行一个服务来监听文件改动,相反的我们依赖于app的自定义URL scheme来交互以及一个年代久远但很好用的pid文件。CLI通过URL与Raycast进行交互,Raycast则通过CLI创建的进程ID交互(比如把dev server停掉)。

Raycast还提供了一些UI工具,比如:Create Extension命令来基于模板生成一个开发新扩展的新项目,或者Manage Extensions命令来打开与卸载扩展,所有这些操作都不需要CLI介入。我们在CLI中通过捕获一个系统的日志流来把运行的一些日志传过去。我们会在v8 worker和Node进程的很多地方捕获异常错误,并转发到Raycast。在那边我们会提取信息,stack trace,然后以尽可能直观的方式展现在一个原生的浮层UI上面,还会包括一些额外操作可以直接跳转到编辑器对应错误的地方。

X依赖于Y,Y又依赖于Z - 版本依赖有时候会变得很复杂

我们都知道会存在这样的情形:依赖X与依赖Y不兼容,又由于未知的原因(╯°□°)╯︵ ┻━┻)Y又不能在Z平台上运行。在Raycast与API的情形下,我们有多个模块需要相互兼容,比如:Raycast、Node、API、扩展,CLI。我们的目标是大大简化这些东西,所以通常情况下开发者与终端用户无需关注这些问题。

既然我们运营着扩展程序的”app store“,遵循app store的通用做法是很合理的,也就是只会发布一个最新版本。开发者无需关注SemVer,这些终端用户也很少关心。最终的结果是开发者无需在他们的扩展包里指定一个版本(如果他们想的话还是可以的,比如需要在修改日志里记录一些信息)

我们也不想像其他插件生态一样需要强行指定插件所兼容的引擎版本(Node版本),或者更糟的是还要表明是否与Raycast本身的兼容性(使用其他引擎的情况下)。开发者唯一需要关心的就是API的版本,这是发布到npm的API TS 类型的依赖包。当开发者需要使用新的API功能时,他们需要升级到包含这个新功能的TS类型的版本。

剩下的则由Raycast处理,包括:我们会自动更新Raycast及扩展,并且只有当这个扩展使用的API与当前Raycast的版本兼容时才会安装。Raycast app的版本需要大于等于API的版本,不然的话扩展可能会使用一个运行时不存在的API(因为用户可能还没更新Raycast版本)

Raycast,从某种意义上来说,包含了API,React,管理Node运行时,以及当需要开发扩展时自动安装CLI。一个重要的决策是同步我们所有的版本号,也就是Raycast,API,CLI版本号都是一样的,而且都是一起发布的。这大大简化了处理版本兼容性的问题。

API的演进意味着你可以新增功能但不能删功能

我们希望尽可能的保证向后兼容。我们的API演进及发布流程大概是这样的:

  1. 根据用户反馈,我们对于比较重要的需要加到API里的需求会有大致的了解
  2. 我们内部创建了一个API的演进提议,用来描述我们新的功能,做出原型API然后与团队讨论
  3. 一旦提议被接受:我们开始实现API,审核,内部先使用测试,在下一次的app更新中发布新的API,然后继续收集更多的反馈以及真实世界用户的使用情况

创建提议对于我们保证API的一致性以及获得一个好的解决方案是非常重要的。一些偶然的需要废弃老的API的需求是不会被接受的。但是如果确实有必要(很少发生),我们通常会用code mods创建一个自动迁移的脚本automatic migration,这样的话对于更新TS类型或者方法参数来说会简单一点。我们会在每次发布时更新文档。我们使用CI流来公开的发布到我们的公开的扩展仓库并且与社区里的贡献者合作,然后在发布前同步到我们内部的仓库里。

不断发布,自动化,茁壮成长

开发者如果想要他们的扩展发布到商店并与其他人分享,我们在Github上创建了一个开源的monorepo仓库,并使用PR来review代码。当有人发起了一个PR,我们会运行一些自动化检查,包括基础信息,代码检查,检查商店用到的静态资源(如扩展的一些截图)等等。在这个过程中我们也会审核并测试扩展,并试着给出一些有用的反馈。

一旦发布到商店后,我们会自动在Slack频道中发布通知,并且开发者也会在Slack社区中宣布他们的创造。总的来说,这套流程运行的还不错,开发者已经在上面开发了成百上千的扩展。你可能会想一个monorepo加上手动review代码,扩展性好么,是不是很繁琐?但是人们常说”先把东西做出来,别管好不好扩展“,对吧?最近我们发布了新的Raycast和CLI命令,它们使得fork扩展仓库然后开发代码做贡献简单了许多,并且不再强制你clone整个monorepo - CLI会在后台在github上做相应处理。

功能平等性与新的领域

自从我们发布了Raycast后,里面默认包含了一些自带的扩展,在这之后开始创建扩展生态,这之间产生了一些有趣的副作用。开发者在想他们怎么才能使用这些自带扩展能用的一些功能或UI组件呢。

这督促我们完善API来包含这些能力,而且也把之前用Swift编写的扩展和直接写在Raycast代码库的扩展都开源出去(比如:GitHubLinearGoogle Workspace, or Zoom)。这些扩展已经变成了基石并且督促我们应该做哪些以及需要暴露哪些API。

当扩展变得越来越复杂时,API也需要演进

API需要在多个方向进行演进,比如提供UI组件;操作系统层面的API,不然有些能力纯在Node端实现的话不太容易;把复杂的事情简单化。对于后者,我们创建并开源了一个工具库,与我们的API配合使用。这使得一些典型的任务如异步操作,网络请求,以及缓存简单了很多,而且还在React层面宣传这些最佳实践。

另一个我们从去年就开始探索的方向是提供一个全新的我们之前从未提供过的扩展类型:菜单栏 命令。你会得到与之前相同的react及热加载的开发体验。而且最终的产品是一个用户可以直接安装、激活、以及他们可以使用自己的工作流的原生macOS菜单栏app。

我们最近的Raycast AI新功能的发布也为开发者打开了新的可能性,我们通过在API中开放AI能力来让开发者可以开发自定义的AI扩展。最后,我们最近也在调研评估一些为开发者带来更多扩展内部的一些数据、异常、分析等能力的展示,一个叫“developer hub”的新功能。

恭喜你来到了最后 👏

感谢与我们同在!如果你已经是一个扩展插件开发者,拜托了请继续创造很酷的扩展(如果可以的话你可以把公司内部的一些自动化脚本以扩展的形式提供对应的UI)。如果你还不是,为什么不现在开始构建你的第一个扩展