首发自 https://xiaozhuanlan.com/topic/2746058139,重新创作自 Session 10665, Meet Safari Web Extensions

今年(2020)苹果宣布引入一种新的 Safari 扩展类型,这种类型使用 Web 技术来为 macOS 上的 Safari 增强功能。在进入正题之前,让我们先回顾下目前 Safari 业已存在的扩展生态系统。目前包含以下类型的扩展;

  • 内容拦截扩展(支持 iOS、macOS)
  • 分享扩展(支持 iOS、macOS)
  • Safari App 扩展(只支持macOS)

现在的 Safari 插件开发对于熟悉 Objective-c 或者 Swift 的开发者来说非常容易入门上手,但事实上,熟悉 JavaScript、HTML 和 CSS 的 web 开发者要比熟悉 Objective-C 或者 Swift 的开发者多的多;而且除了 Safari 插件外,其他主流的浏览器的插件技术都是基于 HTML 等 web 技术来构建(事实上,Safari 扩展在历史上也是可以用 Web 技术来实现的)。

近年来,Apple 在思考如何把更多的 iOS App 的生态引入到 macOS 的生态,所以他们引入了 Mac Catalyst 技术作为桥接;同样的,如果需要把其他浏览器的插件生态导入到 Safari 的生态,不得不重新启用 web 技术来支持这个目标;所以 Safari Web 插件采用了业界浏览器插件开发的事实标准。使用和其他浏览器插件一样的接口,有助于消除开发者的学习成本。同时,苹果的插件技术选型,让从其他浏览器的插件转化为 Safari Web 插件成为顺理成章的事情 ——把 Safari App Extension 升级为Safari Web Extension不是基于技术优劣的选择而是基于市场的考虑。 Safari Web Extension 开发插件技术栈类似其他浏览器上的插件开发,同时也符合苹果一直在强调的隐私控制规范。

下面将介绍的内容如下;

  1. 如何创建 Safari Web 扩展
  2. 重视隐私和权限控制
  3. 如何调试插件
  4. 和 App 通讯的方式

本文不涉及如何移植其他浏览器插件

在开始编写第一个 Safari Web Extension 之前,我们需要了解下,Safari Web Extension 是如何打包、安装到 Safari 里的。Safari Web Extension 必须包含在 Native App 内。当用户从 App Store 下载到电脑后,插件会被自动安装到 Safari 里。

如何创建 Safari Web 扩展

在编写插件之前,你需要 Xcode 12,但Safari Web Extension 是运行在 Safari 14 上的,所以你还需要升级你的 macOS 到 11.0 macOS Big Sur(macOS 10.15 据说也可以,没有尝试)。 创建 Safari Web Extension 有两种途径,一种是为原有的 App 创建一个扩展(Safari Extension);或创建 Safari Extension App ,同时创建一个宿主 App 和 扩展。这里我们采用第二种方式。

依次选择菜单 File -> NewAnd Target -> Safari Extension App, 创建  Safari Extension App Xcode 会自动创建好模板的目录结构, 。 从目录结构上看,一种分为 3 部分。 图例2

  • 宿主 App,当宿主 App 启动时可以执行一些 macOS 特有能力的操作,如在 App 界面内做检索、分享等操作。主要文件,ViewController.swift
  • Native Extension 部分。它有部分 macOS 平台的接口的执行权限,和 宿主 App 拥有各自独立的沙盒。最大的区别是它不能有自己的界面。主要文件,SafariWebExtensionHandler.swift
  • Extension Web 部分。它是和网页打交道的主战场,包括展示自定义界面、修改当前活动网页内容,并保存部分信息到另外两部分等操作。主要文件,_locales\icons\manifest.json\background.js\content.js\popup 等。

其中最最重要的是 Extension Web 部分,我们打开 MDN Web Extensions 查看插件开发的事实规范,来仔细研究下:

分为两部分

一,manifest 文件关键字段释义

manifest.json 是每个扩展都必须包含的文件,用来指定扩展的元数据,如名字、版本,一些扩展的功能点。这个文件的格式是 jsonc,即可以使用注释

1
2
3
{
"extension_version": 1 //  这里是 json 的注释
}

单纯的 JSON 格式不支持注释语法。重要字段整理如下:

关键字段 定义和常用值
default_locale 指定插件文案支持的语言,用来处理国际化
icons 指定在 macOS 和 Safari 各个地方需要显示的图标,建议包括 1024 * 1024 尺寸的。目前使用 Xcode 的模板生成的 manifest 文件是不包含的,需要手动加上去。
background background 里指定在 Safari 运行期间,独立于页面生命周期(甚至是独立于浏览器 windows)的 JS代码,这些代码可以使用所有 Web Extension APIs 的接口;可以访问相同的 window 对象。 除了最常见的 scripts 字段外,还支持 page 字段,他们两是互斥的。
content_scripts content_scripts 定义了页面如何注入 JS 的规则,常见的规则如代码示例1,指定了哪些页面注入哪些 JS 文件。这些 JS 会被注入到目标 Web 页面,去操作目标页面;同时支持注入 JS 和 CSS;可以指定 JS 注入的时机,由 run_at 字段来指定;同时支持使用 contentScripts 接口动态注册
permissions permissions 是向 Safari 浏览器申请特定的访问权限; 浏览器会帮助我们在用户界面提示用户授权。包括三类权限:1. 适用的网页;2. 可调用 API 范围;3. activeTab 权限
browser_action browser_action 在浏览器上的 toolbar 区域添加一个按钮,点击后会打开一个网页弹窗或者被 background scripts 响应;它的主要目的是处理和具体页面无关的功能逻辑;
page_action page_action 在浏览器上的 URLBar 区域添加一个按钮(注意和 toolbar 的区别),点击后会打开一个网页弹窗或者被 background scripts 响应;主要目的是触发特定页面的逻辑;

代码示例1

1
2
3
4
5
6
"content_scripts": [
  {
    "matches": ["*://*.mozilla.org/*"],
    "js": ["borderify.js"]
  }
]

2. 重要的 API 接口释义

WebExtensions 的接口,在background scripts,browser scripts,page scripts,sidebars 等页面里都可以调用;其中一小部分可以在content scripts里使用。

越是强大的 APIs,在使用之前越需要获得用户的授权,这些授权通常在安装插件时,会提示用户。申请权限保持最小范围原则,越少的权限更容易获得用户的授权。

可用 APIs 对象有;activeTab, alarms, background, bookmarks, browserSettings, browsingData, captivePortal, clipboardRead, clipboardWrite, contentSettings, contextMenus, contextualIdentities, cookies, debugger, dns, downloads, downloads.open, find, geolocation, history, identity, idle, management, menus, menus.overrideContext, nativeMessaging, notifications, pageCapture, pkcs11, privacy, proxy, search, sessions, storage, tabHide, tabs, theme, topSites, unlimitedStorage, webNavigation, webRequest, webRequestBlocking,

在 JS 里调用时,需要调用 browser 命名空间,如

1
2
3
4
5
function logTabs(tabs) {
  console.log(tabs)
}
// 这里 api 调用时,传入回调的写法;可用和下面使用 promise 的相比较。
browser.tabs.query({currentWindow: true}, logTabs)

大部分的 APIs 都提供使用 callback 获取返回值和返回 promise 的两种方法。把上面的代码改为使用 promise 的实现;

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
function logTabs(tabs) {
  for (let tab of tabs) {
    // tab.url requires the `tabs` permission
    console.log(tab.url);
  }
}

function onError(error) {
  console.log(`Error: ${error}`);
}

let querying = browser.tabs.query({currentWindow: true});
querying.then(logTabs, onError);

注意,chrome 的接口挂载在 chrome 对象下,而且它使用 callback 来作为异步接口的返回值。在 MDN 的文档里,推荐使用 promise 来实现异步接口的返回值;Microsoft Edge 也不支持 promise 的返回值写法。但如果你要移植到 chrome、IE,需要考虑兼容性问题。

最全的 APIs 接口,请查阅,JavaScript API listing。 这里需要重点介绍一个接口 browser.runtime 系列的接口, 图例2 里所示的三方通讯都需要这个接口来实现。

>Extension 各部分如何相互通讯

在实际的插件业务中用到最多的场景是如何在 content scriptsbackground scripts 之前的通讯。根据 MDN 上的接口,我总结了以下的表格,演示其中不同通讯方式的用法。 总结一下,两部分通讯分为两种方式:

  1. 单向,也可以理解为通知、广播、多对多。browser.runtime.sendMessage();
  2. 双向,也可理解为回调方式、长链接,点对点。runtime.port.postMessage()

如何选择哪种方式,在 MDN 上有给出最佳实践

一次性消息传递和基于连接的消息传递之间的选择取决于您的插件期望如何传递这些消息。推荐的考虑是:

选择一次性消息传递。如果…
  • 发一次消息只需要一个回复
  • 只有个别 script 在监听一次性消息
选择基于连接的消息传递,如果…
  • 在一个连接中需要多次交换消息
  • 如果扩展需要知道目前任务的进度、是否被中断,或者想主动中断任务

另外一部分是 JS 如何和 Native 世界交换数据,我们使用 Session 里的图说明。 图4 图中没有提到如何从 background 里向 Native 传输数据,因为不能,需要借助 Extension ,Extension 也可以直接使用 dispatchMessage 发消息。

隐私和安全

苹果一贯重视隐私和安全,在插件的 manifest.json 文件声明的权限是插件默认启用的权限,在安装插件时会提示用户授权;如果是一些某些情况下才需要权限,可以在运行时申请,这时候用户在触发操作后会出现授权提示。

插件开发的调试体验

按照前面描述的插件组成部分,你写的代码包括; popup.html 页面, background.js, content.js如果进入调试页面呢?

类型 方法
popup.html 点击工具栏的插件图标,弹出悬浮窗里点击右键
background.js 在 Safari 菜单,Develop -> Web Extension Background Pages 里打开,
content.js 在脚本生效的页面里点击右键,唤出审查元素菜单,

当打开调试面板后一切都熟悉起来。这里建议开发插件时,打开两个 Safari Window,这样可以同时调试多个部分的脚本问题。

常见的问题和排除

经过编写 LightNote 的 demo 插件,发现目前苹果对 Safari Web Extension 开发工作流做的远远不够。这几天中,我遇到的常见问题:

  • JS 里的中文乱码问题。如果是在 JS 文件里硬编码了中文,在别的地方输出时,就会显示乱码。
  • 调试 Native Extension 和 Native App 体验一如既往的差,Native Extension 经常挂载不上 debugger
  • 修改了 JS,不支持 HotReload 调试,每次修改 ContentScript 都需要重新 run (我后面会重点研究的地方
  • sendNativeMessage()时,传入 const AppId = "application.id"; 这样的 AppId 也可以?可见内部实现的时候有很多 magic string 。
  • Safari Web Extension 里 manifest.json 很多配置都不能如预期工作,在 API 兼容表格里声称支持 Content_Script 里基本参数,但实际上不是。支持导入一个 JS 文件,多个不行;不支持导入 CSS 文件(大坑)(是支持多个 JS 和 CSS,2020-12-26 更新)。
  • 不支持 "http://*/*"的语法
1
2
3
4
             "matches": [
-                ["http://*/*", "https://*/*"]
+                "<all_urls>"
             ],
  1. 插件的 3 个组成部分,Native Extension, ContentScript, BackgroundScript 。他们的数据流是这样的:ContentScript 触发、BackgroundScript 中转、Native Extension 处理后返回;BackgroundScript 中转、ContentScript 接收
  2. BackgroundScript 启动是伴随浏览器启动,ContentScript 生命周期是和 WebPage 一起的,只有 Native Extension 被请求事件唤醒了,才会启动
  3. Swift 里的 原生对象,如 Dictionary 无缝可以转化为 JS 对象,比较方便
  4. 记得调用 API 之前申请权限
  5. ContentScript 里不能使用 document.onload 之类接口,因为默认已经在 onload 事件之后加载插件 JS 的
  6. 如何适配深色模式。
1
2
3
:root {
    color-scheme: light dark;
}

至此,Session 里包含的内容讲完了。相比其他主流浏览器,Safari 的插件生态要差的多。

Safari 插件的生态

在 MDN 上我们看到,Firefox 除了提供开发文档外,它还提供了如发布、管理、社区等功能,这有一个地方可以让插件在用户中流动起来,而相比 Safari 的插件。首先要通过 Mac App 才能安装;其次它的插件搜索功能实在是太鸡肋,封闭,猜测苹果的想法是如何让插件也能成为苹果新的商业模式,为他们贡献现金流。很多插件开发者开发插件很随意,很 hack,哪些条条框框的约束是对插件开发自由度的一种干涉,双方不够协调。导致 Safari 的可用插件少的可怜。在 Safari 插件生态上的建设,多年来停滞不前甚至有倒退。只能期望来年它有些新动作。

本文里很多 API,会在我提供的 LightNote for Safari 的 demo 里有所展示,尤其是涵盖了几乎所有的通讯方式。这个插件的功能是用户在打开的页面上看到不错的文字。用户可以高亮标注出来,并且添加评论,可以在一个地方整理。本 demo 只提供雏形,但会在此基础上迭代,目标是替换 Liner 这个插件,调试插件时注意打开控制台。

参考

  1. demo,一个简单的浏览器内高亮工具雏形。
  2. Safari Web Extensions,苹果 Safari Web Extensions 文档入口,你遇到的很多问题都可以在这里找到最佳解决方案。
  3. Browser support for JavaScript APIs.