Chrome の拡張機能を Manifest V3 に対応する

はじめに

Chromeの拡張機能を作成する」で作成した Manifest V2 用の拡張機能を Manifest V3 用に変更します。

開発から既に数年経過しているため、最終的なコードからはかけ離れていますが、 Manifest V3 用のサンプルとしての位置付けでこの記事は作成されています。

※CopyTabTitleUrl の最新コードは、次のサイトで確認できます。
 (最新コードはまだ Manifest V3 には対応していません)
 k08045kk/CopyTabTitleUrl - GitHub

コード

新バージョン(オフスクリーン方式 Chrome109+)

manifest.js{
  "manifest_version": 3,
  "name": "TinyCopyTabTitleUrl",
  "description": "コンテキストメニューを追加して、クリップボードへタイトルとURLをコピーします。",
  "version": "3.0.2",

  "background": {
    "service_worker": "background.js"
  },

  "permissions": [
    "contextMenus",
    "clipboardWrite",
    "offscreen"
  ]
}

background.js// Chrome 109+
// see https://developer.chrome.com/docs/extensions/reference/offscreen/
// see #example-maintaining-the-lifecycle-of-an-offscreen-document
let creatingOffscreenDocument = null;
const hasOffscreenDocument = async (path) => {
  const offscreenUrl = chrome.runtime.getURL(path);
  if (chrome.runtime.getContexts) {
    // Chrome 116+
    const contexts = await chrome.runtime.getContexts({
      contextTypes: ['OFFSCREEN_DOCUMENT'],
      documentUrls: [offscreenUrl],
    });
    return !!contexts.length;
  } else {
    // Chrome 109-115
    // see #before-chrome-116-check-if-an-offscreen-document-is-open
    for (const client of await clients.matchAll()) {
      if (client.url === offscreenUrl) {
        return true;
      }
    }
    return false;
  }
};
const setupOffscreenDocument = async (path) => {
  // 備考:オフスクリーンドキュメントは、1つしか開けない。
  //    そのため、正常にクローズされなければならない。
  //    別パスのオフスクリーンドキュメントが生存していてはならない。

  if (!(await hasOffscreenDocument(path))) {
    if (creatingOffscreenDocument) {
      await creatingOffscreenDocument;
    } else {
      creatingOffscreenDocument = chrome.offscreen.createDocument({
        url: path,
        reasons: ['CLIPBOARD'],
        justification: 'Used for writing to the clipboard.',
      });
      await creatingOffscreenDocument;
      creatingOffscreenDocument = null;
    }
  }
};

const copyToClipboard = async (tab, text) => {
  // オフスクリーン方式(Chrome 109+)
  await setupOffscreenDocument('offscreen.html');
  await chrome.runtime.sendMessage({
    target: 'offscreen',
    type: 'clipboardWrite',
    text: text,
  });
  await chrome.offscreen.closeDocument();

  // インジェクションファンクション方式
//  const injectedFunction = async function(text) {
//    try {
//      await navigator.clipboard.writeText(text);
//      //console.log('successfully');
//    } catch (e) {
//      //console.log('failed', e);
//    }
//  }
//  chrome.scripting.executeScript({
//    target: {tabId: tab.id},
//    func: injectedFunction,
//    args: [text]
//  });
};

const updateContextMenus = async () => {
  await chrome.contextMenus.removeAll();

  chrome.contextMenus.create({
      id: "context-copytab-title-url",
      title: "タブのタイトルとURLをコピー",
      contexts: ["all"]
  });
  chrome.contextMenus.create({
      id: "context-copytab-title",
      title: "タブのタイトルをコピー",
      contexts: ["all"]
  });
  chrome.contextMenus.create({
      id: "context-copytab-url",
      title: "タブのURLをコピー",
      contexts: ["all"]
  });
};

chrome.runtime.onInstalled.addListener(updateContextMenus);
chrome.runtime.onStartup.addListener(updateContextMenus);
chrome.contextMenus.onClicked.addListener((info, tab) => {
  switch (info.menuItemId) {
  case 'context-copytab-title-url':
    copyToClipboard(tab, tab.title+'\n'+tab.url);
    break;
  case 'context-copytab-title':
    copyToClipboard(tab, tab.title);
    break;
  case 'context-copytab-url':
    copyToClipboard(tab, tab.url);
    break;
  }
});

offscreen.html<!DOCTYPE html>
<script src="offscreen.js"></script>

offscreen.jschrome.runtime.onMessage.addListener(async (data, sender) => {
  if (data.target === 'offscreen' && data.type === 'clipboardWrite') {
    const text = data.text;
/**/// a. document.execCommand('copy') ----------------------------------------
    document.addEventListener('copy', () => {
      event.preventDefault();
      event.stopImmediatePropagation();
      event.clipboardData.setData('text/plain', text);
    }, {capture:true, once:true});
    document.execCommand('copy');
/** // b. navigator.clipboard.writeText ---------------------------------------
    // 次のエラーが発生するため、この方法は使用できません。
    // 「DOMException: Document is not focused.」
    try {
      await navigator.clipboard.writeText(text);
      //console.log('successfully');
    } catch (e) {
      //console.log('failed', e);
    }
/**/// ------------------------------------------------------------------------
  }
});

旧バージョン(インジェクションファンクション方式)

manifest.js{
  "manifest_version": 3,
  "name": "CopyTabTitleUrl",
  "description": "コンテキストメニューを追加して、クリップボードへタイトルとURLをコピーします。",
  "version": "3.0.1",

  "background": {
    "service_worker": "background.js"
  },

  "permissions": [
    "activeTab",
    "contextMenus",
    "clipboardWrite",
    "scripting",
    "tabs"
  ]
}

background.jsconst copyToClipboard = (tab, text) => {
  function injectedFunction(text) {
    try {
      navigator.clipboard.writeText(text);
      //console.log('successfully');
    } catch (e) {
      //console.log('failed');
    }
  }
  chrome.scripting.executeScript({
    target: {tabId: tab.id},
    func: injectedFunction,
    args: [text]
  });
};

const updateContextMenus = async () => {
  await chrome.contextMenus.removeAll();

  chrome.contextMenus.create({
      id: "context-copytab-title-url",
      title: "タブのタイトルとURLをコピー",
      contexts: ["all"]
  });
  chrome.contextMenus.create({
      id: "context-copytab-title",
      title: "タブのタイトルをコピー",
      contexts: ["all"]
  });
  chrome.contextMenus.create({
      id: "context-copytab-url",
      title: "タブのURLをコピー",
      contexts: ["all"]
  });
};

chrome.runtime.onInstalled.addListener(updateContextMenus);
chrome.runtime.onStartup.addListener(updateContextMenus);
chrome.contextMenus.onClicked.addListener((info, tab) => {
  switch (info.menuItemId) {
  case 'context-copytab-title-url':
    copyToClipboard(tab, tab.title+'\n'+tab.url);
    break;
  case 'context-copytab-title':
    copyToClipboard(tab, tab.title);
    break;
  case 'context-copytab-url':
    copyToClipboard(tab, tab.url);
    break;
  }
});

コードの補足

chrome.runtime.onInstalled / chrome.runtime.onStartup

元々、コンテキストメニューの登録処理は、バックグラウンドページに直接記入していました。ですが、バックグラウンドが ServiceWorker となった関係でバックグラウンドの存続期間に大きな変更がありました。

バックグラウンドの存続期間は、 Manifest V2 であればブラウザが開いてから閉じるまでです。ですが、 Manifest V3 では数分程度処理がない場合、バックグラウンドの処理は停止してしまいます。再開する場合、バックグラウンド処理を再度実施します。

Manifest V3 でもコンテキストメニューの登録処理を直接記述することはできます。ですが、バックグラウンド処理の実行毎に実行されるのはあまり気持ちが良いものではありません。そのため、chrome.runtime.onInstalled / chrome.runtime.onStartupで特定のタイミング(インストール時・更新時・起動時)に実行しています。

備考
この処理は、有効無効時に問題を抱えています。
次の記事で問題の回避策について考察します。

chrome.offscreen

Chrome109 で実装された新機能です。 Service Worker からdocumentにアクセスするためにオフスクリーンドキュメントを作成して、 Service Worker から DOM API を使用できるようにします。

ただし、オフスクリーンであるため、フォーカスが取得できないようです。そのため、navigator.clipboard.writeText()が使用できないようです。なので、document.execCommand('copy')を使用する必要があります。

オフスクリーンは、 Service Worker 同様に残存期間があります。そのため、オフスクリーンへアクセス毎にオフスクリーンのセットアップを実施する必要があります。セットアップ後、chrome.runtime.sendMessageでバックグランドからオフスクリーンへ通信してクリップボードコピーを実行しています。また、オフスクリーンは1つしか使用できません。そのため、複数のオフスクリーンを使用する場合、速やかにクローズする必要があります。1つしか使用しない場合、勝手にクローズするまで放置しても良いかもしれません。再オープンの時間を(メモリ容量と引き換えに)節約できるかもしれません。

chrome.offscreen - Chrome Developers

chrome.scripting.executeScript

旧バージョン(インジェクションファンクション方式)のコードです。

ServiceWorker には、documentがありません。そのため、古いクリップボードコピーの方式(document.execCommand("copy"))が利用できません。

新しいクリップボードコピーの方式(navigator.clipboard.writeText())も ServiceWorker 上からはアクセスできません。

拡張機能の方式(chrome.clipboard)は、テキストのコピーに対応していません。

上記の通り Manifest V3 でのクリップボードコピーは、現状八方塞がりです。ですが、アクティブタグにスクリプトを挿入することでこの問題を回避しています。ただし、システム系のタブ(chrome://)などで機能しないなど問題も多くあります。

別解として、新しいタブを開いてスクリプトを挿入する方法があります。より安全にスクリプトを注入できます。ただし、タブのオープン・クローズによる画面のちらつきがあります。

※正式な実装時には、要検討対象です。
Content scripts - Chrome Developers

その他の Manifest V3 の問題

chrome.i18n.getMessage が ServiceWorker で使用できない

chrome.i18n.getMessage がバックグラウンド上の ServiceWorker からアクセスできません。

この問題は、アクションボタンのラベルやコンテキストメニューのラベルの国際化に影響します。

この問題は、まだ解決していませんが、今後解決する可能性がああります。(現在Chrome98)

Chrome103 で問題が解決したようです。

関連記事

  1. Firefox userChrome.js用ユーザスクリプトを作成する
  2. Firefox用WebExtensions拡張機能を作成する
  3. Chromeの拡張機能を作成する