Firefoxのアドオンで組み込みのページを提供する場合の注意点 - 2017-12-20 - ククログ

ククログ

株式会社クリアコード > ククログ > Firefoxのアドオンで組み込みのページを提供する場合の注意点

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

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

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

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

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

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

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

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

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

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

「そうでないスクリプト」とは何かというと、コンテントスクリプトがそれにあたります。

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

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

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

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

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

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

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

今回のような場面では、コンテントスクリプトを動的に読み込む方法を使う必要があります。

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

// "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で行いました。まずWebExtensions関係の実装が含まれているディレクトリ配下で(path:components/extensions)、自動テストのファイルと思われるファイル以外で(-path:test)、タブを閉じるための内部的なメソッドを参照している箇所(removeTab)を検索しました。すると、WebExtensions関係でタブを閉じる処理を行っていると思われる箇所が数カ所見つかります。この中でメソッド名の部分一致でない検索結果は以下の2箇所でした。

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

そこで後者のコードでフックを仕掛けているアドオンのシャットダウン処理の通知の出所を検索してみたところ、ExtensionPageContextParentというクラスのshutdownというインスタンスメソッドの中から通知されている事が分かりました。この時、通知のメッセージと共にExtensionPageContextParentのインスタンス自身がリスナに渡されており、そこから芋蔓的に「閉じられるべきタブ」が特定されているという事も分かりました。ということは、このインスタンスがどこで作成されているのかを調べれば、タブが勝手に閉じられてしまう条件が掴めそうに思えます。

という事でクラス名で再検索すると、ExtensionPageContextParentクラスのcreateProxyContextメソッドの中でインスタンスが作られていて、このメソッドはe10sにおけるプロセス間通信でのAPI:CreateProxyContextというメッセージを切っ掛けに実行されている事が分かりました。このメッセージの出所はChildAPIManagerクラスのコンストラクタの中だという事も分かりました。

このクラスのインスタンスが作られる場面を検索すると、以下の3箇所が該当しました。

  1. コンテントスクリプトの名前空間の初期化に関わっていそうな箇所

  2. アドオンに含まれるページの名前空間の初期化に関わっていそうな箇所

  3. 開発ツールの名前空間の初期化に関わっていそうな箇所

ここでそれぞれのコードの周囲を見ると、先程見たExtensionPageContextParentのインスタンスが作られる分岐に入る条件に現れている"addon_parent"という文字列と同じ物が、2番目のアドオンに含まれるページの名前空間の初期化処理らしき箇所にもある事と、コンテントスクリプトの名前空間の初期化処理らしき箇所からは別の分岐に流れている様子が窺えました。

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

おわりに

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

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

  1. <script>タグの内容にスクリプトを直接書いた物(インラインスクリプト)は、アドオンにおいては安全のため初期状態では実行されません。実行を許可するためには、実行したいスクリプトのハッシュ値をecho '<script>タグの内容' | openssl dgst -sha256 -binary | openssl enc -base64などの方法で求めて、マニフェストファイルのcontent_security_policyキーを使ってインラインスクリプト用のContents Security Policyを設定する必要があります。

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