Firefoxのアドオンで適切な終了処理を実装する方法

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

ソフトウェアをアンインストールする際には、ゴミや痕跡を無駄に残さない事が望ましいです。また、イベントを監視する必要のある機能を含んでいる場合、監視の必要がなくなったにも関わらず監視を続けていると、メモリやCPUを無駄に消費する事になります。こういった無駄を取り除くために行うのが、いわゆる終了処理です。Firefoxのアドオンでも、場合によって終了処理が必要になってきます。

# アドオンが削除される際の終了処理は、現状では不可能

WebExtensions APIはGoogle Chromeの拡張機能向けAPIのインターフェースを踏襲しており、その中には、アドオンがアンインストールされたり無効化されたりしたタイミングで実行されるイベントハンドラを定義するための仕組みも含まれています。以下の2つがそれです。

 * [`runtime.onSuspend`](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/runtime/onSuspend)
 * [`runtime.onSuspendCanceled`](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/runtime/onSuspendCanceled)

しかしながら、これらのAPIはFirefox 57の時点で未実装のため、Firefoxのアドオンでは使用できません。よって、これらのタイミングでの終了処理で後始末をしなければならない類のデータについては、FAQやアドオンの紹介ページの中で手動操作での後始末の手順を案内したり、あるいはそれを支援するスクリプトなどを配布したりする必要があります。

ただ、データの保存の仕方によっては終了処理がそもそも必要ない場合もあります。具体的には、`browser.storage.local`を使用して保存されたデータがこれにあたります。`browser.storage.local`の機能で保存されたデータはアドオンのアンインストールと同時にFirefoxによって削除されますので、アドオン側でこれを消去する終了処理を用意する必要はありません。

# パネルやサイドバーが閉じられた時の終了処理を実現する

ツールバーのボタンのクリックで開かれるポップアップパネル内や、サイドバー内に読み込んだページにおいて登録されたイベントリスナーは、それらのページが破棄されるタイミングで動作しなくなる事が期待されます。そのため、これらのページでは特に終了処理は必要ない場合が多いです。

しかしながら、*これらのページだけで完結せず、バックグラウンドページやコンテントスクリプトと連携する形で機能が実装されている*場合には終了処理が依然として必要です。

例えば、[ツリー型タブ](https://addons.mozilla.org/ja/firefox/addon/tree-style-tab/)はツールバーボタンのクリック操作でサイドバーの表示・非表示をトグルできるようになっていますが、この機能はサイドバーとバックグラウンドページの連携によって実現されています。というのも、[サイドバーの表示・非表示を切り替えるAPI](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/sidebarAction/open)はユーザーの操作に対して同期的に実行された場合にのみ機能して、それ以外の場合はエラーになる、という制限があるからです。WebExtensionsには今のところサイドバーの開閉状態を同期的に取得するAPIがありません。また、ツールバーボタンの動作を定義する箇所で開閉状態のフラグをON/OFFしても、サイドバーのクローズボックスや他のサイドバーパネルの切り替え操作など、ツールバーボタンのクリック操作以外にもサイドバーパネルが開閉される場面は数多くあるため、フラグと実際の状態がすぐに一致しなくなってしまいます。そのため、サイドバー内のページの初期化処理中にバックグラウンドページに対して`browser.runtime.sendMessage()`で通知を送り、サイドバーが開かれた事をフラグで保持し、ツールバーボタンの動作において同期的にフラグを参照しているわけです。

サイドバーが開かれた事はこれで把握できますが、問題はサイドバーが閉じられた事の把握です。ここで「サイドバー内のページのための終了処理」が必要となります。

## DOMイベントの監視

ページが閉じられた事を検知する最も一般的な方法は、ページが破棄される時に発行されるDOMイベントを捕捉するという物です。このような用途に使えそうなDOMイベントは以下の4つがあります。

 * `close`
 * `beforeunload`
 * `unload`
 * `pagehide`

この中で、*サイドバーやポップアップに表示されるページにおいて`close`は通知されず、実際に使えるのは残りの3つだけです*。よって、これらの中のいずれかを捕捉して以下のように終了処理を行う事になります。

```javascript
window.addEventListener('pagehide', () => {
  ...
  // 何らかの終了処理
  ...
}, { once: true });
```

ただし、このタイミングでできる終了処理は非常に限定的です。例えば、`browser.runtime.sendMessage()`でバックグラウンドページ側にメッセージを送信しようとしても、そのメッセージが通知されるよりも前にスクリプトの名前空間が破棄されてしまうせいか、*実際にはそのメッセージがバックグラウンドページ側に通知される事はありません*。ツリー型タブの事例だと、このタイミングで「サイドバーが閉じられた（ページが破棄された）」というメッセージをバックグラウンドページに送ろうとしても、そのメッセージは実際には届く事は無いため、バックグラウンドページから見るとサイドバーは開かれたままとして認識されてしまう事になります。

## 接続の切断の検知

DOMイベントのリスナーではできない終了処理をする方法として、*バックグラウンドページとそれ以外のページの間で接続を維持しておき、その切断をもってページが閉じられた事を検出する*というやり方があります。

[`browser.runtime.connect()`](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/runtime/connect)は、バックグラウンドページとサイドバー内のページのような、異なる名前空間のスクリプト同士の間で双方向にメッセージを送受信できる専用の通信チャンネル（`runtime.Port`）を確立するAPIです。`browser.runtime.sendMessage()`で送信したメッセージは`browser.runtime.onMessage`にリスナを登録しているすべてのスクリプトに通知されますが、この方法で確立した通信チャンネル上を流れるメッセージは、接続を要求した側と受け付けた側のお互いにのみ通知されるという違いがあります。

このAPIは双方向通信のための仕組みなのですが、確立した通信チャンネル（`runtime.Port`）の`onDisconnect`にリスナを登録しておくと、接続元のページが閉じられたなどの何らかの理由で接続が切れたという事を、接続を受け付けた側で検知できるという特徴があります。これを使い、サイドバー内に開かれたページからバックグラウンドページに対して接続を行って、バックグラウンドページ側で接続の切断を監視すれば、間接的にサイドバー内に開かれたページが閉じられた事を検知できるという訳です。以下は、その実装例です。

```javascript:バックグラウンドページ側
var gPageOpenState = new Map();
var CONNECTION_FOR_WINDOW_PREFIX = /^connection-for-window-/;

browser.runtime.onConnect.addListener(aPort => {
  // サイドバー内のページからの接続を検知して処理を行う
  if (!CONNECTION_FOR_WINDOW_PREFIX.test(aPort.name))
    return;
  // 接続名に含めた、サイドバーの親ウィンドウのIDを取り出す
  var windowId = parseInt(aPort.name.replace(CONNECTION_FOR_WINDOW_PREFIX, ''));
  // サイドバーが開かれている事を保持するフラグを立てる
  // （以後は、このフラグを見ればそのウィンドウのサイドバーが開かれているかどうかが分かる）
  gPageOpenState.set(windowId, true);
  // 接続が切れたら、そのウィンドウのサイドバーは閉じられたものと判断し、フラグを下ろす
  aPort.onDisconnect.addListener(aMessage => {
    gPageOpenState.delete(windowId);
  });
});
```

```javascript:サイドバー内で開かれるページ側
window.addEventListener('DOMContentLoaded', async () => {
  // このサイドバーの親となっているウィンドウのIDを取得する
  var windowId = (await browser.windows.getCurrent()).id;
  // サイドバーが開かれた事をバックグラウンドページに通知するために接続する
  browser.runtime.connect({ name: `connection-for-window-${windowId}` });
}, { once: true });
```

確立した通信チャンネルそのものは使っていない、という所がミソです。


## 余談：サイドバー内にページが読み込まれているかどうかを後から調べる方法

`browser.runtime.sendMessage()`で送出されたメッセージは、`browser.runtime.onMessage`のリスナで受け取って任意の値をレスポンスとして返す事ができます。また、誰もメッセージを受け取らなかった場合（誰もレスポンスを返さなかった場合）には、メッセージの送出側には`undefined`が返されます。この仕組みを使い、バックグラウンドページから送ったメッセージにサイドバーやツールバーボタンのパネル側で応答するようにすると、そのページがまだ開かれているのか、それとも何らかの切っ掛けで閉じられた後なのかを判別できます。

```javascript:バックグラウンドページ側
async isSidebarOpenedInWindow(aWindowId) {
  // サイドバーが開かれている事になっているウィンドウを対象に、死活確認のpingを送る
  var response = await responses.push(browser.runtime.sendMessage({ type: 'ping', windowId: aWindowId }))
                         .catch(aError => null); // エラー発生時はサイドバーが既に閉じられていると見なす
  // pongが返ってくればサイドバーは開かれている、有効な値が返ってこなければ閉じられていると判断する
  return !!response;
}
```

```javascript:サイドバー内で開かれるページ側
var gWindowId;
window.addEventListener('DOMContentLoaded', async () => {
  // このサイドバーの親となっているウィンドウのIDを取得する
  gWindowId = (await browser.windows.getCurrent()).id;
}, { once: true });

browser.runtime.onMessage.addListener((aMessage, aSender) => {
  switch (aMessage && aMessage.type) {
    case 'ping':
      // このウィンドウ宛のpingに対してpongを返す
      if (aMessage.windowId == gWindowId) {
        // Promiseを返すと、それがレスポンスとして呼び出し元に返される
        return Promise.resolve(true);
      }
      break;
  }
});
```

この応用として、バックグラウンドページからポーリングすれば、前項の方法の代わりとして使う事もできます（前項の方法に対するメリットは特にありませんが）。

# まとめ

Firefoxのアドオンにおいて、アドオン自体が使用できなくなる場面での終了処理は現状では不可能であるという事と、ツールバーボタンで開かれるパネルに読み込まれたページやサイドバーに読み込まれたページの終了処理の実現方法をご紹介しました。

WebExtensions APIは原則としてリッチなAPIセットを提供する事を志向しておらず、基本的な機能の組み合わせで目的を達成できるのであれば、リッチなAPIは実装しないという判断がなされる事が多いです。やりたい事をストレートに実現できるAPIが見つからない場合には、「APIが無いんじゃあ仕方がない」と諦めてしまわず、今あるAPIの組み合わせで実現する方法が無いか検討してみて下さい。