• 作成:

google-search-title-qualifiedのChrome Manifest V3対応

ncaq/google-search-title-qualified という自作のWebブラウザ拡張のChrome版のManifest V3対応作業を行いました。

Manifest V3対応はGoogleに迫られているものでしたが、自分自身はGoogle Chromeを全く使っておらず、ほぼFirefoxしか使っていないので、忙しかったのもありモチベーションと作業時間が確保できませんでした。

今回なんとなくモチベーションが上がってきたので行えました。割と大変でした。

GitHub上の作業結果

あたりのPRで作業を完了しました。

自動生成でしたがリリースノートは以下です。 Release v1.0.0 · ncaq/google-search-title-qualified

fetchのオプションのデフォルト化

fetchを実行するときにmode: "no-cors"を設定していたのですが、 Manifest V3ではmodeを設定していると<all_urls>権限を持たせていてもfetchのresponseの内容が見れなくなったり奇妙な動作をします。ちゃんと読み込めば挙動はわかると思いますが、単にfetchでGETするだけなので、デフォルト設定にすることで問題を解決しました。

GETするだけでぶっ壊れるのは周りがおかしいと思うので、雑に済ませています。一応credentials: "omit"は残しているので機密上の問題は少ないでしょうし。

fetchのタイムアウトにAbortSignalを使用

推奨されないsetTimeoutを置き換えるために、最初はalarms APIを使ってタイムアウトを実装していたのですが、 signal: AbortSignal.timeout(timeoutFetchPageMilliSeconds), と書くほうがシンプルであることに気がついたので置き換えました。

DOMParserを使うためにOffscreen Documentを使う

HTMLを正しく書いてくれるサイトは少ないので、 HTMLをパースするには成熟した実用的なブラウザのパーサが必要です。なのでDOMParserを使っているのですが、 Manifest V3ではService Worker内部になるのでDOMParserが使えません。

なので、 MV3 service workerからoffscreenでDOM解析 | chmono.com で紹介されているOffscreen Documentを使うことにしました。

webextension-polyfillはOffscreenのようなWebExtension APIが提供していない型情報をカバーしていないので、関連プロジェクトが提供している@types/chromeを使う必要があります。 sendMessageなどは別のモジュールなので通常通り使えます。

最初に以下のようにDOMParserが使えるかどうかを検知して、使えない場合Offscreenを使うように分岐しました。

/**
 * `DOMParser`が実際に正しく動作するかをチェックします。
 * ChromeのManifest V3のService Workerでは`DOMParser`は存在するが、
 * `parseFromString`の結果に対して`querySelector`等が正しく動作しません。
 * FirefoxのWebExtension APIなどでは動作します。
 */
function checkDOMParser(): boolean {
  try {
    const parser = new DOMParser();
    const doc = parser.parseFromString("<title>test</title>", "text/html");
    const title = doc.querySelector("title")?.textContent;
    // 正常に動作していればtitleは"test"になるはず
    return title === "test";
  } catch {
    return false;
  }
}

/**
 * `DOMParser`が実際に正しく動作するかを一度だけチェックしてキャッシュします。
 */
const workDOMParser: boolean = checkDOMParser();

参考にした記事とは違って、我々が必要とするOffscreen Documentは一つだけで良いので、終了処理は具体的には書かずにChrome側のリソース管理に任せることにしました。どうせ保持してる変数とか全然ないからメモリも大して食いませんし。

メッセージパッシングの更新

これ自体はManifest V3に直接関係はしていないのですが、これまでcontent scriptとbackground scriptの間でのみメッセージパッシングをしていたのを、 content script <-> background <-> offscreenのように増えてしまったので、 content scriptが送ったメッセージがbackgroundだけではなくoffscreenにも届くようになってしまい、 offscreenがメッセージを受け取って混線してしまいました。

これの原因が今ひとつ分からなくてかなり混乱していました。

なのでちゃんとio-tsを使ってtarget: t.literal("background"),のように担当を分離する必要がありました。

またChromeのSendMessagePromiseを第一級市民として扱っていないので、以下のように処理を二段階に分けて、担当かどうかを同期的に処理して、返り値で非同期処理を行うことをアピールしてPromiseを実行する必要がありました。

/**
 * バックグラウンド向けのメッセージかを同期的に判定し、
 * 該当する場合は非同期処理を開始してtrueを返します。
 * 該当しない場合はfalseを返して他のリスナーに委譲します。
 */
export function onMessageListener(
  message: unknown,
  sendResponse: (response: BackgroundResponse) => void,
): boolean {
  const decoded = BackgroundMessage.decode(message);
  if (isLeft(decoded)) {
    // バックグラウンド向けのメッセージではないので、他のリスナーに委譲
    return false;
  }
  // バックグラウンド向けのメッセージなので非同期処理を開始
  handleMessage(decoded.right)
    .then(sendResponse)
    .catch((err: unknown) => {
      // eslint-disable-next-line no-console
      console.error("onMessageListener is error.", err);
      sendResponse(undefined);
    });
  // 非同期応答を行うことをChromeに通知
  return true;
}

Responseが前触れなくnullになることがあるので混乱しました。私はundefinedで基本的に統一していたので。 ??で変換することにしています。

manifest.jsonの変更

当然manifest_version3に変更しました。

permissions: ["<all_urls>"]と書いていたものを分離します。ホスト名を設定するものはhost_permissions: ["<all_urls>"]と名前を変えて、バックグラウンド用のAPI権限としてpermissions: ["alarms", "offscreen", "storage"]のように設定します。

Chrome Manifest V3ではbackgroundのエントリーポイントはscriptsではなくservice_workerに記述します。両方指定するとFirefox WebExtension APIではscriptsが読み込まれて、 Chrome Manifest V3ではservice_workerが読み込まれるようなので、両対応を雑に行うなら両方に記述すれば良いでしょう。それだとChrome側ではscriptsフィールドはManifest V2用の記述だと警告が出ますが、警告は無視しても動作には問題ありません。

まあそれも含めて警告を残すのは気持ちが悪いので、 web-ext lintの警告も含めて消すために分離しますが。

元々manifest.jsonは手書きではなく、 manifest.json.make.tsとしてTypeScriptで書いて、 ts-nodeで実行して生成していました。 Googleのドメインを機械的に取得したかったからです。

なので今回はFirefox用とChrome用でmanifest.jsonを分離するのが都合が良かったので、 target引数を渡して、 baseとなるManifestをベースにそれぞれ派生させました。

ビルド時にesbuildに、

ts-node manifest.json.make.ts firefox && esbuild --bundle --outdir=build/firefox/dist --target=firefox115 --define:__BROWSER_TARGET__='\"firefox\"' src/background/main.ts src/content/main.ts && yarn copy-assets:firefox

のようにdefineでコンパイル時変数を渡して、 Firefoxでは不要かつ非対応のOffscreenを呼び出しているモジュールをifでわかりやすく分岐することにより、 esbuildにデッドコード除去を行ってもらうことで、 web-ext lintが発する「対応していない」という警告を抑制しました。

ビルドの出力先の分離

これまでは単一のgoogle-search-title-qualified.zipというビルド結果のファイルを生成していましたが、今回はmanifest.jsonからして分離したので、それぞれ違うビルド結果ファイルを生成しました。

これまではビルド結果のscriptは/dist以下に入ってそれをzipにまとめることになっていましたが、今回は/build/firefox/build/chromeに分けて、それ以下にdistのようなビルド結果のスクリプトが入るようにしました。

またzipファイルは/artifactsディレクトリ以下に入れています。

懸念

FirefoxはWebExtension APIをChromeのAPIに合わせて実装していたので、 Chromeの拡張機能をスムーズにFirefoxに移植できました。しかし今回のOffscreenのように、 Firefoxが対応していないAPIが増えることになりました。

そもそもFirefoxは普通にbackground scriptでDOMParserが使えるので、 Offscreenを使う必要はないのですが、移植する手間は当然かかることになります。

Chromeが自分自身のアーキテクチャに合わせて詳細にAPIを作っていった結果、互換性が失われていくと面倒ですね。私の場合はFirefoxしか使っていないので、本当に面倒になったらChromeのサポートを打ち切るだけなのですが、逆に利用されることを目的にする開発者にとってはFirefoxのサポートが打ち切られる懸念もあります。