Google の検索結果から指定したサイトをブロックする Chrome 拡張機能 uBlacklist を、Firefox に移植してみました。
拡張機能のページ: https://addons.mozilla.org/ja/firefox/addon/ublacklist/
ソースコード: https://github.com/iorate/uBlacklist/tree/firefox
その時に変更する必要があった点のメモです。Chrome の最新バージョンは 73、Firefox は 66 でした。
基本的には
Chrome 拡張機能と Firefox 拡張機能 (WebExtensions) の互換性は高く、多くのコードはそのまま動きます。
しかしこの拡張機能では、以下の点を変更する必要がありました。多くは Google Drive で設定を同期する機能に関連したものです。
manifest.json
key
"key": "..."
Chrome では明示的に key
キーを指定することで、拡張機能の ID を固定できます。開発中に OAuth2 を使った機能をテストするために必要です。
Firefox では key
キーは使わず、代わりに以下のように ID を指定します。
"browser_specific_settings": {
"gecko": {
"id": "@ublacklist"
}
}
ID には、GUID またはメールアドレスのような文字列 (上記のように @ の前が空だったり、有効なドメインでなくても大丈夫のようです) が使用できます。
oauth2
"oauth2": {
"client_id": "...",
"scopes": ["https://www.googleapis.com/auth/drive.file"]
}
Chrome では oauth2
キーを指定しておくと、chrome.identity.getAuthToken
メソッドを使って簡単に Google の OAuth2 が使えます。
Firefox では oauth2
キーは使えず、後述のように chrome.identity.getAuthToken
メソッドも使えません。
拡張機能の API
Firefox では browser
オブジェクト下に Promise ベースの API が用意されていますが、chrome
オブジェクト下のコールバックベースの API も Chrome 同様に使えます。しかしいくつかの API はサポートされていません。
chrome.identity.getAuthToken
chrome.identity.getAuthToken({interactive: true}, token => {
// ...
});
このように簡単に Google の OAuth2 が使えるメソッドです。Firefox ではサポートされていませんから、Google のドキュメント を参考に、browser.identity.launchWebAuthFlow
メソッドを使って自前で書く必要があります。
const getAuthToken = async details => {
const OAUTH2_CLIENT_ID = "...";
const OAUTH2_SCOPES = ["https://www.googleapis.com/auth/drive.file"];
const authURL = 'https://accounts.google.com/o/oauth2/auth'
+ `?client_id=${OAUTH2_CLIENT_ID}`
+ '&response_type=token'
+ `&redirect_uri=${encodeURIComponent(browser.identity.getRedirectURL())}`
+ `&scope=${encodeURIComponent(OAUTH2_SCOPES.join(' '))}`;
const redirectURL = await browser.identity.launchWebAuthFlow({
url: authURL,
interactive: details.interactive || false
});
const params = new URLSearchParams(new URL(redirectURL).hash.slice(1));
if (params.has('error')) {
throw new Error(`Authentication failed: ${params.get('error')}`);
}
return params.get('access_token');
};
また chrome.identity.getAuthToken
は、取得したアクセストークンを有効期間中キャッシュしてくれます。上記のコードには含めていませんが、これも自分で実装する必要があります。
chrome.identity.removeCachedAuthToken
chrome.identity.removeCachedAuthToken({token: '...'}, () => {
// ...
});
アクセストークンのキャッシュを削除するメソッドで、やはり再実装する必要があります。
ウェブ標準
dialog 要素
Firefox はデフォルトの状態では <dialog>
要素に対応していません (バージョン 66 時点)。about:config
から dom.dialog_element.enabled
を true
に設定することで有効にできますが、拡張機能のユーザーにそれを要求することはできませんし、また showModal
メソッドが意図した挙動をしません。
そこで polyfill を導入する必要があります。
dialog-polyfill.js
と dialog-polyfill.css
を読み込んだ上で、<dialog>
要素に対して dialogPolyfill.registerDialog
メソッドを呼び出すと、ダイアログとして使えるようになります。
<dialog id="ubBlockDialog">
...
</dialog>
const blockDialog = document.getElementById('ubBlockDialog');
dialogPolyfill.registerDialog(blockDialog);
blockDialog.showModal();
この拡張機能で使っている範囲では、Chrome の <dialog>
と同じ挙動が実現されています。
MutationObserver
Chrome と Firefox の挙動の違いにハマりました。と言っても、今回は拡張機能側の潜在的なバグといった感じですが。
MutationObserver
の observe
メソッドで {childList: true}
を指定すると、子ノードの追加・削除時にコールバックが呼び出されます。これはテキストノードを含みますから、監視対象の textContent
を変更すると、子のテキストノードの削除と追加が行われ、コールバックが呼び出されます。
Firefox は textContent
をセットする度にコールバックを呼び出します。しかし Chrome はその内容が変わらない場合はコールバックを呼び出しません。以下のコードで確認できます。
<span id="foo"></span>
<script>
const foo = document.getElementById('foo');
new MutationObserver(() => {
console.log('callback');
}).observe(foo, {
childList: true
});
setInterval(() => {
foo.textContent = 'foo';
}, 1000);
</script>
Firefox は 1 秒ごとにコンソールに callback
と出力しますが、Chrome は最初の 1 回しか出力しません。
元々の拡張機能のコードでは、コールバック中に毎回 textContent
をセットしていたため、Firefox では無限ループに突入していました。
対応としては、textContent
をセットするのではなく、子のテキストノードの nodeValue
を変更してあげれば良さそうです (参考)。今回は、そもそもコールバックの度に textContent
をセットする設計を改めました。
サードパーティライブラリ
Google API Client Libraries (gapi)
Google の API に簡単にアクセスするためのライブラリで、各言語版が用意されています (JavaScript 版)。これが Firefox アドオンからだと動きませんでした。
const script = document.createElement('script');
script.src = 'https://apis.google.com/js/api.js';
script.addEventListener('load', () => {
gapi.load('client', {
callback() {
// ...
},
onerror(e) {
// e == "d'moz-extension"
throw new Error(`The Google API client failed to load: ${e}`);
}
});
});
document.body.appendChild(script);
d'moz-extension
というエラーメッセージが返ってきているので、Firefox 拡張機能の URL のスキーム moz-extension
に対応していない感じがします。検索すると似たような事案はあるようです。
仕方がないので gapi
の使用をやめて、API リクエストを行う gapi.client.request
の呼び出しを、fetch
を使った自前の実装に差し替えました。
さらに、Firefox 拡張機能のオリジン moz-extension://...
からの fetch
では CORS が通らないようなので、manifest.json
の permissions
に以下を追加して、同一オリジンポリシーを回避しました。
"permissions": [
"...",
"*://*.googleapis.com/*",
"*://*.googleusercontent.com/*"
]
オプションページ
window.close
Chrome 拡張機能のオプションページは、(manifest.json
で "open_in_tab": true
にしていなければ) モーダルダイアログとして開き、拡張機能が自分で閉じることができます。
document.getElementById('okButton').addEventListener('click', () => {
// ...
window.close();
});
Firefox ではオプションページは親ページに埋め込まれる形で表示され、拡張機能から勝手に閉じることはできません。
まとめ
最初に述べたように、そもそも拡張機能の互換性は高いです (Chrome が Manifest V3 に移行したらどうなるのか知りませんが…)。しかし使用している API やライブラリによっては、変更を加える必要があります。