Chrome拡張機能 から IE を開く

ある特定のページ内にある、特定の URL のみ IE で開き直す Chrome拡張機能 がほしくなったので作りました。

動機

普段閲覧するほとんどのサイトは Chrome系ブラウザ で問題ないのですが、ある特定のリンク集ページの特定の URLリンク のみ表示崩れなどが発生してしまうため、そこだけは IE で開き直したい、というようなシチュエーションが発生しました。

開く度にこのトラップに引っかかって、手動で「ここはダメな URL だから IE で開き直して……」という作業は、地味にストレスを蓄積します。そこで、この「IE で開き直す」という虚無の作業を自動化したいと考えました。

コード

動作イメージ

Chrome拡張機能 をインストールした図
Chrome拡張機能 をインストールした図
Chrome拡張機能 の設定画面
Chrome拡張機能 の設定画面

設計

今回必要になる機能は以下。

  • Chrome から IE で開き直す
    • Chrome からコンピュータのローカルリソースにアクセス: ブラウザからブラウザ、というと一見簡単そうに見えますが、 IE も実体としては exeファイル のため、「 Chrome というブラウザのサンドボックスから、どうにかしてホスト(コンピュータ)のネイティブなリソース (exe の実行ファイル) にアクセスする」という課題をくりあする必要があります
    • URL をパラメータとして渡す: IE がただ開くだけでは要求を達成できません。 Chrome でクリックしたリンクの URL を IE に渡し、その URL を IE のタブで開き直す動作が必要です
    • Chrome のタブを閉じる: IE が開けば Chrome で開いたタブは不要なので、自動的に閉じる必要があります
  • 設定画面
    • manifest.json に「特定ページ内で動作」を決め打ちで指定したり、「特定リンクをクリック」の対象 URL をコード中に記述して決め打ちに指定したり、といったことは避けたかったので、個人的には初めて Chrome拡張機能 の設定画面を作る必要に迫られました
  • PowerShell
    • IE を起動するためには、ホスト側のプログラムが必要です。ただ起動する、URLをパラメータとして渡す、までであれば bat でもできなくはなさそうでしたが、不要になった Chrome のタブを閉じるためには Chrome から送信されてきた JSON形式 のメッセージにレスポンスを返さなければなりません。それには、 bat だけでは不十分だと感じたので Powershell で対応することにしました

ディレクトリ構造

  /
  ├ app/                  // Chrome拡張機能 側のコード
  │ ├ icon/
  │ ├ index.css
  │ ├ index.html         // Native messaging を実行するための Chromeタブ
  │ ├ nmancie.js         // index.html で動作する JavaScript
  │ ├ options.css
  │ ├ options.html       // 設定画面
  │ └ options.js
  │
  ├ host/                 // ネイティブ側のコード
  │ ├ register-host.bat  // レジストリに Chrome拡張機能 を登録する bat
  │ ├ revenant.bat       // nmancie.js から Native messaging で呼ばれるプログラム。渡されたメッセージを横流しして nmancie.ps1 をキックする
  │ ├ revenant.json      // register-host.bat で登録される manifest.json (アプリマニフェスト)
  │ └ revenant.ps1       // Native messaging を Chrome拡張機能 側とやり取りしつつ IE をキックするホスト側プログラムのメインコード
  │
  ├ background.js         // Chrome拡張機能 の Content scripts
  ├ manifest.json
  ├ readme.md
  └ transi.js             // Chrome拡張機能 の Background scripts (イベントページ)

実装 (重要な部分のみ)

manifest.json と各プログラムの連携

    "background": {
        "scripts": [
            "transi.js"
        ],
        "persistent": false
    },
    "content_scripts": [
        {
            "matches": [
                "*://*/*"
            ],
            "js": [
                "background.js"
            ]
        }
    ],
    "app": {
        "launch": {
            "local_path": "./app/index.html"
        }
    },
    "options_page": "./app/options.html",
    "permissions": [
        "tabs",
        "activeTab",
        "storage",
        "nativeMessaging"
    ],

今回は必要な機能が多いので、込み入ってます。

まず重要な点として、 Native messaging を始めとする chrome.runtime の一部機能や chrome.tabs といったプロパティ・メソッドは Content scripts (content_scripts) からは操作できません。

そのため、「特定ページのみで動作する」 Content scripts から Background scripts に処理を引き継いであげる必要があります。その際にクリックしたリンクの URL を渡したかったので、 chrome.runtime.sendMessage を使用しました。なお、 chrome.runtime.sendMessage は上述の Native messaging とは異なり、 Content scripts からもアクセスできます。

そこで、 content_scripts は全てのページで background.js が動作する(設定で保存された URL でのみ処理が走るように最初に条件分岐させていますが)ようにしました。

一方、 background で Background scripts として transi.js を指定しています。

transi.jsbackground.js からクリックされた URL を受け取り、 chrome.tabs.create で新しいタブを開きます。開くタブは app/index.html 、つまりこの Chrome拡張機能 の専用ページです。

専用ページをひらくために applaunchlocal_path で開くページを指定しています。この app/index.html から nmancie.js が読み込まれ、 chrome.runtime.sendNativeMessage で Native messaging によりホスト側に処理が引き継がれます。

なお、ホスト側の処理で IE が開いた場合は Native messaging のレスポンスが返却されるので、それを受け取ったら app/index.html のタブを閉じます。

また、設定画面のために options_page の指定もしています。

以上から、 permissions には tabs, activeTab, storage, nativeMessaging を指定しました。

background.js

次に Chrome拡張機能 側のコードを見て行きます。

const bokor = () => {
    let powder = {
        pageUrl: 'example.com',
        actionUrl: [
            'example.jp',
            'example.co.jp',
            'example.or.jp',
            'example.ac.jp',
            'example.go.jp',
            'example.org'
        ]
    };
    // default options
    let defaultPowder = powder;
    defaultPowder.actionUrl = JSON.stringify(powder.actionUrl);
    // get options
    chrome.storage.local.get(
        defaultPowder,
        function (items) {
            if (
                chrome.runtime.error
            ) {
                console.log(`Error: ${chrome.runtime.error}`);
            }
            else {
                powder.pageUrl = items.pageUrl;
                powder.actionUrl = JSON.parse(items.actionUrl);
                // call action with options
                zombie(powder);
            }
        }
    );
};

まず設定読み込みを実施 ( options.js もほぼ同じことをしています) し、成功したら処理の呼び出しをしています。設定読み込み失敗時は chrome.runtime.error を出力しています。

const zombie = (powder) => {
    // 略
    chrome.runtime.sendMessage(
        {
            args: targetLink.href
        },
        function (response) {
        // メッセージリッスンが終わった後に実行
        const msg = `${response.return ? 'done' : 'failed'}: ${response.msg}`;
        console.log(msg);
    });
    //略
};

transi.js 呼び出し部分。上述の通り chrome.runtime.sendMessage でやり取りをしています。引数に args でクリックしたリンクの URL を渡しています。

transi.js

// content script (background.js) からメッセージが渡されて実行
chrome.runtime.onMessage.addListener(
    function(
        request,
        sender,
        sendResponse
    ) {
        try {
            // 新規タブを開き、拡張機能ページを表示 (メッセージで渡された URL を GETパラメータにセット)
            chrome.tabs.create(
                {
                    url: `app/index.html?transi=${request.args}`
                }
            );
            // レスポンス返却
            sendResponse(
                {
                    return: true,
                    msg:    'OK.'
                }
            );
        } catch (error) {
            // レスポンス返却
            sendResponse(
                {
                    return: false,
                    msg:    error
                }
            );
        }
    }
);

chrome.runtime.onMessage.addListenerchrome.runtime.sendMessage によるメッセージが送信されたら、というイベントリスナを登録。

ここから chrome.tabs.create で新しいタブで Chrome拡張機能 専用ページを開くようにしています。上述の通り、このスクリプトは Background scripts なので chrome.tabs.create にアクセス可能です。

また、 sendResponse で元の background script にメッセージを返却しています。

app/nmancie.js

const revenant = () => {
    // 略
    chrome.runtime.sendNativeMessage(
        'transi.nmancie.revenant',
        {
            'args': params.get('transi')
        },
        (responseFromNativeHost) => {
            if (
                chrome.runtime.lastError !== undefined
                && chrome.runtime.lastError !== null
            ) {
                console.error(chrome.runtime.lastError.message);
                msgBox.textContent = chrome.runtime.lastError.message;
            }
            else if (
                responseFromNativeHost !== undefined
                && responseFromNativeHost !== null
                && responseFromNativeHost.message !== 'ok'
            ) {
                console.log(`Error occured: ${responseFromNativeHost.message}`);
                msgBox.textContent = `Error occured: ${responseFromNativeHost.message}`;
            }
            else {
                console.log(`done: ${responseFromNativeHost.message}`);
                msgBox.textContent = `done: ${responseFromNativeHost.message}`;
                // IE を開くことに成功したら、自身のタブを閉じる
                chrome.tabs.getCurrent(function (tab) {
                    chrome.tabs.remove(tab.id);
                });
            }
        }
    );
};

chrome.tabs.create で開いた専用ページで、ホスト側とのやり取りを実装しています。

chrome.runtime.sendNativeMessage がそれです。第一引数がレジストリに登録する名前空間になるので

  • この第一引数
  • アプリマニフェストの name
  • レジストリに登録するキー

の3つを一致させます。

今回は transi.nmancie.revenant ですね。

第二引数はホスト側に送信するメッセージ。今回は URL を指定したいのでそれを args として渡しています。オブジェクトでの指定ですが、実際にはバイト文字列として送信されます。

第三引数はコールバック関数で、引数はホスト側からのレスポンスメッセージです。なので、レスポンスメッセージの内容で条件分岐させます。エラーでないならば、chrome.tabs.getCurrent() で自身の タブID を取得し、その タブID を指定して chrome.tabs.remove() 、つまりタブを閉じています。これで、「 IE を開いた後に不要となったタブを閉じる」の部分を実現しています。

host/register-host.bat

REG ADD "HKCU\Software\Google\Chrome\NativeMessagingHosts\transi.nmancie.revenant" /ve /t REG_SZ /d "%~dp0revenant.json" /f

まずはホストプログラムをレジストリに登録する bat 。上述の通り、キーに transi.nmancie.revenant を指定しています。値はアプリマニフェストのファイルパスですが、 %~dp0 でディレクトリを指定すると自身の位置を返すので、これで「同じディレクトリにある revenant.json」を指定することができます。

レジストリに登録された値
レジストリに登録された値

host/revenant.bat

@echo off

pushd %~dp0

powershell -ExecutionPolicy RemoteSigned -NoLogo -NonInteractive -NoProfile -WindowStyle Hidden -File ".\revenant.ps1"

ホスト側の Powershell をキックする bat 。 pushd %~dp0 で bat が受け取った引数をそのまま実行するファイルに横流しすることができます。今回は .\revenant.ps1 です。

host/revenant.ps1

# 略
try {
    $reader = New-Object System.IO.BinaryReader([System.Console]::OpenStandardInput())
    $len = $reader.ReadInt32()
    $buf = $reader.ReadBytes($len)
    $msg = [System.Text.Encoding]::UTF8.GetString($buf)

    $obj = $msg | ConvertFrom-Json

    $url = $obj.args # 引数をURLに指定
    $shell = New-Object -ComObject Shell.Application
    $ie = New-Object -ComObject InternetExplorer.Application # IE起動
    $ie = $objShell.Windows() | ? {$_.Name -eq "Internet Explorer"} | Select-Object -First 1
    $ie.Visible = $true
    $ie.Navigate($url, 4)
    # 略
}
# 略

最初の $reader で Native messaging のメッセージを受け取ります。なお、このメッセージは

  • JSON でシリアライズされ
  • UTF-8 でエンコードされ
  • メッセージ長を表す 32-bit の値がネイティブのバイト順で先頭に付加

されているので(特に最後)、メッセージから必要な値を取り出すデコード処理が必要です。その過程を経たものが $obj = $msg | ConvertFrom-Json$obj に代入されます。上述のキー args に URL が値としてセットされているため、 $obj.args が開きたい URL になります。

それを IE のオブジェクトに引数で渡すことで、「指定された URL を IE で開く」ことを実現しています ( $ie.Navigate($url, 4) )。

以上がメインの動作となります。

app/options.js

// Saves options to chrome.storage
const disperseSkeleton = () => {
    const pageUrl = document.querySelector('#pageUrl').value;
    const actionUrl = document.querySelector('#actionUrl').value;
    chrome.storage.local.set(
        {
            pageUrl: pageUrl,
            actionUrl: JSON.stringify(actionUrl.split('\n'))
        },
        function() {
            const status = document.querySelector('#c-saveMsg');
            status.textContent = '保存されました';
        }
    );
};

// stored in chrome.storage.
const constructSkeleton = () => {
    const defaultData = {
        pageUrl: 'example.com',
        actionUrl: '["example.jp","example.co.jp","example.or.jp","example.ac.jp","example.go.jp","example.org"]'
    };
    chrome.storage.local.get(
        defaultData,
        function (items) {
            if (
                chrome.runtime.error
            ) {
                console.log(`Error: ${chrome.runtime.error}`);
            }
            else {
                document.querySelector('#pageUrl').value = items.pageUrl;
                document.querySelector('#actionUrl').value = JSON.parse(items.actionUrl).join('\n');
            }
        }
    );
};

const main = () => {
    constructSkeleton();
    const $btnSave = document.querySelector('#c-btnSave');
    $btnSave.addEventListener('click', function () {
        disperseSkeleton();
    });
};

document.addEventListener('DOMContentLoaded', main());

最後にオプションページ(設定画面)。ここは主に2つの動作から構成されています。

  • 設定読み込み: 保存された設定の読み込み。画面表示時に処理が走り、 Chrome のストレージに保存された設定内容を読み込みます
    • 最初にデフォルト値をセットし、 chrome.storage.local.get() で読み込みを実施します。読み込みに成功されれば、画面の入力フォームに値をセットします
  • 設定の保存: 「保存」ボタンクリック時に、オプションページに入力された内容を Chrome のストレージに保存します
    • ボタン押下時に chrome.storage.local.set() で入力フォームにセットされた値を取得、 chrome のストレージに保存します

まとめ

Chrome拡張機能 も掘り下げると奥が深いということがよく分かりました。

特に冒頭で述べたように

  • chrome.runtime の一部機能や chrome.tabs といったプロパティ・メソッドは Content scripts (content_scripts) からは操作できない
  • そもそも Content scripts と Background scripts の違い
  • マニフェストの permission

この辺りについて理解するまでかなり時間を要しました。

また、オプションページも今まで作ったことがなかったので、こちらについても未知の領域でしたが、何とか形にすることができました。

余談: 名前の由来

さて、最後は例によって名前の由来を。

このご時世もはや IE は滅びゆく定めだと思うのですが、そんな死に体の IE をわざわざ呼び起こすなんて、(いわゆるファンタジー世界のアンデッドモンスターを生み出す・操るような) ネクロマンシー(necromancy) 以外の何物でもない、と思います (元々 necromancy は necro(死) + mancy(占い) で口寄せ・降霊術を指していたらしいです)。

アンデッドといえばゾンビやスケルトンが有名どころだと思います。ちなみにレヴナントというのもありますが、これは旧フランス語の「戻る」「再び来る」を意味する revenir に由来する名前とのこと。

さて、先のネクロマンシーをフランス語で綴ると necromancIE 。 IE を呼び起こすにはふさわしい名前だろう、ということでこの綴りを採用しました。

それはそれとして、朽ちていく死体を彫刻に取り込んだ transi というものがあります。これもフランス語由来です。こちらは特にネクロマンシーとは関係なく、美術におけるヴァニタスの一環として生まれた表現技法だそうですが、今回はこれも取り込みました。

あとは先述の通りアンデッドモンスターの名前やそれに関する語句から関数などの名前を採っていきました。

参考

「Chrome拡張機能 からコンピュータのローカルリソース (プログラム) にアクセスする」というアイディア

Native messaging

Native messaging を利用した Chrome拡張機能 のサンプル

Error when communicating with the native messaging host.

chrome.runtime.lastError

Chrome拡張機能 でリンクの URL 一覧を取得する

manifest.json

permission

chrome.runtime

chrome.tabs

タブを閉じる

Chrome拡張機能 の設定画面

Chrome apps の移行期間

PowerShell から IE を操作

ダークモード

JavaScript Tips

この記事を書いた人

アルム=バンド

フロントエンド・バックエンド・サーバエンジニア。LAMPやNodeからWP、Gulpを使ってejs,Scss,JSのコーディングまで一通り。たまにRasPiで遊んだり、趣味で開発したり。