Firefoxのアドオンで組み込みのページを提供する場合の注意点

この記事は[所属会社のブログとのクロスポスト](http://www.clear-code.com/blog/2017/12/20.html)です。

WebExtensions APIに基づくFirefox用アドオンでは、ユーザーインターフェースを提供するための方法として、ツールバーボタンのポップアップメニュー、サイドバーといったUIの部品として表示される物以外に、[通常のタブやウィンドウとして開くためのページを組み込む事ができます](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/user_interface/Extension_pages)。

といっても実現方法は非常に単純で、開きたいページを実装したHTMLファイルをアドオンのパッケージ内に含めた上で`browser.tabs.create({ url: "./group-tab.html" })`のように指定してタブで開くだけです。実際に、例えば[ツリー型タブ](https://addons.mozilla.org/firefox/addon/tree-style-tab/)では、初回インストール時などに開かれる説明ページや、ブックマークフォルダからまとめてタブを開いた時などにそれらをグループ化するために使われるタブ（以下、「グループタブ」と呼ぶ事にします）がこの方法で実装されています。

# タブが「勝手に消えてしまう」場合がある

さて、この方法で開かれたタブを一種のUIとして使う場合に、1つ気をつけなくてはならない事があります。それは、*ページの作り方によっては、Firefoxの再起動時に必ずそのタブが失われてしまう場合がある*という事です。再起動以外にも、アドオンマネージャでアドオンを一時的に無効化した時や、アドオンが自動更新された時なども同じ事が起こります。本項で述べる条件に当てはまるページを開いているタブは、これらの場面でFirefoxによって勝手に閉じられてしまうという性質があります。

その条件とは、端的に言えば、*ページの一部として、アドオンの通常の権限で実行可能なJavaScriptを含むページ*です。具体的には、`<script>`タグの内容として直接スクリプトを記述している場合[^inline-script]や、パッケージ内に含まれるJavaScriptのファイルを`<script type="application/javascript" src="./group-tab.js"></script>`のようにして参照している場合がこれにあたります。

[^inline-script]: `<script>`タグの内容にスクリプトを直接書いた物（インラインスクリプト）は、アドオンにおいては安全のため初期状態では実行されません。実行を許可するためには、実行したいスクリプトのハッシュ値を`echo "<script>タグの内容" | openssl dgst -sha256 -binary | openssl enc -base64`などの方法で求めて、[マニフェストファイルの`content_security_policy`キー](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/manifest.json/content_security_policy)を使って[インラインスクリプト用のContents Security Policyを設定する](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src#Unsafe_inline_script)必要があります。

ユーザーの操作に反応するなどの動的な処理を行うためには、スクリプトの使用は避けて通れません。実際に「ツリー型タブ」のグループタブでも、タブ名の部分をクリックして編集したり、「一時的なグループ」チェックボックスの状態をURLのクエリパラメータとして保持したりするために、スクリプトを使う必要があります。しかし上記の理由から、そのままだとFirefoxの再起動やアドオン自体の更新の度にグループタブが失われてしまうという事になります。グループタブはタブのツリーを形成する要素の1つなので、勝手に失われてしまうとツリーが壊れてしまいますから、これでは実用に耐えません。

どうすればこの問題を解消できるでしょうか？

# 動的な機能を持ったページをタブで開きつつ、自動的に閉じられないようにする方法

タブが閉じられてしまう原因は、前述した通り「アドオンの通常の権限を持ったスクリプトがそのページ内で動作している」せいです。言い換えると、そうでないスクリプトが実行されているだけであれば、タブが勝手に閉じられてしまう事はありません。

「そうでないスクリプト」とは何かというと、[コンテントスクリプト](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Content_scripts)がそれにあたります。

コンテントスクリプトは、Webページの名前空間にアドオンから任意のスクリプトを注入して実行する仕組みです。注入するスクリプトの中では一般的なJavaScriptの機能の他に[WebExtensions APIのサブセット](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Content_scripts#WebExtension_APIs)を利用できます。これらの範囲内だけでページの機能を実装すれば、「ツリー型タブ」のグループタブのように「Firefoxを再起動したりアドオンを更新したりしても勝手に閉じられない、機能性を持ったタブ」を開いておけます。

## 静的なコンテントスクリプトでは対応できない

コンテントスクリプトは一般的には、`manifest.json`の`content_scripts`キーを使って注入の指示を静的に・宣言的に指定します。しかし、今回の用途にはこの方法は使えません。

アドオンのパッケージ内に含まれるHTMLファイルは、実際には`moz-extension://a5abe0a8-70d1-4c64-975b-b19c7f7740fe/resources/group-tab.html`のような内部的なURLで参照されています。`content_scripts`でのコンテントスクリプトの注入対象は[マッチパターン](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/match_patterns)で指定する必要があるのですが、実は、この内部的なURLに対してはどんなマッチングパターンを指定しても期待通りの結果を得られない（指定したコンテントスクリプトが読み込まれない）のです。例えば以下の要領です。


```javascript
  "content_scripts": [
    {
      "matches": [
        /* moz-extension:を含むマッチングパターンは不正な物として扱われる */
        "moz-extension://*/group-tab.html*",
        /* かといって、こちらも期待通りにマッチしない */
        "<all_urls>"
      ],
      "run_at": "document_start",
      "js": [
        "/group-tab.js"
      ]
    }
  ],
```

そもそも、上記の例のようにアドオンの内部的なURLはUUIDを含む形になっており、そのUUIDがインストールする度に変わるようになっている[^uuid]ことから、静的・宣言的な定義ではURLを正確に指定できないという問題もあります。無駄に広範なマッチングパターンを設定して無関係のページにまでスクリプトを注入してしまうというのは、褒められた事ではありません。

[^uuid]: これは、Webページがアドオンの内部URLを参照して各ユーザーのアドオンのインストール状況を調べユーザーの動向をトラッキングする「フィンガープリンティング」を防止するための仕様です。

## 動的なコンテントスクリプトでの実現

今回のような場面では、[コンテントスクリプトを動的に読み込む方法](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/Tabs/executeScript)を使う必要があります。

`tabs.executeScript()`は特定のタブに任意のタイミングでコンテントスクリプトを注入する機能です。一般的なWebページに対してはマッチパターンであらかじめ許可を与えられた場合にのみ使えるのですが、例外として、そのアドオン自身に含まれているページ（上記の内部的なURLで示されるページ）に対しては事前の許可無しで使えるようになっています。以下はこれを使って、アドオンに含まれるページがタブに読み込まれた時点でそれを検知し、コンテントスクリプトを注入する例です。

```javascript
// "tabs" をpermissionsに含めておく必要がある。

// スクリプトを注入したいページが新たに読み込まれた時のハンドリング。
browser.tabs.onUpdated.addListener((aTabId, aChangeInfo, aTab) => {
  if (aChangeInfo.status || aChangeInfo.url)
    tryInitGroupTab(aTab);
});

// スクリプトを注入したいページを既に読み込み済みのタブがアクティブになった時のハンドリング。
browser.tabs.onActivated.addListener(async (aActiveInfo) => {
  var tab = await browser.tabs.get(aActiveInfo.tabId);
  tryInitGroupTab(tab);
});

// 事前にUUIDを含む内部URLを特定しておく。
const GROUP_TAB_URL = browser.extension.getURL('/group-tab.html');

async function tryInitGroupTab(aTab) {
  // URLをヒントに、スクリプトを注入したいページを開いたタブかどうかを判別する
  if (aTab.url.indexOf(GROUP_TAB_URL) != 0)
    return;
  browser.tabs.executeScript(aTab.id, {
    runAt:           'document_start',
    matchAboutBlank: true
    file:            '/group-tab.js'
  });
}
```

実際にリリースされているバージョンの「ツリー型タブ」でも、これと同様の事を行ってグループタブの挙動を実現しています。

コンテントスクリプトではWebExtensions APIのサブセットのみ使えますが、その中には`browser.runtime.sendMessage()`が含まれています。サブセットに含まれていないAPIが必要な機能をそのページ起点で使いたい場合は、必要なAPIを自由に使えるバックグラウンドスクリプトなどで機能の大部分を実装しておき、`browser.runtime.sendMessage()`でそれを呼び出すという形にすると良いでしょう。


# 参考：この現象が起こる原因が何であるかの調査の様子

ここからは余談として、「アドオンの通常の権限で実行可能なJavaScriptを含むページを読み込んでいるタブがFirefoxによって勝手に閉じられてしまう」という事について、実際に起こっていた現象からその挙動の元になっている実装を特定するまでの調査の様子をご紹介します。

調査は主に、Firefoxのソースコードをオンラインで検索できる[DXR](https://dxr.mozilla.org/)で行いました。まず[WebExtensions関係の実装が含まれているディレクトリ配下で（`path:components/extensions`）、自動テストのファイルと思われるファイル以外で（`-path:test`）、タブを閉じるための内部的なメソッドを参照している箇所（`removeTab`）を検索](
https://dxr.mozilla.org/mozilla-central/search?q=path%3Acomponents%2Fextensions++-path%3Atest+removeTab&redirect=false)しました。すると、WebExtensions関係でタブを閉じる処理を行っていると思われる箇所が数カ所見つかります。この中でメソッド名の部分一致でない検索結果は以下の2箇所でした。

 * [`browser/components/extensions/ext-tabs.js`の449行目](https://dxr.mozilla.org/mozilla-central/rev/5d203926da51a7e949a20818664b19d5b115572d/browser/components/extensions/ext-tabs.js#449)
 * [`browser/components/extensions/ext-browser.js`の76行目](https://dxr.mozilla.org/mozilla-central/rev/5d203926da51a7e949a20818664b19d5b115572d/browser/components/extensions/ext-browser.js#76)

前者は`browser.tabs.remove()`の挙動を実装している箇所なので、今回の挙動とは無関係である可能性があります。その一方で、後者は[アドオンの無効化時やFirefoxの終了時などのシャットダウン処理にフックを仕掛けて、一定の条件が満たされた時にタブを閉じるという実装](https://dxr.mozilla.org/mozilla-central/rev/5d203926da51a7e949a20818664b19d5b115572d/browser/components/extensions/ext-browser.js#64)になっています。起こっている現象から見て、こちらの箇所が問題の挙動の原因である可能性が高そうです。

そこで後者のコードでフックを仕掛けている[アドオンのシャットダウン処理の通知の出所を検索してみた](https://dxr.mozilla.org/mozilla-central/search?q=page-shutdown&=mozilla-central)ところ、[`ExtensionPageContextParent`というクラスの`shutdown`というインスタンスメソッドの中から通知されている](https://dxr.mozilla.org/mozilla-central/rev/5d203926da51a7e949a20818664b19d5b115572d/toolkit/components/extensions/ExtensionParent.jsm#555)事が分かりました。この時、通知のメッセージと共に`ExtensionPageContextParent`のインスタンス自身がリスナに渡されており、そこから芋蔓的に「閉じられるべきタブ」が特定されているという事も分かりました。ということは、このインスタンスがどこで作成されているのかを調べれば、タブが勝手に閉じられてしまう条件が掴めそうに思えます。

という事で[クラス名で再検索する](https://dxr.mozilla.org/mozilla-central/search?q=ExtensionPageContextParent)と、[`ExtensionPageContextParent`クラスの`createProxyContext`メソッドの中](https://dxr.mozilla.org/mozilla-central/rev/5d203926da51a7e949a20818664b19d5b115572d/toolkit/components/extensions/ExtensionParent.jsm#728)でインスタンスが作られていて、このメソッドは[e10sにおけるプロセス間通信での`API:CreateProxyContext`というメッセージ](https://dxr.mozilla.org/mozilla-central/rev/5d203926da51a7e949a20818664b19d5b115572d/toolkit/components/extensions/ExtensionParent.jsm#676)を切っ掛けに実行されている事が分かりました。このメッセージの出所は[`ChildAPIManager`クラスのコンストラクタの中](https://dxr.mozilla.org/mozilla-central/rev/5d203926da51a7e949a20818664b19d5b115572d/toolkit/components/extensions/ExtensionChild.jsm#802)だという事も分かりました。

[このクラスのインスタンスが作られる場面を検索](https://dxr.mozilla.org/mozilla-central/search?q=ChildAPIManager)すると、以下の3箇所が該当しました。

 1. [コンテントスクリプトの名前空間の初期化に関わっていそうな箇所](https://dxr.mozilla.org/mozilla-central/rev/5d203926da51a7e949a20818664b19d5b115572d/toolkit/components/extensions/ExtensionContent.jsm#548)
 2. [アドオンに含まれるページの名前空間の初期化に関わっていそうな箇所](https://dxr.mozilla.org/mozilla-central/rev/5d203926da51a7e949a20818664b19d5b115572d/toolkit/components/extensions/ExtensionPageChild.jsm#246)
 3. [開発ツールの名前空間の初期化に関わっていそうな箇所](https://dxr.mozilla.org/mozilla-central/rev/5d203926da51a7e949a20818664b19d5b115572d/toolkit/components/extensions/ExtensionPageChild.jsm#297)

ここでそれぞれのコードの周囲を見ると、先程見た[`ExtensionPageContextParent`のインスタンスが作られる分岐](https://dxr.mozilla.org/mozilla-central/rev/5d203926da51a7e949a20818664b19d5b115572d/toolkit/components/extensions/ExtensionParent.jsm#728)に入る条件に現れている`"addon_parent"`という文字列と同じ物が、2番目の[アドオンに含まれるページの名前空間の初期化処理らしき箇所](https://dxr.mozilla.org/mozilla-central/rev/5d203926da51a7e949a20818664b19d5b115572d/toolkit/components/extensions/ExtensionPageChild.jsm#246)にもある事と、コンテントスクリプトの名前空間の初期化処理らしき箇所からは別の分岐に流れている様子が窺えました。

以上の調査結果と、実際の検証時の「ページに埋め込んだスクリプトやページから直接参照したスクリプトがある時はタブが閉じられて、コンテントスクリプトを注入しただけだとタブは閉じられない」という結果から、*スクリプトの名前空間が破棄される時に、そのページが読み込まれているタブを自動的に閉じるコードが実行されうるのは、「コンテントスクリプト」「開発ツールのスクリプト」以外の全般的な「アドオンに含まれるページのスクリプト」だけであるようだ*と判断しました。

（なお、筆者は普段の業務では「Firefox（Thunderbird）を使っていてこういう現象が起こっているのだが何故か？ また、回避策はあるか？」というようなお問い合わせをお客様から受けて、この節で記載したような方法で調査を行い「原因はこうで、このようにして回避できます」のような回答をお返しする、という技術サポートを行っています。Firefox/Thunderbirdについてこのような調査を業務として請け負える会社は珍しいようで、重宝していただけているようです。）

# おわりに

Firefox用のアドオンにHTMLとJavaScriptで実装されたページを含めるにあたって、そのページを開いたタブがFirefoxによって勝手に閉じられてしまう場合があるという事と、その条件、回避方法を解説しました。また、条件を特定するにあたって具体的に行った調査の進め方の例もご紹介しました。

OSS・フリーソフトウェアの開発時やAPIの挙動に不可解な部分があって、ドキュメントにそれらしい解説が見つからない場合、それはまだドキュメント化されていない仕様に基づく物である可能性があります。そういう時には、せっかくソースを読める状況にあるのですから、全くの当てずっぽうで使うのではなく、その挙動の原因を明らかにしてから使うようにしてみてはどうでしょうか。そうすることで、最終的なプロダクトの挙動に対して、より確かな自信を持つ事ができるようになるかもしれません。皆さんも、実際に動作しているOSS・フリーソフトウェアのソースをぜひ見てみて下さい。