分类
extension

Chrome Extension MV3 在 background script 里扫描二维码并传出结果

升级浏览器扩展到 MV3 时,因为 MV3 不再允许使用 background 页面,必须使用 service worker 类型的 background script。这样一来,就没有 window 对象,会遇到很多问题。

今天分享下如何在 service worker 类型的 background script 里完成扫描二维码并传出结果。比如我帮朋友升级 二维码生成器 (Quick QR) 这个扩展,它有个功能是通过右键菜单扫描页面中的二维码,并返回扫描结果。这个过程只能在 background script 里完成,于是升级时就给我带来了不小的麻烦。

首先,右键菜单的响应事件只能得到图片 URL。其次,扫描功能需要用到 jsqr,它接受 imageData 作为参数,可以返回扫描结果。所以我们就需要把 URL 转换成 imageData,过程如下:

// service worker 里可以使用 `fetch()` 请求服务器
const response = await fetch(src);
const blob = await response.blob();
// 使用 `createImageBitmap` 可以将支持的图片格式转换成位图
const bitmap = await createImageBitmap(blob);
const {width, height} = bitmap;
// 在 service worker 里,需要使用 OffscreenCanvas
const canvas = new OffscreenCanvas(width, height);
// 注意,这里只能是 `2d`,`bitmaprenderer` 无法使用 `getImageData` 方法
const context = canvas.getContext('2d');
context.drawImage(bitmap, 0, 0);
const imageData = context.getImageData(0, 0, width, height);
// 接下来识别就好
const data = jsQR(imageData.data, width, height);
const [tab] = await getActiveTab();
// 因为没有 `window`,`alert()`、`prompt()` 都不能用,只能通过在目标页面执行脚本的方式输出结果。注意,`tab.executeScript` 也没有了,必须用下面这个新 API,并且要提前申请权限
await chrome.scripting.executeScript({
  args: [i18n(SCAN_RESULT), data.data],
  target: {
    tabId: tab.id,
  },
  func: (message, result) => {
    result = prompt(message, result);
    if (result) {
      navigator.clipboard.writeText(result)
        .catch(() => void 0);
    }
  },
});

大概过程如上面代码所示,需要注意的东西我都放在注释里了。

关于 MV3 的其它升级经验,我会更新在 Chrome Extension Manifest V3 升级笔记,欢迎常来看看。

分类
extension

使用 Chrome extension 操作 CodeMirror

我们知道,浏览器扩展(Chrome extension)可以通过 content script 在目标网页里执行代码。它和目标页面的 JS 处于不同的沙箱,但是共用一套 DOM。所以,content script 无法访问目标页面的 JS 变量,但是可以修改目标页面的 DOM、侦听 DOM 上的事件、或者触发 DOM 事件。

在这种情况下,要操作 CodeMirror 的时候会遇到一些问题。为了性能考虑,CodeMirror 不会把所有代码都渲染出来,而是会按需渲染。比如代码总共 1k 行,但是当前窗口里只能看到 40 行,那么 CodeMirror 就会渲染当前的 40 行,和前后大概各 20 行(方便快速滚屏),总计约 80 行。

换言之,如果无法接触到目标页面的 JS 环境,单纯从 DOM 里无法获取到全部代码,也很难修改 CM 里的代码。于是我们就需要一些特别的技巧——这些技巧在操作其它类似的类库时也会用到,所以写篇博客分享下。

解决方案:向目标网页注入 <script>

此时,我们可以通过创建 <script> 的方式向目标网页注入一段 JS。这个 JS 的执行环境也是目标网页,所以可以访问到目标网页 环境下的 JS 变量。

CodeMirror 会把实例添加到容器节点上,所以我们可以先找到容器节点,即 div.CodeMirror;然后访问它的 CodeMirror 属性找到 CM 实例;最后进行操作。

为了方便 content script 和 CodeMirror 交互,我们必须搭建一个桥梁,也就是下面这段代码:

const cms = document.getElementsByClassName('CodeMirror')
for (const dom of cms) {
  // 这个 textarea 被 CodeMirror 视图所取代,是隐藏的。我们利用它传递代码
  const textarea = dom.previousElementSibling;
  const cm = dom.CodeMirror;
  // 给 CodeMirror 添加清空功能,可以通过在 dom 上广播事件触发
  dom.addEventListener('clear', () => {
    cm.setValue('');
  });
  // `changes` 是 CodeMirror 的事件,在代码改变时触发,这里我们通过侦听它,并且将代码同步到 `textarea` 的方式,方便 content script 访问
  cm.on('changes', cm => {
    if (texture.value === cm.getValue()) {
      return;
    }
    textarea.value = cm.getValue();
  });

  // 为初始化
  textarea.value = cm.getValue();
}

因为我执行 content script 的时机总在页面完成初始化之后,此时所有 CodeMirror 都已就位。如果你要在别的时间执行,可以做一些调整。

分类
chrome

Chrome Extension Manifest V3 升级笔记

关于 MV3 的知识,可以看这篇前文:

《Chrome 扩展大升级 Manifest V3:变化》

近期尝试把一个浏览器扩展升级到 MV3,然后发现了很多问题,写篇博客记录一下。

与文档不符

  • 很多号称返回 Promise 实例的方法实际上不会返回结果,或者在没有 callback 的时候会报错

文档未说明

  • service worker 里无法使用 chrome.i18n.getMessage 或者 chrome.i18n.getUILanguage(【v97 已修复】)。于是目前右键菜单都无法 i18n 了。
  • chrome.contextMenus.create 无法向 browser_action 添加菜单项

未实现 Promise 或者有 bug

  • chrome.storage.local.get(keys) Promise 结果固定返回 undefined,不可用,需用回 callback (【v97 已修复】)
  • 【未测试】chrome.permissions.request 未 Promise 化,无返回值
  • 【未测试】必须传入 callback,不然报错
    • chrome.permissions.contains
    • chrome.contextMenus.remove

文档写了,但我没看见(>_<!)

  • 在 service worker 里使用 chrome.contextMenus.create 时,必须传入 id,且不能使用 onclick 函数。只能侦听 chrome.contextMenus.onClicked.addListener,然后做进一步判断。

MV2 存在的功能,但无法迁移至 MV3

(有些可能是暂时的)

因:不允许使用 remote JS

MV3 不支持使用外部 JS,所以 Google Analytics 和 Facebook 分享按钮之类的 SDK 都无法使用。也即是说,现在扩展不能使用 GA,也不能嵌入 Facebook Like,也不能使用其它需要引用外部 SDK 才能实现的功能。

因:Service worker 取代 background page/JS

无法获取系统 light/dark mode

Service worker 没有 window 对象,也就没有那些 window 对象上才有的方法,比如 matchMedia()。所以我们就无法在 service worker 里检查系统的 dark mode,实现动态修改 icon 的功能。

无法将 SVG 解析成 ImageData

因为 createImageBitmap 无法解析 SVG blob,所以原则上,所有 SVG 的操作均无法执行。

外穿信息比较麻烦

无法使用 alert()prompt(),也无法手动弹出 popup。所以如果我们想从 service worker 里向外传出内容,只有三个选择:

  1. 使用通知 notification API
  2. 直接打开页面
  3. 使用 chrome.scripting.executeScript() 在目标页执行脚本

(1)(3)需要不同的需要权限,(2)的体验不好。怎么用就自己选择吧。

分类
扩展

Chrome 扩展大升级 Manifest V3:变化

上一篇博客 聊了聊 Manifest V3 的设计思路,接下来就该详细介绍下这个版本的变化。按照 官方介绍,V3 的变化主要有以下五点:

  1. 需使用 Service worker 替换 background scripts/pages。这个变化对我影响很大,未来开发新扩展的时候,需要使用全新的代码架构;老扩展要升级,多半也要彻底重构。
  2. 修改网络请求的 API 换作 chrome.declarativeNetRequest。这个变化我暂时没用到,将来有机会再说。
  3. 禁止运行远程代码,所有将会运行的代码都必须事先打包进扩展,接受商店的审查。这点对我厂的影响非常大,很可能我厂的 Showman 要彻底改变实现方式——最大的可能就是停留在 Manifest V2,如果 Manifest V2 被废弃,那就使用新技术栈开发下一代产品。
  4. API 全部提供 Promise。这是应该的,小改善。
  5. 其它一大批杂七杂八的功能变化。

其中 2 暂时不好评价,3 只能被动接受,4、5没啥好说的,所以接下来谈谈 1,background script => service worker。

Background Scripts/Pages

Background scripts/pages(后面简称 background script)的地位很重要,是浏览器扩展的重要组成部分,可以作为联系其它组件的中心、主控,在扩展功能复杂时,作用很大。比如我厂的 Showman,既需要用 content script 注入功能到目标网页,也需要单独打开页面让用户交互,这里每个 JS 都要跑在独立环境,彼此隔离,所以就需要有一个中控,一方面连接各个独立的组件,另一方面常驻内存存储一些全局数据。

相信大家不难想到,这种常驻内存的东西,虽然给开发带来了便利,但是也会给内存带来不小的压力。况且,在 Manifest V2 阶段,甚至还支持一个浏览器扩展注册多个 background scripts 和 background pages。每个页面和脚本都要独享一个环境,哪怕只有短短几行代码,于是就会吃掉大量的内存。

Chrome 对内存使用方面的不检点,被大家诟病已久。其开发团队当然也知道,所以最近几个版本 Chrome 都在试图改进这些问题。既然新版本要降低系统要求提高性能表现,尽量节省内存、同时减少运行时间就是必须考虑的事情。所以,新版本扩展规范规定,background script 有且只有一个,而且只能是 service worker。

Service worker 只能注册事件侦听器,不能持续运行。这样一来,就可以让 background script 的执行之间降到最低,并且随需而动,减少内存占用。

Service worker 的特殊用法

1. 事件侦听器都要放在顶层

事件侦听器都要放在顶层,非顶层的事件侦听器会被直接忽略。

2. 使用 storage API 存储持久化状态

不要用全局变量,把一些需要用到的数据都放到 storage 里,如下:

// 不要这样做
const foo = 'bar'
chrome.runtime.onMessage.addListener(({ type, name }) => {
  if (type === "set-name") {
    name = foo;
  }
});

// 这样做
chrome.runtime.onMessage.addListener(({ type, name }) => {
  const foo = await chrome.storage.local.get(['name']);
  if (type === "set-name") {
    name = foo.name || 'bar';
  }
});

3. 使用 chrome.alarms 取代定时器

我们以前一般习惯于用 setTimeoutsetInterval 定时执行,但它们在 service worker 里都会失效。此时,要用专门的 Alarms API 代替,使用方法倒也不难:

chrome.alarms.create({ delayInMinutes: 3 });
chrome.alarms.onAlarm.addListener(() => {
  // do something
});

需要注意的是,content script 里虽然仍支持 setTimeout,但是太长的定时器会被直接忽略掉,时间阈值是 13s,即短于这个时间的仍然会触发,但是超过的会被忽略。建议如果 content script 需要定时器,那么也交给 background script 来做。

4. 其它一些常见场景的处理

包括解析 HTML/XML、处理音视频播放、使用 <canvas> 绘图等,因为我暂时都没用到,所以就先不说了。

分类
扩展

Chrome 扩展大升级 Manifest V3:设计原则

Chrome 前阵子发布了 Chrome Extension Manifest V3 规范,算是为浏览器扩展开发的下一步明确了方向。虽然目前只在 Chrome 浏览器里有效,不过鉴于 Chrome 的统治级地位,相信只要不出大问题,将来肯定会成为 Browser Extension 规范——实际上,Browser Extension API 就约等于 Chrome Extension Manifest V2。

我最近一两年的工作大多围绕浏览器扩展展开,所以对这个规范很关注,近期也研读了不少,准备写几篇博客分享一下。这是第一篇,主要分享下 MV3 规范的设计原则。


1. 保护隐私

最初的浏览器扩展给了开发者非常大的自由,可以让浏览器扩展大大加强浏览器的能力,但是也会给普通用户带来大量数据外泄的风险。

新规范支持浏览器扩展在不需要特殊权限的时候先正常运行,有用到某个权限的时候,再向用户请求使用许可。另外,权限的授予也不再是永久的,避免长期授权可能导致的问题。

2. 保障安全

扩展要上商店,必须经过审核,很多功能可能没那么容易过审。于是很多应用开发者都会想方设法使用外部资源来绕开平台对应用的监管,这样做能带来更强大的功能,也会带来更大的隐患。比如我厂,Showman 必须用外部 JS 才能驱动网页;但如果是恶意开发者,他们可能会利用这点干坏事。

对于外部资源的使用,新规范采用了更严格的限制。V3 里,只能使用静态外部资源,比如图片视频等,外部脚本彻底不能跑了。这对我厂 Showman 是不利消息。

3. 提高性能

Chrome 的性能一直被人诟病,内存杀手的恶名背负已久。可能很多人不知道,其实 Chrome 扩展架构一直是个大问题。出于安全考虑,扩展里的 JS 会运行在很多不同环境下,比如 content script,background script,都会跑在独立的沙箱里,也就是说,哪怕你一个页面都不开,可能也有好几个扩展 background page/script 在悄悄运行,占用你的内存。

所以新版本也作出改进,强制限制只能使用一个 background script,而且这个 background script 必须以 service worker 形式来跑。保证浏览器扩展不会吞噬掉所有系统资源。

4. 追随统一的 Web 平台

以前为了提供更强大的功能,给扩展增添了很多专享 API,比如文件读写。如今 Web 平台不断发展,一些经过验证的 API 被吸纳到 Web 标准,一些步子太大的 API 被证明其实没啥用。总之,为了让降低开发者的心智负担,也让更多的代码可以一次开发导出运行,新规范会尽量向 Web 平台靠拢,减少专享 API,鼓励使用 Web API。

5. 改善功能

当然,说到底,大家之所以用浏览器扩展,还是因为它的功能比 Web 强。所以新版本还会继续探索新的可能性,让扩展平台更强大、功能更丰富,以便给用户带来更大的价值。

总结

MV2 已经过去了大约十年,确实有一些不合时宜之处,各个平台也逐步摸索了一些更好的策略,比如,权限和安全性的改进很明显是从 iOS、Android 学习而来。至于性能表现,我也很期待。

以上内容主要来自于我对新老 API 的理解和这篇官方介绍:Extensions platform vision – Chrome Developers。推荐阅读。

分类
分享

应用(直播)创意:弹幕收集器(BB酱)

BB酱 v0.1

2021-03-27 更新

经过近两周的开发,我已经用上了 BB酱 v0.1。目前支持以下功能:

  • 支持读取并上传弹幕
  • 按用户名搜索、筛选
  • 取特定时间段弹幕进行抽奖

2021-08-29 想起来就更新一次

  • 支持修改用户信息
  • 支持用户修改自己的密码
  • (正在开发)插件里登录

在 B 站直播了一段时间,发现 B 站没有弹幕记录——其实斗鱼也没有。这里面的产品逻辑应该是:弹幕比评论更短、更情绪化、与视频内容关系更紧密,量也太大,独立的弹幕存在价值稀薄,单独做一个列表的性价比很低。

不过我觉得,对于主播来说,弹幕还是有一些价值的,尤其是我这种没什么人看的小主播,很多时候翻翻弹幕能找回很多继续播的动力。

所以我想做一个浏览器扩展,自动收集直播里的弹幕,然后保存起来。这样需要的时候我就能回看,或者搜索,野炊就经常用这个功能跟弹幕互怼。

另外,B 站的直播互动功能不强,连基础的弹幕投票和弹幕抽奖都不支持,也可以利用这个插件实现。

这个想法吸引我的还有几个点,我觉得很适合用来做直播:

  1. 足够简单,一次直播半个小时就能完成基本功能
  2. 涉及到的技术点也不少,比如 Chrome extension API、Vue 项目搭建、Mutation Observer、Serverless 等,展开讲能讲不少,不展开直接口播介绍一下也可以
  3. 做出来之后可以给其他主播用
分类
chrome

Chrome 扩展里实现 SSO

周五打算给客户发版,结果在这里卡了大半天,写篇博客记录下。

0. SSO 的实现

SSO,Single sign-on,单点登录,即统一处理用户登录、提供用户身份凭据的功能。使用 SSO,可以只维护一套用户体系,容易开发维护;对用户来说,只需要登录一次就能使用该开发商的全部产品,也很轻松方便。

一般来说,SSO 的流程是:

  1. 用户使用 A产品,域名是 pa.mydomain.com,登录服务(S)位于 login.mydomain.com
  2. 用户使用提供服务的 A产品,A产品需要登录,用户选择登录
  3. 来到登录服务,完成登录
  4. S服务将用户指回 A产品,返回的 URL 里包含一个 token
  5. A产品拿到 token,请求 S服务,验证 token,获取部分用户信息(比如邮箱,一般只用来展示
  6. A产品生成自己所需的身份凭据,并以此验证用户身份

我厂的产品也是这么实现的。

1. Chrome 扩展遇到的问题

本地调试一切正常,但是加载成扩展之后,从登录服务跳回扩展会遇到 ERR_BLOCKED_BY_CLIENT 错误,URL 也被重定向到 chrome://invalid/。我在这里卡了很久,主要是不知道该怎么定位问题和搜索答案。

后来经过反复尝试,我终于发现,只有从登录页面跳转回去插件页面的时候,即 location.href='chrome-extension://{id}/ui/index.html 的时候,才会报错,所以立刻换用 chrome extension href ERR_BLOCKED_BY_CLIENT 作为关键词,立刻找到了答案:redirect to chrome-extension:// results in ERR_BLOCKED_BY_CLIENT

然后阅读文档:Manifest – Web Accessible Resources(可由 Web 访问的资源),得知需要在 manifest.json 里添加对应的配置:

{
  ...  "web_accessible_resources": [
    "ui/index.html"
  ],
  ...
}

添加后 SSO 就正常了。

2. 后记

不过我没想明白的是,这个配置意义何在?配置写在扩展里,防止 web 访问扩展里的文件,似乎并没有什么帮助,也没什么安全性的顾虑。也许是我还没遇到吧。

分类
chrome

让 Chrome API 支持 Promise

Chrome API 都是回调型,连续使用非常不方便,希望能改成 Promise 型。Chrome 本身不提供 promisify,不过可以自己写一个:

export default function promisify(original) {
  return function (...args) {
    return new Promise((resolve, reject) => {
      original(...args, (...results) => {
        if (chrome.runtime.lastError) {
          reject(chrome.runtime.lastError.message);
        } else {
          resolve(...results);
        }
      });
    });
  }
}

这里有几个注意事项:

  1. 参数使用 ...args 进行拆解和合并,方便调用
  2. 需要检查 runtime.lastError,不然出错的时候浏览器会报错,影响体验

使用的时候,我比较喜欢只修改需要使用的函数,不打算 promisify 全部函数,大概是这样:

import promisify from './index';

/* global chrome */

export const update = promisify(chrome.tabs.update);
export const remove = promisify(chrome.tabs.remove);
export const get = promisify(chrome.tabs.get);
// 我觉得 `close tab` 看起来更合理
export const close = remove;
// 封装一个 `goto` 的快捷方式
export const goto = function (tabId, url) {
  return update(tabId, {url});
}
// 全部导入,好处是简单,坏处是不方便 tree-shaking
import * as ChromeTabs from '../chrome-promisify/chrome.tabs';

ChromeTabs.goto(tabId, 'https://blog.meathill.com');

// 需要哪个导入哪个
import {goto} from '../chrome-promisify/chrome.tabs';

goto(tabId, 'https://github.com/meathill');
分类
chrome

Chrome 扩展存储

以前写的有问题,编辑下。

研究了半天,原来 Chrome 扩展不支持文件存储,只能使用 chrome.storage 保存持久化数据。

提供两种形式,一种可以在浏览器间自动同步,适用登录用户保存设置,比如微博的“眼不见心不烦”,chrome.storage.sync,提供一个 key 8K,最大512个 key,总数据量100K(即不可能512个 key 都装满)的存储。这个方式对读写频率也有限制,想想也好理解,比精要往 Google 的云同步嘛。

另外一种则更接近平时用的 localStorage,叫 chrome.storage.local。它的限制很少,只要总量不大于5M即可(可以通过设置 unlimitedStorage: true 来取消上限)。

使用的时候,需要在 manifest.json 里声明权限

    {
      "permissions": [
        "storage"
      ]
    }

这样的话就比较符合我的预期了,用户可以任意保存字幕到本地,太多了自己删掉就是,如果希望云同步就付费或者看广告。

分类
chrome

保持 Chrome 插件 popup.html 长期打开

开发 Chrome 插件时,如果使用 popup.html,调试时反复审查元素很麻烦。此时可以用

chrome-extension://插件id/popup.html

在新标签页打开,就简单多了。