Firefoxのアドオンで、一般的な方法では分からないタブの状態を判別する

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

Firefoxのタブを参照するアドオンは、[`browser.tabs.get()`](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/tabs/get)や[`browser.tabs.query()`](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/tabs/query)などのAPIを使って各タブの状態を取得します。この時、Firefoxのタブの状態を表すオブジェクトは[`tabs.Tab`という形式のオブジェクト](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/tabs/Tab)で返されます。

`tabs.Tab`にはタブの状態を表すプロパティが多数存在していますが、ここに表れないタブの状態という物もあります。「未読」「複製された」「復元された」といった状態はその代表例です。これらはWebExtensions APIの通常の使い方では分からないタブの状態なのですが、若干の工夫で判別することができます。


# タブが未読かどうかを判別する方法

タブの未読状態は、バックグラウンドのタブの中でページが再読み込みされたりページのタイトルが変化したりしたらそのタブは「未読」となり、タブがフォーカスされると「既読」となります。これは、[`tabs.onUpdated`](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/tabs/onUpdated)で`title`の変化を監視しつつ、[`tabs.onActivated`](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/tabs/onActivated)でタブの未読状態をキャンセルする、という方法で把握できます。以下はその実装例です。

```javascript
// バックグラウンドページで実行しておく（tabsの権限が必要）
var gTabIsUnread = new Map();

browser.tabs.onUpdated.addListener((aTabId, aChangeInfo, aTab) => {
  // アクティブでないタブのタイトルが変化したら未読にする
  if ('title' in aChangeInfo && !aTab.active)
    gTabIsUnread.set(aTabId, true);
});

browser.tabs.onActivated.addListener(aActiveInfo => {
  // タブがアクティブになったら既読にする
  gTabIsUnread.delete(aActiveInfo.tabId);
});

browser.tabs.onRemoved.addListener((aTabId, aRemoveInfo) => {
  // タブが閉じられた後は未読状態を保持しない
  gTabIsUnread.delete(aTabId);
});

// 上記の処理が動作していれば、以下のようにしてタブの未読状態を取得できる。
// var unread = gTabIsUnread.has(id);
```

上記の例では`Map`で状態を保持していますが、Firefox 57以降で使用可能な[`browser.sessions.setTabValue()`](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/sessions/setTabValue)と[`browser.sessions.getTabValue()`](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/sessions/getTabValue)を使えば、名前空間をまたいで状態を共有する事もできます。以下はその例です。

```javascript
// バックグラウンドページで実行しておく（tabs, sessionsの権限が必要）

browser.tabs.onUpdated.addListener((aTabId, aChangeInfo, aTab) => {
  // アクティブでないタブのタイトルが変化したら未読にする
  if ('title' in aChangeInfo && !aTab.active)
    browser.sessions.setTabValue(aTabId, 'unread', true);
});

browser.tabs.onActivated.addListener(aActiveInfo => {
  // タブがアクティブになったら既読にする
  browser.sessions.removeTabValue(aActiveInfo.tabId, 'unread');
});

// 上記の処理が動作していれば、以下のようにしてタブの未読状態を取得できる。
// var unread = await browser.sessions.getTabValue(id, 'unread');
```

# タブが複製された物か、復元された物かを判別する

WebExtensionsではタブが開かれた事を[`tabs.onCreated`](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/tabs/onCreated)で捕捉できますが、そのタブが既存のタブを複製した物なのか、閉じられたタブが復元された物なのか、それとも単純に新しく開かれたタブなのか、という情報は`tabs.Tab`からは分かりません。しかし、タブのセッション情報を使えばこれらの3つの状態を判別できます。

## 判別の方法


複製や復元されたタブは、元のタブに[`browser.sessions.setTabValue()`](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/sessions/setTabValue)で設定された情報を引き継ぎます。この性質を使うと、以下の理屈でタブの種類を判別できます。

 1. [`browser.sessions.getTabValue()`](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/sessions/getTabValue)でIDを取得してみて、取得に失敗したら（IDが保存されていなければ）そのタブは新しく開かれたタブである。
 2. IDの取得に成功し、そのIDを持つタブが既に他に存在しているのであれば、そのタブは複製されたタブである。
 3. IDの取得に成功し、そのIDを持つタブが他に存在していないのであれば、そのタブは一旦閉じられた後に復元されたタブである。

（この判別方法にはFirefox 57以降で実装された[`sessions` API](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/sessions)の[`browser.sessions.setTabValue()`](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/sessions/setTabValue)と[`browser.sessions.getTabValue()`](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/sessions/getTabValue)という2つのメソッドが必要となります。そのため、これらが実装される前のバージョンであるFirefox ESR52などではこの方法は使えません。また、これらのメソッドは今のところFirefoxでのみ実装されているため、GoogleChromeやOperaなどでもこの方法を使えないという事になります。）

以上の判別処理を実装すると、以下のようになります。

```javascript
// バックグラウンドページで実行しておく（tabs, sessionsの権限が必要）

// IDからタブを引くためのMap
var gTabByPrivateId = new Map();
// 判別結果を保持するためのMap
var gTabType = new Map();

// 一意なIDを生成する（ここでは単に現在時刻とランダムな数字の組み合わせとした）
function createNewId() {
  return `${Date.now()}-${parseInt(Math.random() * Math.pow(2, 16))}`;
}

// タブの種類を判別する
async function determineTabType(aTabId) {
  // セッション情報に保存した独自のIDを取得する
  var id = await browser.sessions.getTabValue(aTabId, 'id');
  if (!id) {
    // 独自のIDが保存されていなければ、そのタブは一般的な新しいタブであると分かるので
    // 新たにIDを振り出す
    id = createNewId();
    // 振り出したIDをセッション情報に保存する
    await browser.sessions.setTabValue(aTabId, 'id', id);
    // IDでタブを引けるようにする
    gTabByPrivateId.set(id, aTabId);
    return { type: 'new', id };
  }

  // 独自のIDが保存されていれば、そのタブは複製されたタブか復元されたタブということになる

  // そのIDをもつタブが存在するかどうかを調べる
  let existingTabId = gTabByPrivateId.get(id);

  // タブが存在しない場合、このタブは「閉じたタブを開き直す」またはセッションの復元で
  // 開き直されたタブであると分かる
  if (!existingTabId) {
    gTabByPrivateId.set(id, aTabId);
    return { type: 'restored', id };
  }

  // タブが存在していて、それが与えられたタブと同一である場合、
  // この判別用メソッドが2回以上呼ばれたということになる
  if (existingTabId == aTabId)
    throw new Error('cannot detect type of already detected tab!');

  // タブが存在しているが、与えられたタブではない場合、このタブは
  // そのタブを複製したタブであると分かるので、新しいIDを振り出す
  id = createNewId();
  await browser.sessions.setTabValue(aTabId, 'id', id);
  gTabByPrivateId.set(id, aTabId);
  return { type: 'duplicated', id, originalId: existingTabId };
}

browser.tabs.onCreated.addListener(async (aTab) => {
  // 新しく開かれたタブに対する任意の処理
  // ...

  // タブの種類の判別を開始する
  var promisedType = determineTabType(aTab.id);
  // 判別結果を他の箇所からも参照できるようにしておく
  gTabType.set(aTab.id, promisedType);
  var type = await promisedType;

  // 上記判別結果を使った、新しく開かれたタブに対する任意の処理
  // ...
});

browser.tabs.onRemoved.addListener(async (aTabId, aRemoveInfo) => {
  // 削除されたタブに対する任意の処理
  // ...

  // それぞれのMapから閉じられたタブの情報を削除する
  var type = await gTabType.get(aTabId);
  gTabByPrivateId.delete(type.id);
  gTabType.delete(aTabId);
});

// 既に開かれているタブについての初期化
browser.tabs.query({}).then(aTabs => {
  for (let tab of aTabs) {
    gTabType.set(tab, determineTabType(tab.id));
  }
});
```

## 他のイベントも監視する場合の注意点

上記のようにして`tabs.onCreated`でタブの種類を判別してからその他の初期化処理を行う場合、*タブの種類の判別は非同期に行われるため、`tabs.onCreated`のリスナーが処理を終える前に他のイベントのリスナーが呼ばれる事もある*、という点に注意が必要です。`tabs.onUpdated`や`tabs.onActivated`のリスナーが、`tabs.onCreated`で何らかの初期化が行われている事を前提として実装されている場合、上記の判別処理やその他の非同期処理が原因で初期化が終わっていないタブが他のリスナーに処理されてしまうと、予想もしないトラブルが起こる可能性があります。

そのようなトラブルを防ぐためには、以下のようにしてタブの初期化処理の完了を待ってからその他のイベントを処理するようにすると良いでしょう。

```javascript
var gInitializedTabs = new Map();

browser.tabs.onCreated.addListener(async (aTab) => {
  var resolveInitialized;
  gInitializedTabs.set(aTab.id, new Promise((aResolve, aReject) => {
    resolveInitialized = aResolve;
  });

  // 任意の初期化処理
  // ...

  resolveInitialized();
});

// 別のウィンドウから移動されたタブに対してはtabs.onCreatedは発生しないため、
// tabs.onAttachedも監視する必要がある
browser.tabs.onAttached.addListener(async (aTabId, aAttachInfo) => {
  var resolveInitialized;
  gInitializedTabs.set(aTabId, new Promise((aResolve, aReject) => {
    resolveInitialized = aResolve;
  });

  // 任意の初期化処理
  // ...

  resolveInitialized();
});


browser.tabs.onUpdated.addListener(async (aTabId, aChangeInfo, aTab) => {
  await gInitializedTabs.get(aTabId);

  // 以降、タブの状態の更新に対する任意の処理
  // ...
});

browser.tabs.onActivated.addListener(async (aActiveInfo) => {
  await gInitializedTabs.get(aActiveInfo.tabId);

  // 以降、タブのフォーカス移動に対する任意の処理
  // ...
});

// メッセージの処理
browser.runtime.onMessage.addListener((aMessage, aSender) => {
  // この例では、必ずメッセージの`tabId`というプロパティでタブのIDが渡されてくるものと仮定する
  if (aMessage.tabId) {
    let initialized = gInitializedTabs.get(aMessage.tabId);
    if (!initialized)
      initialized = Promise.resolve();
    // async-awaitではなく、Promiseのメソッドで初期化完了を待つ
    // （関数全体をasyncにしてしまうと、このリスナが返した値が必ず
    // メッセージの送出元に返されるようになってしまうため）
    initialized.then(() => {
      // 初期化済みのタブを参照しての何らかの処理
      // ...
    });
  }
  // その他の処理
  // ...
});

// 他のアドオンからのメッセージの処理
browser.runtime.onExternalMessage.addListener((aMessage, aSender) => {
  // ここでもbrowser.runtime.onMessageのリスナーと同じ事を行う
  // ...
});
```

## `tabs.onUpdated`を監視する場合の、Bug 1398272への対策

ここまでの実装例で`tabs.onUpdated`を監視する例を示してきましたが、現時点での最新リリース版であるFirefox 57には、[`tabs.onUpdated`を監視しているとウィンドウをまたいでタブを移動した後にタブのIDの一貫性が損なわれる](https://bugzilla.mozilla.org/show_bug.cgi?id=1398272)（本来であればウィンドウをまたいで移動した後もタブのIDは変わらない事が期待されるのに対し、このBugの影響により、ウィンドウをまたいで移動したタブに意図せず新しいIDが振り出されてしまう）という問題があります。

この問題を回避するには、IDの振り出し状況を監視して対応表を持つ必要があります。単純ではありますが、エッジケースの対応なども考慮に入れると煩雑ですので、[この問題のWorkaroundとして必要な一通りの処理をまとめた`webextensions-lib-tab-id-fixer`というライブラリ](https://github.com/piroor/webextensions-lib-tab-id-fixer)を作成・公開しました。`tabs.onUpdated`を監視する必要があるアドオンを実装する場合には試してみて下さい。


# まとめ

WebExtensions APIで一般的には提供されていないタブの状態の情報について、既存APIの組み合わせで間接的に状態を判別する方法をご紹介しました。

現時点でFirefoxにのみ実装されている[`sessions` API](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/sessions)の機能には、このような意外な応用方法があります。皆さんも、今あるAPIを違った角度から眺めてみると、APIが無いからと諦めていた事について実現の余地が見つかるかもしれませんので、色々試してみる事をお薦めします。