Help us understand the problem. What is going on with this article?

WebExtensionsベースのアドオンが他のアドオンと連携するにはどうするのが良いか?

この記事は自サイトとのクロスポストです

ツリー型タブXULからWebExtensionsに移植した時の話で、アドオン同士の連携が取りづらくなる事への懸念について書きました。この点について現時点での知見をまとめておきます。

ケース1: リクエスト/レスポンス型API

TSTは他のアドオン向けにAPIを提供しています。APIは browser.runtime.sendMessage()経由で以下のように呼ばれます:

const kTST_ID = 'treestyletab@piro.sakura.ne.jp';
browser.runtime.sendMessage(kTST_ID, {
  type: 'expand-tree',
  tab:  2 // Tab.id
});

この例では、指定されたタブの配下に属する折り畳まれたツリーが展開されます。このような「リクエスト/レスポンス」型のAPIは実装が非常に容易です。受信側のアドオン(この例ではツリー型タブ)は単に、以下のようにbrowser.runtime.onMessageExternalのリスナーを登録するだけでOKです:

browser.runtime.onMessageExternal.addListener((message, sender) => {
  if (!message || !message.type)
    return; // 不正なAPI呼び出しは無視

  switch (message.type) {
    case 'expand-tree':
      // 指定されたツリーを展開するコードをここに書く
      // 必要であれば、処理の完了後に解決されるPromiseを返す事もできる。
      return Promise.resolve(true);
  }
});

この時、問題が1つあります。受信側のアドオンがインストールされていない・無効化されている状況では、「Error: Could not establish connection. Receiving end does not exist.」のようなエラーになるという点です。

解決策1: 他のアドオンとの連携の有効・無効を切り替える設定を設ける

解決策の一つは、このようなAPI呼び出しを伴う他アドオンとの連携の有効・無効を切り替えるオプションを提供するという事です。storage.localを使った例としては以下のような形になります:

let enableIntegration = false;
browser.storage.local.get({ enableIntegration })
  .then(values => {
    enableIntegration = values.enableIntegration;
  });

function someFeature() {
  // ...
  if (enableIntegration)
    browser.runtime.sendMessage(kSERVER_ID, { ... });
}

しかし、この方法はまた、デフォルト設定をどのようにするべきか、有効と無効のどちらが望ましいのか、という別の悩みが生じます。初期状態で有効であれば、ユーザーは引き続きデバッグコンソール上で謎のエラーを目にし続ける事になります。初期状態で無効であれば、ユーザーはアドオン同士を連携させるためには設定を手動で有効化しなくてはならず、中にはそのようなオプションの存在にすら気付かない人もいるでしょう。

解決策2: WebExtensionsのmanagementAPIを使って初期化する

「クライアント」となるアドオンは「サーバー」となるアドオンがインストールされているかどうかを browser.management.getAll()というAPIを使って知る事ができます。これを使えば前述のようなオプションを提供する必要はなく、「サーバー」となるアドオンが利用可能な時にだけ機能を有効化できるはずです:

let serverIsActive = false;
browser.management.getAll().then(items => {
  for (const item of items) {
    if (item.id == kSERVER_ID && item.enabled) {
      serverIsActive  = true;
      break;
    }
  }
});

function someFeature() {
  // ...
  if (serverIsActive)
    browser.runtime.sendMessage(kSERVER_ID, { ... });
}

しかしながら、このAPIを使うにはmanifest.jsonのパーミッション一覧にmanagementを追加しなくてはならず、しかもこの権限はoptional_permissionsには置く事ができませんので、アドオンのインストール時の確認には新たに「拡張機能の使用状況の監視とテーマの管理」という行が表示されるようになります。多くのユーザーは1つのアドオンが無用に多くの権限を要求する事を嫌うものなので、この点は前述の解決策に比べての欠点と言えます。

解決策3: 単純にエラーを無視する

前述の解決策2つはどちらにも懸念が残ります。そのため僕は大抵の場合何もしない事にしています。つまり、何の確認もせず「サーバー」のアドオンにメッセージを送りつける事が多いです。それらのメッセージは「サーバー」のアドオンが幸運にも有効であれば処理され、そうでなければ単に無視されます。一部の開発者はデバッグコンソール上にエラーが出力される様子を見る事になりますが、ほとんどのユーザーはそれに気付く事すらありません。

もちろん、以下のようなエラーハンドラを使ってその手のエラーを「黙らせる」事もできます。

function handleMissingReceiverError(error) {
  if (!error ||
      !error.message ||
      error.message.indexOf('Could not establish connection. Receiving end does not exist.') == -1)
    throw error;
}

browser.runtime.sendMessage(kSERVER_ID, { ... })
  .catch(handleMissingReceiverError);

これは最も安直な方法と言えます。

また、開発者向けには連携を無効にするオプションを提供する場合もあります。これは、意図的に連携機能を無効化した人に対しては、連携機能の存在自体を再び知らせる必要は無いと考えているからです。

ケース2: 純粋なブロードキャスト……はWebExtensionsでは不可能

リクエスト/レスポンス型APIとは別の種類のAPIとして、「ブロードキャスト」型と「Pub/Sub(Publish/Subscribe)」型のAPIという物もあります。

非常に残念な事に、WebExtensionsベースのアドオンではWebExtensions APIの制限のため、未知のアドオンも含めたほかのアドオンに対してメッセージを「ブロードキャスト」する事はできません。browser.runtime.sendMessage()は任意のメッセージを他のアドオンに送れますが、そのためには受信者となるアドオンの正確なIDを指定する必要があります。そのため受信者となるアドオンは送信者になるアドオンに対して自身のIDを伝えなくてはなりません。このようなAPIモデルが「Pub/Sub(Publish/Subscribe)」型です。

タブやブックマークに関してはいくつかの限定的な情報はアドオン間で共有されるため、ブロードキャストのように使う事もできます。例えば、Firefox 63以降ではbrowser.tabs.highlight({ windowId: <id>, tabs: <タブのインデックス> })またはbrowser.tabs.update(<id>, { highlight: <真偽値> }) を使う事でタブの複数選択の状態を変更でき、変更された状態はbrowser.tabs.onHighlighted.addListener()で登録されたリスナに通知されます。これにより、タブの複数選択に関連するアドオン同士は容易に連携し合う事ができます。しかしこういったAPIは非常に限られた用途のために用意されており、一般的な情報を共有するのには使えません。

browser.storage.onChanged.addListener()はどうでしょうか? これも残念ながら、この目的では使えません。ストレージ系のAPIはアドオンごとに完全に分離されており、他のアドオンでのデータの保存は一切通知されません。browser.sessions.setTabValue()/getTabValue()/setWindowValue()/getWindowValue()も同様です。ウィンドウやタブに紐付けられた情報は他のアドオンからはアクセスする事ができません。

結論として、「サーバー」アドオンから「クライアント」アドオン向けに何らかの任意の情報を通知するAPIを実装したければ、Pub/Subモデルで実装しなくてはならないという事が言えます

ケース3: WebExtensionsアドオンでのPub/Subによる通信

とはいえ、難しく考える必要はありません。Pub/Sub型のAPIはリクエスト/レスポンス型APIと同じ技術で作る事ができます。「購読開始(サブスクライブ)」(と「購読解除(アンサブスクライブ)」)用のAPIは、以下のような単純なリクエスト/レスポンス型APIとして実装されます:

const subscribers = new Set();

browser.runtime.onMessageExternal.addListener((message, sender) => {
  if (!message || !message.type)
    return; // 不正なAPI呼び出しは無視

  switch (message.type) {
    case 'subscribe':
      subscribers.add(sender.id);
      return Promise.resolve(true);

    case 'unsubscribe':
      subscribers.delete(sender.id);
      return Promise.resolve(true);
   }
});

その上で、「購読者」であるアドオンに対して以下のようにしてメッセージを送るだけです:

async function doSomething() {
  // ... 何らかの処理 ...
  const message = { type: 'published', ... }; // 通知されるメッセージ
  await Promise.all(
    Array.from(subscribers)
      .map(id => browser.runtime.sendMessage(id, message))
  );
}

クライアントとなるアドオンは以下のようにして「購読開始」し、通知されたメッセージを受け取る事になります:

browser.runtime.sendMessage(kSERVER_ID, {
  type: 'subscribe'
});
browser.runtime.onMessageExternal.addListener((message, sender) => {
  if (sender.id != kSERVER_ID)
    return;    
  switch (message.type) {
    case 'published':
      // 通知されたメッセージに対する処理
  }
});

ただし、ここに1つ大きな問題があります。サーバーとなるアドオンがメッセージの待ち受けを始める前にクライアント側アドオンが「購読開始」のメッセージを送った場合、そのAPI呼び出しは失敗してしまうという点です。

(2つのアドオンがメッセージを介して連携する様子のシーケンス図)
(この図はツリー型タブのXULからWebExtensionsへの移行時の記事からの引用です。)

これは以下のような場面で発生します:

  • クライアント側アドオンより後にサーバー側アドオンがインストールされた。
  • サーバー側アドオンとクライアント側アドオンの両方がインストールされた状態でFirefoxが起動し、クライアント側アドオンが先に初期化され、サーバー側アドオンが後から読み込まれた。

解決策1: クライアント側から定期的に「購読開始」を再試行する

これは最も安直な方法です。

const subscribeTimer = setInterval(async () => {
  try {
    const response = await browser.runtime.sendMessage(kSERVER_ID, {
      type: 'subscribe'
    });
    if (response) // サーバーは「購読開始」のリクエストに「true」を返すものと想定
      clearInterval(subscribeTimer);
  }
  catch(e) {
  }
  // レスポンスが得られなかったか何らかのエラーが発生した場合、時間を置いてやり直す
}, 5000);

しかし、これはあまり良い方法ではありません。というのも、サーバー側アドオンがインストールされていなかったり有効化されていなかったりする場合に無限に「購読開始」のメッセージが送られ続けてしまうからです。

解決策2: 「購読開始のリクエストを受け付ける準備ができた」という事をサーバー側からクライアント側に通知する

これは実際にツリー型タブが取っている方法です。ツリー型タブは購読者のIDをそれ自身のストレージにキャッシュしており、ツリー型タブの初期化処理の完了後にreadyというメッセージをそれらに対して送るようになっています。購読者側は以下のように、readyメッセージを監視しておき、メッセージが通知された後に「購読開始」の処理を行えばよいという訳です:

(キャッシュされた情報に基づいて初期化が成功する様子のシーケンス図)
(この図はツリー型タブのXULからWebExtensionsへの移行時の記事からの引用です。)

以下は、サーバー側アドオンからreadyが通知された事をトリガーとして、クライアント側アドオンで「購読開始」を行う例です:

// 読み込み完了時に1回「購読開始」を試行する。
// この処理は、サーバー側アドオンが既に有効な場合には成功する。
browser.runtime.sendMessage(kSERVER_ID, {
  type: 'subscribe'
});
// `ready`をトリガーとした購読開始。
// サーバー側アドオンが再読み込みされた時に再購読しないと
// いけないので、この処理が必要となる。
browser.runtime.onMessageExternal.addListener((message, sender) => {
  if (sender.id != kSERVER_ID)
    return;    
  switch (message.type) {
    case 'ready':
      browser.runtime.sendMessage(kSERVER_ID, {
        type: 'subscribe'
      });
      break;
  }
});

これは先の例よりはマシですが、依然として若干の問題があります。

  • サーバー側アドオンがインストールされていつつもまだ初期化されていない、という時に、この処理は失敗する恐れがあります。
    • この問題は、他のアドオンとの連携を無効化するオプションや、management APIベースでの初期化メカニズムによって解消する事もできます。
  • サーバー側アドオンがクライアント側アドオンよりも後にインストールされた場合、クライアント側のアドオンはサーバーからのreadyメッセージを受け取る事ができません。この場合、クライアント側アドオンを手動で再読み込みする必要があります。
  • 初期化のためのコードの規模が大きくなってしまいます。

解決策3: 中央サーバーを介してAPIの依存性情報を提供する

サーバーとなるアドオンがクライアントなりうるアドオンのリストを知る事ができたなら、サーバー側アドオンがreadyのメッセージをすべてのクライアントとなりうるアドオンに自発的に送る事ができるはずです。さて、一体誰がそのようなリストを提供してくれるのでしょうか? 今のところ、それをしてくれる人はいません。我々アドオン作者はnpmjs.comやcpan.orgのようなリポジトリのプロジェクトを始める必要があります。そういう物があれば、アドオン作者は各アドオンがAPIの情報をリポジトリに登録しておき、そのリポジトリを通じて得た情報に基づいてアドオン同士がより積極的に連携し合う、という事も可能でしょう。

しかしこの案には、そのようなリポジトリが単一障害点となってしまうという問題があります。なので僕は、この案は非現実的だと考えています。

まとめ

「クライアントとなるアドオンはサーバーとなるアドオンに何の書くにも無しにAPI呼び出しのメッセージを送りつけてよいし、そうするべき」「そのような連携機能を無効化するオプションがあると、不要なエラーを目にしたくない開発者にとっては助けになる」これが僕の現状での考えです。

とはいえ、Pub/Sub型APIによるWebExtensionsベースのアドオン同士の連携は非常に複雑ですし、また、完全であるとも言えません。ケース3に対する解決策2(サーバー側からクライアント側に通知のメッセージを送る)は他よりはマシに見えますが、引き続きより良い方法を模索していく必要がありそうです。

piroor
FirefoxサポートエンジニアでTree Style Tab等のFirefoxアドオンを作ったり「シス管系女子」という漫画 https://system-admin-girl.com/ を日経Linux誌で連載したり。名前の読みが同じですが「数学ガール」の結城浩先生とは別人です。
https://piro.sakura.ne.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away