2
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Firefox-WebExtensionsに挑戦

Last updated at Posted at 2019-09-09

Firefox-WebExtensionsとは

拡張機能とは何か?

拡張機能はウェブブラウザーに機能を追加します。標準化されている web 技術(JavaScript / HTML / CSS)に専用の JavaScript API をいくつか加えて書かれます。とりわけ、拡張機能はブラウザーに新しい機能を追加したり、特定のウェブサイトが持つ見た目やコンテンツを変更したりできます。

つまり、自分好みのFirefoxにカスタマイズ出来る仕組みですね。

今回目指す拡張機能

単純なRequestヘッダのインターセプタを作りたいと思います。
もちろんインターセプトしたRequestヘッダは中身を編集して再送信できます。

動機

なぜ挑戦しようとしたのか、
それすなわち「諸々の事情で必要になった+好奇心」ですわな!(まったく説明になってないっていう)
元々、Firefoxのデベロッパツールには「ネットワーク->右クリック+編集して再送信」がありますが、再送信結果をプレビューでは見られますが、ブラウザで見られません(私がしらないだけかも)。そこで、他の方が作成したExtensionを導入しようとしたのですが、どうせやったら勉強のために自分で作ろうと思いました。

作ったもの

あなたもやんごとなき事情により、以下に始まるグダグダ説明をすっ飛ばしてこのExtensionを使いたいという方は、以下のURLに置いてます。
拡張機能のインストールページ
GitHub - Paipu

※ 自己責任で使用してください。悪用も厳禁!!!!!

Paipu更新履歴:

date version comment
9/8 v1.0.1 初回リリース
9/16 v1.0.2 Cookieをパースするようになった!(v1.0.1どこいった)

開始

奇跡的にやろうとしている事のチュートリアルが載っていたので、参考にしました。
HTTP リクエストへの介入
※ただし、「リクエストヘッダの改変」の章で、manifest.jsonは先ほどの例と同じとありますが、先の章の例ではpermissionsに特定のURLを指定しているので、このままだと「The webRequest.addListener filter doesn't overlap with host permissions.」というエラーが自分の環境では発生しました。そこで、permissionsに"<all_urls>"を追加してあげると正常に動作しました。

今回は、リクエストヘッダの改変ができることを目指します。それが出来れば私の諸事情は解決されるので。

1. 構想

paipu_struct.png

  1. UserがWeb pageにアクセス&リクエスト。
  2. リクエストパケットをbackgroundがインターセプト。
  3. リクエストパケットをメインのPaipuページへ送信。
  4. PaipuページでUserがリクエストパケットを編集&送信。
  5. 編集済リクエストパケットがbackgroundへ送り返されるので、backgroundもWeb pageへそのパケットを送り返す。

といった流れになりそうです。

2. 最終的なディレクトリ構造

tree
.
├── background
│   └── background.js
├── icons
│   ├── paipu-128.png
│   ├── paipu-128.svg
│   ├── paipu-32.svg
│   └── paipu-48.svg
├── manifest.json
├── option
│   ├── option.css
│   ├── option.html
│   └── option.js
├── paipu.css
└── paipu.html

3. manifest.json

manifest.json
{
    "description": "Simple request header interceptor.",
    "homepage_url": "https://github.com/BiggieBoo18/paipu",
    "manifest_version": 2,
    "name": "Paipu",
    "version": "1.0",
    "icons": {
	"48": "icons/paipu-48.svg"
    },
    "permissions": [
        "activeTab",
        "storage",
        "webRequest",
        "webRequestBlocking",
        "<all_urls>"
    ],
    "browser_action": {
    	"default_icon": "icons/paipu-48.svg",
    	"default_title": "Paipu"
    },
    "background": {
        "scripts": ["background/background.js"]
    },
    "applications": {
        "gecko": {
            "id": "paipu@biggie.boo",
            "strict_min_version": "60.0"
        }
    },
    "options_ui": {
    	"page": "option/option.html",
    	"browser_style": true
    }
}

まず、拡張機能を作成する上で必須なのが、manifest.jsonです。拡張機能の基本設定をここで記載します。
以下は今回使う設定の説明です。

Name Value
manifest_version 常に2
name 拡張機能名
version 拡張機能のバージョン
description 拡張機能の説明
homepage_url 拡張機能のURL
icons 拡張機能のアイコン
permission 拡張機能で使う権限の許可
browser_action ブラウザのツールバーに関する設定
background backgroundスクリプトに関する設定
applications 拡張機能のIDを設定。Firefox 48以前とAndroid版Firefoxでは必須
options_ui オプションページに関する設定

manifest_version、name、versionの設定は必須です。
permissionは拡張機能を動作させる上で必要な権限を許可します。今回は、タブに関する権限の「activeTab」(今思ったらこれ必要なかったかも)、オプションで設定の保存に使う「storage」、リクエストをごにょごにょするために使う「WebRequest」、リクエストをブロックするために使う「WebRequestBlocking」、最後にすべてのパスに関して拡張機能を動作させるという意味の「<all_urls>」を指定。(参照: permissions)
browser_actionには拡張機能ツールバーに関する設定を行います。今回は、タイトルとアイコンを設定しました。その他にも、ツールバーのpopupページなどが設定可能です。(参照: browser_action)
backgroundはbackgroundスクリプトに関する設定を行います。スクリプトのパスを指定する他にページの指定も行えます。(参照: background)
applicationsは拡張機能のユニークなIDを設定します。通常は不要ですが、Firefox 48以前とAndroid版Firefoxでは必須です。また、今回はstorageを使用するために設定しておきました。(参照: applications)
options_uiはオプションページに関する設定を行います。今回は、オプションページのパスとそのスタイルを設定。(参照: options_ui)

参照: manifest.json

4. Paipu

先にメインのPaipuページを説明します。

paipu.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Paipu</title>
    <link rel="stylesheet" href="paipu.css"/>
  </head>
  <body>
    <h1>Paipu</h1>
    <p>Simple request header interceptor
      <button id="option-button" class="option-button">Option</button>
    </p>
    <hr>
    <div class="buttons">
      <input type="checkbox" id="listen-button" class="listen-button"><label id="listen-text" class="listen-text" for="listen-button">Paipu OFF</label>
      <button id="send-all" class="send-all">Send ALL</button>
    </div>
    <div id="header-table" class="header-table"></div>
    <script src="background/background.js"></script>
  </body>
</html>

このページではインターセプトON/OFFや、オプションページの表示、リクエストヘッダの編集/送信を行います。メインのくせにあんまり説明することがないと気づきました。

5. option

設定ページです。今回は、インターセプトするURLを指定出来るようにしました。

option.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Paipu - option</title>
    <link rel="stylesheet" href="option.css"/>
  </head>
  <body>
    <form id="form-option">
      <div>
      <p>Target URL: </p>
      <input type="text" placeholder="Scheme" title="Scheme" id="url-scheme" class="url-text">
      <label>://</label>
      <input type="text" placeholder="Host" title="Host" id="url-host" class="url-text">
      <label>/</label>
      <input type="text" placeholder="Path" title="Path" id="url-path" class="url-text">
      <p>
        <a href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns">[Help] How to write</a>
      </p>
      </div>
      <button type="submit" class="submit-button">Save</button>
      <p class="saved-message" id="saved-message">Saved !<br>If the paipu window is open, open it again.</p>
    </form>
    <script src="option.js"></script>
  </body>
</html>
option.js
function saveOptions(e) {
    browser.storage.sync.set({
        targetScheme: document.getElementById('url-scheme').value,
        targetHost: document.getElementById('url-host').value,
        targetPath: document.getElementById('url-path').value
    });
    document.getElementById('saved-message').style.visibility = "visible";
    e.preventDefault();
}

function restoreOptions() {
    let targetInfo = browser.storage.sync.get();
    targetInfo.then((res) => {
        document.getElementById('url-scheme').value = res.targetScheme || '';
        document.getElementById('url-host').value = res.targetHost || '';
        document.getElementById('url-path').value = res.targetPath || '';
    });
}

document.addEventListener('DOMContentLoaded', restoreOptions);
document.getElementById('form-option').addEventListener('submit', saveOptions);

ここでは、storageを使って設定したターゲットURL情報を保存します。「save」ボタンを押したときにstorageに保存し、再びオプションページを開いた際にはstorageに保存されている情報を復元して表示します。storageに保存された情報はcontent_scriptsやbackgroundスクリプトなどでも参照出来ます。また、保存するstorageによってデバイス間での共有やローカルのみなどの設定も可能です。
(参照: オプションページ, storage)

6. background

表の支配者がPaipu(見せかけ)ならbackgroundは裏の支配者(モノホン)です。backgroundスクリプトはアクティブタブなどに関係なく持続的に拡張機能を動作させるために使用します。今回は、backgroundスクリプトに頑張ってもらいました。以下、主な部分だけ掲載します。

background(createPaipuWindow)
function onCreated(windowInfo) {
    console.log(`Created window: ${windowInfo.id}`);
    browser.windows.update(windowInfo.id, {height:900, width:801}); // for redraw
    browser.windows.onRemoved.addListener((windowId) => {
        console.log('Closed window: ' + windowId);
        if (windowId===windowInfo.id) {
            window.hasRun = false;
        }
    });
}
function onError(error) {
    console.error(`Error: ${error}`);
}
browser.browserAction.onClicked.addListener(() => {
    if (!window.hasRun&&location.pathname.indexOf('paipu.html')===-1) {
        let paipuURL = browser.extension.getURL('paipu.html');
        let creating = browser.windows.create({
            url: paipuURL,
            type: 'detached_panel',
            height:900,
            width:800
        });
        creating.then(onCreated, onError);
        window.hasRun = true;
    } else {
        console.log('Already created a paipu window');
    }
});

ツールバーがクリックされた時にメインのPaipuページを開きます。window.hasRunなどを使って一つだけ開くようにしています。また、onCreatedの最初の方でheightとwidthを再設定しているのは、なぜか開いた直後は真っ白で要素が表示されないため、再設定して再描画させています。

background(modifyHeaders)
function modifyHeaders(e) {
    console.log('original requestHeaders:');
    console.dir(e.requestHeaders);
    let edited_headers = e.requestHeaders;
    let headerTable    = document.getElementById('header-table');
    if (headerTable) {
        if (tableIds.length) {
            tableIds.push(tableIds[tableIds.length-1]+1);
        } else {
            tableIds.push(0);
        }
        currentIdx = tableIds[tableIds.length-1];
        // create header table
        createHeaderTable(e.requestHeaders, e.url, headerTable, currentIdx);
        let button = document.getElementById(`send-button-${currentIdx}`);
        let asyncModifyHeader = new Promise((resolve, reject) => {
            button.onclick = () => {
                currentIdx = button.id.slice('send-button-'.length);
                edited_headers = getTable(currentIdx);
                console.log('edited headers:');
                console.dir(edited_headers);
                deleteTable(currentIdx);
                resolve({requestHeaders: edited_headers});
            };
        });
        return asyncModifyHeader;
    }
    return {requestHeaders: e.requestHeaders};
}
let listenButton = document.getElementById('listen-button');
if (listenButton) {
    listenButton.addEventListener('change', () => {
        listenText = document.getElementById('listen-text');
        if (listenButton.checked) {
            browser.webRequest.onBeforeSendHeaders.addListener(
                modifyHeaders,
                {urls: [targetPage]},
                ['blocking', 'requestHeaders']
            );
            listenText.textContent = 'Paipu ON';
        } else {
            browser.webRequest.onBeforeSendHeaders.removeListener(modifyHeaders);
            listenText.textContent = 'Paipu OFF';
        }
    });
}

ここでは、リクエストのインターセプトを行い、ヘッダをPaipuページに表示しています。ListenボタンがONの時のみ、インターセプトするようにしています。
browser.webRequest.onBeforeSendHeadersでリクエストヘッダに介入出来ます。

browser.webRequest.onBeforeSendHeaders.addListener(
listener, // function
filter, // object
extraInfoSpec // optional array of strings
)

listenerにはイベント発火時に実行する関数を指定し、filterにはフィルタするURLやtypeなどを指定、extraInfoSpecには"blocking"や"requestHeaders"を指定。blockingはリクエストをブロックしたい場合に指定し、requestHeadersはlistner関数に渡すdetailsオブジェクトにリクエストヘッダを含めたい場合に指定します。(参照: onBeforeSendHeaders)

※今回できれば、リクエストURLやリクエストボディも編集できるようにしたかったのですが、見た感じリクエストヘッダとは別のイベントっぽいので、今後の課題として今回はリクエストヘッダのみにしています。参照: webRequest

リクエストに介入した後は、テーブル番号を保存しておき、テーブルを作成し、Paipuページへ表示しています。そして、Paipuページでの編集が終えて編集済パケットを受け取ったら、そのパケットを返してリクエスト処理を再開させます。余談ですが、実はこの部分がキモなだけに一番苦労しました。当初はcontent_scriptsとbackgroundで相互にリクエストパケットのやりとりをして実現させようとしていましたが、content_scriptsとbackgroundでの変数の共有の必要性やcontent_scriptsでリクエスト編集が終えるまでリクエストのブロックが出来なったことなどが原因でこの構成は諦めました。

デバッグ

作成中の拡張機能をFirefoxに一時的に読み込ませて動作確認やデバッグなどが出来る仕組みが用意されています。(参照: Firefoxへの一時的なインストール

公開

作成した拡張機能を公開するには、以下の流れで行います。

  1. アドオンに署名
  2. AMOへ登録または自分で配布

アドオンに署名してもらうことで、一般に公開し、インストールしてもらうことが出来ます。
書名の方法は、

  • Developer Hub on AMOからアドオンをアップロードする
  • addons.mozilla.org 署名 APIを使う
  • web-ext sign を使う

私は以下のような流れでDeveloper Hub on AMOを使用しました。

  1. AMOにアクセス
  2. ユーザ登録
  3. 右上のメニューから「開発センター」をクリック
  4. 「新しいアドオンを登録」をクリック
  5. 配布方法を選択(以降は「当サイト上で。」を選択した場合)
  6. アドオンをアップロードし(この時アドオンをxpiにしておく(参考: 拡張機能をパッケージ化する)、自動検証を実施
  7. アドオンに関する説明などを記載して、審査を実施
  8. 承認されれば公開

終了ー

拡張機能を作る上でのチュートリアルやサンプルが豊富なおかげで(私が活かせたかどうかは別)、私のようなド素人でも何とか欲しいものを作ることが出来ました。この拡張機能は発展途上でまだまだ実現できることはありそうです(例えばレスポンスの編集など)。
また、拡張機能は思っていたよりずっといろんなことが出来そうです。私も今度クリップボードに関する拡張機能を作成したいなーと思っています。
皆さんもぜひ何か作ってみてください。

2
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?