Chrome 拡張機能を Firefox に移植してみた

Google の検索結果から指定したサイトをブロックする Chrome 拡張機能 [uBlacklist](https://qiita.com/iorate/items/9ff65360fbdf4082476a) を、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

```json
"key": "..."
```

Chrome では明示的に `key` キーを指定することで、拡張機能の ID を固定できます。開発中に OAuth2 を使った機能をテストするために必要です。
Firefox では `key` キーは使わず、代わりに以下のように ID を指定します。

```json
"browser_specific_settings": {
  "gecko": {
    "id": "@ublacklist"
  }
}
```

ID には、GUID またはメールアドレスのような文字列 (上記のように @ の前が空だったり、有効なドメインでなくても大丈夫のようです) が使用できます。

### oauth2

```json
"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

```javascript
chrome.identity.getAuthToken({interactive: true}, token => {
  // ...
});
```

このように簡単に Google の OAuth2 が使えるメソッドです。Firefox ではサポートされていませんから、[Google のドキュメント](https://developers.google.com/identity/protocols/OAuth2UserAgent) を参考に、`browser.identity.launchWebAuthFlow` メソッドを使って自前で書く必要があります。

```javascript
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

```javascript
chrome.identity.removeCachedAuthToken({token: '...'}, () => {
  // ...
});
```

アクセストークンのキャッシュを削除するメソッドで、やはり再実装する必要があります。

## ウェブ標準
### dialog 要素
Firefox はデフォルトの状態では `<dialog>` 要素に対応していません (バージョン 66 時点)。`about:config` から `dom.dialog_element.enabled` を `true` に設定することで有効にできますが、拡張機能のユーザーにそれを要求することはできませんし、また `showModal` メソッドが意図した挙動をしません。
そこで polyfill を導入する必要があります。

[dialog-polyfill](https://github.com/GoogleChrome/dialog-polyfill)

`dialog-polyfill.js` と `dialog-polyfill.css` を読み込んだ上で、`<dialog>` 要素に対して `dialogPolyfill.registerDialog` メソッドを呼び出すと、ダイアログとして使えるようになります。

```html
<dialog id="ubBlockDialog">
  ...
</dialog>
```

```javascript
const blockDialog = document.getElementById('ubBlockDialog');
dialogPolyfill.registerDialog(blockDialog);

blockDialog.showModal();
```

この拡張機能で使っている範囲では、Chrome の `<dialog>` と同じ挙動が実現されています。

### MutationObserver
Chrome と Firefox の挙動の違いにハマりました。と言っても、今回は拡張機能側の潜在的なバグといった感じですが。

`MutationObserver` の `observe` メソッドで `{childList: true}` を指定すると、子ノードの追加・削除時にコールバックが呼び出されます。これはテキストノードを含みますから、監視対象の `textContent` を変更すると、子のテキストノードの削除と追加が行われ、コールバックが呼び出されます。
Firefox は `textContent` をセットする度にコールバックを呼び出します。しかし Chrome はその内容が変わらない場合はコールバックを呼び出しません。以下のコードで確認できます。

```html
<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` を変更してあげれば良さそうです ([参考](https://qiita.com/cyokodog@github/items/3368883bf00b4dd48dff))。今回は、そもそもコールバックの度に `textContent` をセットする設計を改めました。

## サードパーティライブラリ
### Google API Client Libraries (gapi)
Google の API に簡単にアクセスするためのライブラリで、各言語版が用意されています ([JavaScript 版](https://developers.google.com/api-client-library/javascript/reference/referencedocs))。これが Firefox アドオンからだと動きませんでした。

```javascript
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` に対応していない感じがします。検索すると[似たような事案](https://github.com/google/google-api-javascript-client/issues/154)はあるようです。
仕方がないので `gapi` の使用をやめて、API リクエストを行う `gapi.client.request` の呼び出しを、`fetch` を使った自前の実装に差し替えました。
さらに、Firefox 拡張機能のオリジン `moz-extension://...` からの `fetch` では CORS が通らないようなので、`manifest.json` の `permissions` に以下を追加して、同一オリジンポリシーを回避しました。

```json
"permissions": [
  "...",
  "*://*.googleapis.com/*",
  "*://*.googleusercontent.com/*"
]
```

## オプションページ
### window.close
Chrome 拡張機能のオプションページは、(`manifest.json` で `"open_in_tab": true` にしていなければ) モーダルダイアログとして開き、拡張機能が自分で閉じることができます。

```javascript
document.getElementById('okButton').addEventListener('click', () => {
  // ...
  window.close();
});
```

Firefox ではオプションページは親ページに埋め込まれる形で表示され、拡張機能から勝手に閉じることはできません。

## まとめ
最初に述べたように、そもそも拡張機能の互換性は高いです (Chrome が Manifest V3 に移行したらどうなるのか知りませんが…)。しかし使用している API やライブラリによっては、変更を加える必要があります。