初めてのアドオン作成記

2022-03-17 14:49:26
随時追記

今回初めてFireFoxのアドオンを作ってみました。熱が冷め止まぬうちに手続き等を記録しておきたいと思います。内容はあくまで忘備録程度のモノで、間違っている可能性が高いです。

チュートリアル

まずはMozillaが公開している拡張機能のチュートリアルをなぞることから始めました。最初のチュートリアルの中身はとてもシンプルなもので、ページを開いたときに裏でjavascriptを走らせるものでした。シンプルとは言っても、これを使えばページに内容を追加/削除したりできるので、機能面では十分なことができます。
その後に次のチュートリアルをやってみました。こちらはツールバーのポップアップ等を組み込んだ、実用的なチュートリアルでした。正直こちらから始めても良かったかもしれません。

ファイルの構造

アドオンの実態は、実際に動作するJavascriptファイルと、仕様などを定めるmanifest.jsonです。
manifest.json
content/
  content.js
popup/
  popup.html
  popup.js
option/
  option.html
  option.js
background/
  background.js
icons/
  icon.png
フォルダやファイルの名前に多少の際はあれど、おおよそこのような構造になっています。
  1. content.jsは開いているwebサイト中で実行されます。そのためcontent.jsではページ内の要素を取得できます。
  2. popup.htmlはツールバーに表示されるアイコンをクリックしたときに表示されるポップアップメニューの中身です。popup.jsではポップアップメニューの状態にアクセスできます。
  3. option.htmlはアドオンの設定に表示されるオプションメニューの中身です。option.jsではオプションメニューの状態にアクセスできます。
  4. background.jsは少し特殊で、ブラウザを立ち上げている間常に実行され続けます。
これらのスクリプトは、sendMessage()onMessage.addListener()などの関数を用いて互いに通信できます。そのため、「popup.jsでポップアップメニューのボタンクリックを感知し、content.jsに送信して、content.js内でwebサイトの中身を書き換える」といった使い方ができます。直接getBackgroundPage()などで取得することもできるようです。

Tips寄せ集め

content.jsの読み込ませ方

manifest.json内で指定する、contentScript APIを使う、tabs.executeScript()を使う

downloads API

"permissions": ["download"]をmanifest.jsonに追加して使う。downloads APIはcontent.js内では使えない。background.jsでは使える。

通知の作り方

"permissions": ["notifications"]が必要。

function createNotification(message) {
  browser.notifications.create({
    type: "basic",
    title: "title",
    message: message,
  });
}

storage API

"permissions": ["storage"]が必要。データの追加と読み込みは非同期処理になる。

セット:

browser.storage.local.set({
  test_text: "hoge",
});

ゲット:

let result;
browser.storage.local
.get()
.then((restoredSettings) = > {
  result = restoredSettings.test_text;
  console.log(result)
})
.catch((e) => {
  console.error("Failed : " + e.message);
});

Promise

ドキュメント読め。

その関数の処理が終わったらthenに進む。

function checkAndDownload(url){
  fetch(url).headers.get("Content-Type").then((response) => console.log(response));
}

右クリックメニューの作り方

"permissions": ["notifications"]が必要。

browser.menus.create({
  id: "abcdef",
  title: "メニューに出る文字",
  contexts: ["all"],
});
browser.menus.onClicked.addListener((info, tab) => {
  func();
}) 
    

Could not establish connection

送信先でonMessage.addListener()が設定されていないかもしれない。content.jsを本当に実行できているか確認。

insertAdjacentHTMLは良くない?

なんか警告が出るので、代わりにdocument.createElement()してappendChildを使う

Hostなんちゃらみたいなエラー

"permissions": ["<all_urls>", "activeTab"]を追加。

Promise.all + map

並列処理ではない(重要)が、forとかで一つずつやるより遥かに速い。

async function func() {
  let array = new Array();
  const result = await Promise.all(
    array.map(async (item, index, all) => {
    // なんか同期を待つ処理
    const fetch_result = await fetch(item.url).catch(() => new Response());
    let new_item = func2(fetch_result)
    return new_item
    }))
}
    

executeScriptでのファイル位置

"/"から始めればルートから、そうでなければ相対パス。

HTTPメソッド

リンク先が画像かどうかの判定を、「GET送ってレスポンスのヘッダーを見る」と実装していた。後でHEADメソッドとかいうヘッダーだけ持ってくるものがあることを知った。というかGETとPOSTしかないと思っていた。アホ。

アドオン開発者センター

新しいバージョンのアップロード手続き中にキャンセルすると、そのバージョンが無効化されてしかも有効化できなくなるっぽい。

文字列の正規表現化

文字列を正規表現扱いするには、RegExp()を使う。

let re = new RegExp("ab+c", "g");

fetch でタイムアウトさせる

というかそもそも何で用意されてないんですかね?

事前にAbortController()を作っておき、fetchの前にsetTimeoutで一定時間後にAbortController.abort()が呼ばれるようにしておく。そしてfetchの引数でsignal:AbortController.signalを指定すれば、一定時間後にfetchがキャンセルされる。うまく行った場合のために、fetch後にsetTimeoutは消しておく。

const controller = new AbortController();
const timeout = window.setTimeout(() => {
  controller.abort();
  console.log(`Connection to ${url} timed out.`)
}, 10000);
const fetch_result = await fetch(url, {
  method: "HEAD",
  signal: controller.signal,
}).catch(() => new Response());
window.clearTimeout(timeout);

frame(iframe)内の要素へアクセス

フレーム内のHTMLドキュメントはcontentDocumentで取得できるが、同一起源ポリシーにより外部ドメインサイトへのアクセスはできない。そのようなときcontentDocumentが例外を起こすのかnullを返すのかまちまちで分からん。ネットでは例外と書いてあったが、実際に動かすとnullだったりする。取り合えず外部サイトへのアクセスが予想されるときはtry/catchとnullチェック。

let iframe_elements = document.getElementsByTagName("iframe");
for (let j = 0; j < iframe_elements.length; j++) {
  try {
    var iframe_document = iframe_elements[j].contentDocument;
  } catch {
    continue;
  }
  // iframe_documentを使った処理
}

全てのURLにマッチ

manifest.json内のcontent_scriptsなどで全てのURLを指定したいときに"*://*"などとしても動かない。"<all_urls>"を使う。

動かないとき

web-ext runで開発中に動かなくなったら、他のタブで例外とかで停止していないか確認。デバッガーとかをすべて閉じれば動くかも。

SelectionとRange

こちらのドキュメントが非常に分かりやすい。Rangeは始点と終点を持つ、ノードの集まり。Selectionは複数のRange(とはいうもののFirefox以外は一つしか持たないらしい)を持ち、getRangeAt(i)でアクセスできる。具体的にRangeに触るには、その後にcloneContents()をしてHTMLフラグメントとして取り出す。下は選択範囲のimgタグを全て抜き取る例。

let selection = window.getSelection();
let ranges = new Array();
for (let i = 0; i < selection.rangeCount; i++) {
  ranges.push(selection.getRangeAt(i).cloneContents());
}

//基本的にrangeは要素一つ。Firefoxだけ複数のrangeをサポートしている
for (let index = 0; index < ranges.length; index++) {
  const fragment = ranges[index];

  let elements_from_fragment = fragment.querySelectorAll("img");
  for (let j = 0; j < elements_from_fragment.length; j++) {
    const element = elements_from_fragment[j];
    //あれやこれや
  }
}

タブが読み込み中か判定

onloadとかではなく、スクリプト実行時に読み込み中なら即returnみたいな使い方をしたいときに。tab.statusが使える。ただし現在のタブを取得するtabs.getCurrent()は罠で、backgroundでは使えない。browser.tabs.query({ active: true, currentWindow: true })などで取得する。

function func(tab) {
  if (tab.status === "complete") {
    //処理
  } else {
    setTimeout(() => {
      func(tab);
    }, 100);
  }
}

runtime.sendMessageとtabs.sendMessage

content scriptに送るときはtabs.sendMessageじゃないとダメらしい。