chrome開発者ツールを作る

開発者ツールを作るシチュエーションはめったに無いと思いますが、そういうシチュエーションになったので書いています。
クローム拡張のアーキテクチャの概要にさらっと触れて、開発者ツールとページ(`content script`)の相互通信を確立するところまで。

# chrome拡張のアーキテクチャ
普通のクローム拡張では、表示しているページと同じコンテキストで動きDOMにアクセスできるがAPI制限のある`content script`と、それとは独立なコンテキストで動作する`background page`を使いますが、開発者ツールを作る場合はこれにプラスして`devtools_page`というやつも使います。
これは開発者ツールを開いたときに開始される上の２つとは独立したコンテキストです。
`content script`や`background page`と同じように、相互にメッセージを送り合うことができます。

![](https://developer.chrome.com/static/images/devtools-extension.png "画像タイトル")


まずは、`devtools_page`を作ります。

## devtools_page
`manifest.json`に

```json
{
    "devtools_page": "devtool.html"
}
```
と書くと開発者ツール起動時に指定したファイルがロードされて`devtool_page`のコンテキストが生成されます。
そのコンテキストで、

```js
chrome.devtools.panels.create("My Panel",
    "icon.png",
    "main.html",
    function (panel) {
        // callback
    }
);
```
とすると、devtoolに`My Panel`というタブが追加されます。
このタブで表示する内容は`main.html`です。
これで開発者ツールにタブを増やせました。

ちなみに、`chrome.devtools.panels.elements.createSidebarPane`でElementsパネルの右の方のメニューを追加したりもできるそうです。

## content scriptの追加(inject)
ページの情報を取得するために、`content script`のコンテキストにスクリプトを送り込む必要があります。
`chrome.tabs.executeScript`を使います。

```js
chrome.tabs.executeScript({
    code: 'document.body.style.backgroundColor="red"'
});
chrome.tabs.executeScript(null, {file: "content_script.js"});
```

別のコンテキストから動的に`content script`にコードを送り込めます。（cross-origin permissionsが必要です）
`chrome.tabs.executeScript`は第一引数にタブIDを受け取りますが省略するとアクティブなタブが使われます。普通このまま動きますが **開発者ツールが独立Windowで起動するときは開発者ツール自体がアクティブタブになってしまう** ので注意が必要です。
それを防ぐには対象タブを指定します。`chrome.devtools`はdevtoolsのコンテキストで使えるAPIです。

```js
chrome.tabs.executeScript(chrome.devtools.inspectedWindow.tabId,{
    file: "./dist/content_script.js"
});
```

## メッセージパッシング
ページの状態を監視して逐一devtoolと通信するためにはメッセージパッシングが必要です。
別のコンテキストで動くスクリプト同士が通信するためのものです。
単発の通信は以下のようにします。

```js
// 送信
chrome.runtime.sendMessage({hoge:"hoge"},response=>{
    console.log(response.fuga);// "hogehoge"
});

//　受信
chrome.runtime.onMessage.addListener((request, sender, sendResponse)=>{
    sendResponse({fuga:request.hoge+request.hoge});
    // return true;
});
```
これでコンテキストをまたいだ通信ができます。
受信側の最後の`return true`は、`sendResponse`を非同期に呼ぶときにないとうまくコールバックできません。(この例は同期処理なので不要)

この通信は、 `content script`から`background page`への一方通行で、逆から開始することはできないので注意が必要です。
逆向きの通信がしたいときは、`chrome.tabs.sendMessage`を使います。

```js
chrome.tabs.sendMessage(tabId,{hogehoge:123},function(){
    //callback
})
```
当然ですがこの場合はタブIDの指定が必須です。

### コネクション
何度も通信を繰り返すならコネクションを張ったほうが便利です。

コネクションを*background page*で待ち受けます。

```typescript
chrome.runtime.onConnect.addListener((port: chrome.runtime.Port) => {
    port.onMessage.addListener(function(){
        port.postMessage({hoge:"aaaa"})
    })
})
```
コネクションに`content script`から接続します。

```typescript
var port = chrome.runtime.connect({
        name: "hogehoge"
    });
port.postMessage({hoge:"bbbb"})
```


`port`を介して無限に会話ができます。

この場合も、逆方向の場合は少しAPIが変わり、

```typescript
let c = chrome.tabs.connect(tabId, {
            name: "hogehoge:"+tabId
        })
```
という感じになります。

さて、`devtool page`から`content script`にコネクションを張りたいなら、**`background page`を経由することになります。**

# コネクションの実装
以下のようにしてみました。

0. `devtool`が開かれたら、`content script`をinjectして、そいつにコネクションの待受をさせる。
1. `background page`がコネクションの待受をしているようにして、`devtool`からコネクションを張る。
2. `background page`は`devtool`とのコネクション確立後、`content script`にコネクションを張って２つのコネクションを直結する。

エントリーポイントが`devtool`なので、開発者ツールを開かない限り不要な`content script`が実行されないのがミソです。

```typescript:content_script.js
var _port;
chrome.runtime.onConnect.addListener((port: chrome.runtime.Port) => {
    _port = port;
    port.onMessage.addListener(function(){
        //do domething
    })
})
```

これを`devtool`からページにinjectします。・

```js:devtool_page.js
chrome.tabs.executeScript(chrome.devtools.inspectedWindow.tabId, {
    file: "./content_script.js"
}, () => {
    var port = chrome.runtime.connect({
        name: `${chrome.devtools.inspectedWindow.tabId}`
    });

    port.onMessage.addListener(function (message) {
        // do something
    });

    port.postMessage({
        hoge:123
    });
});
```

これを待ち受けるのは、

```js:background.js
chrome.runtime.onConnect.addListener((port: chrome.runtime.Port) => {
    let tabId = Number(port.name);
    let c = chrome.tabs.connect(tabId, {
            name: "hogehoge:"+tabId
        })
    port.onMessage.addListener((message, port) => {
            c.postMessage(message);
        });
    c.onMessage.addListener((message, port) => {
            port.postMessage(message);
        })
})
```

# さいごに
- 今回はtabIdをコネクション名に乗せて雑に通知していますがあまりいい使い方ではなさそうです。
- また、コネクションが解除(=開発者ツールが閉じた)ときにコネクションを片付ける処理も本来は必要です。