google-search-title-qualifiedのChrome Manifest V3対応
ncaq/google-search-title-qualified という自作のWebブラウザ拡張のChrome版のManifest V3対応作業を行いました。
Manifest V3対応はGoogleに迫られているものでしたが、自分自身はGoogle Chromeを全く使っておらず、ほぼFirefoxしか使っていないので、忙しかったのもありモチベーションと作業時間が確保できませんでした。
今回なんとなくモチベーションが上がってきたので行えました。割と大変でした。
GitHub上の作業結果
- refactor(background): バックグラウンドスクリプトをモジュール化して分割 by ncaq · Pull Request #187 · ncaq/google-search-title-qualified
- build(deps)!: 古すぎるブラウザの切り捨て by ncaq · Pull Request #190 · ncaq/google-search-title-qualified
- refactor(encoding): DOMParserを必要なときに必要な場所でのみ生成 by ncaq · Pull Request #192 · ncaq/google-search-title-qualified
- fix(fetch-page): fetchオプションをデフォルトに近づける by ncaq · Pull Request #193 · ncaq/google-search-title-qualified
- fix: 例外を減らしたりメッセージを改善することで開発をやりやすくする by ncaq · Pull Request #194 · ncaq/google-search-title-qualified
- feat!: Manifest V3に対応 by ncaq · Pull Request #195 · ncaq/google-search-title-qualified
- chore(manifest): bump version to 1.0.0 by ncaq · Pull Request #196 · ncaq/google-search-title-qualified
あたりの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のSendMessage
はPromise
を第一級市民として扱っていないので、以下のように処理を二段階に分けて、担当かどうかを同期的に処理して、返り値で非同期処理を行うことをアピールして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_version
を3
に変更しました。
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のサポートが打ち切られる懸念もあります。