Chrome拡張機能とローカルアプリでプロセス間通信

2022.07.14

Introduction

Native messagingとは、ユーザーのPCにインストールされたアプリと
Webブラウザの拡張機能間でメッセージ交換を可能にする機能です。

この機能を使えば、ネイティブアプリケーションが
Web経由でアクセスできなくても拡張機能にサービスを提供できます。
例えば、ローカルアプリがパスワードの暗号化と保管を行い、
拡張機能へパスワードを送信してフォームに自動設定、みたいなことも可能です。
また、拡張機能からは通常アクセスできないリソースに対しても実質的にアクセス可能になります。

本稿ではGoogle Chromeの拡張機能(extention)と
ローカルに用意したNodeプログラムでプロセス間通信をためしてみます。

※現状ではChrome以外でもEdge/Firefox/Safariなど主要なプラウザで使用可能

Environment

  • OS : MacOS 10.15.7
  • Node : v18.3.0
  • Chrome : 103.0.5060.114

Native Massage Architecture

ローカルアプリとextentionで通信するためのNative Message機能ですが、
仕組みは特に難しくありません。
↓の図のように、ブラウザの拡張機能とローカルアプリをmanifest.jsonで関連付け、
chromeのapiと標準入出力でメッセージのやりとりをします。

このmanifest.json(拡張機能のmanifest.jsonとは別)が重要で、
ここでアクセスを許可する拡張機能のIDを指定や、
拡張機能からアクセスするための名前を指定します。

ローカルのアプリはそのまま実行できる形式であればOKで、
exe(Windows)形式やpythonのファイルなどが指定可能です。
今回はshebangをつけたjsファイルを作成します。

Try

Chrome Extensionをつくる

まずはChrome拡張を作成します。
ここで作成するExtensionは、
service workerでネイティブアプリに対して
メッセージを送受信するだけのものです。

Extension用ディレクトリを作成し、
そこにChrome Extension用のmanifest.jsonを作成します。

{
    "manifest_version": 3,
    "name": "Native Messaging Example",
    "description": "Native Messaging Example chrome extension using manifest v3",
    "version": "0.0.1",
    "action": {
        "default_title": "Native Messaging Example",
        "default_popup": "popup.html"
    },
    "permissions": ["nativeMessaging"],
    "host_permissions": [
        "*://*/*"
    ],
    "background": {
        "service_worker": "service-worker.js"
    }
}

permissionsにnativeMessagingを指定しています。
これがないとNative Messageが使えません。
通信処理は後述するservice-worker.jsに記述します。

今回はとくに関係ないけど、
ボタン押したときに起動するpopup.htmlを作成。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Native Messaging Example</title>
</head>
<body>
    This is Native Messaging Example.
</body>
</html>

Netive Messageを行うservice-worker.jsを
作成します。

//ローカルアプリの起動
var port = chrome.runtime.connectNative('my_native_message_example')

//ローカルアプリからメッセージ受信
port.onMessage.addListener((req) => {
  if (chrome.runtime.lastError) {
    console.log(chrome.runtime.lastError.message)
  }
  handleMessage(req)
})

//アプリから切断されたときの処理
port.onDisconnect.addListener(() => {
  if (chrome.runtime.lastError) {
    console.log(chrome.runtime.lastError.message)
  }
  console.log('Disconnected')
})

function handleMessage (req) {
  console.log("req.message : " + req.message);
  if (req.message === 'pong') {
    console.log(req)
  }
}

//ローカルアプリへメッセージ送信
port.postMessage({message: 'ping', body: 'hello from browser extension'})

chrome.runtime.connectNativeでアプリと接続し、 メッセージの送受信処理を記述します。
ここで指定している「my_native_message_example」という名前が、
後述するmanifestファイルで指定するプロセス通信名となります。

ここまでできたら、chrome://extensions/にアクセスし、
「パッケージ化されていない拡張機能を読み込む」ボタンを押して
Extentionを登録します。
すると、↓のようにIDが表示されるので、
控えておきましょう。

なお、現時点でService Workerのリンクをクリックしても
ホスト側のアプリがないのでエラーがでます。

manifestファイルの用意

Native Messageで通信するため、ローカルのアプリ本体とは別に
JSON形式のmanifestファイルが必要になります。
このマニフェストファイルの名前とnameの値は、
さきほどconnectNativeで指定した名前と同じにします。

my_native_message_example.jsonという名前をつけ、
下記内容を記述します。

{
"name": "my_native_message_example",
"description": "Native Message example",
"path":"<ローカルアプリへのフルパス>",
"type": "stdio",
"supports_native_initiated_connections": true,
"allowed_origins": ["chrome-extension://<Chrome拡張のID>/"]
}

pathはこのあと作成するローカルアプリのフルパスです。
また、allowed_originsにはさきほど控えたIDを指定します。
※最後の/がないとエラーになるっぽいので注意

manifestファイルを作成したら、決められた位置に置きます。
ここにあるように、
/<ユーザーディレクトリ>/Library/ApplicationSupport/Google/Chrome/NativeMessagingHosts
にmanifestファイル(↑の例だとmy_native_message_example.json)を
配置します。
このファイルを置く位置は、対象ブラウザやOSによって違うので
リンク先を確認してください。

ローカルアプリの作成

manifestで指定したpathの場所に実行可能なアプリを作成します。
今回はNodeで動くjsファイルを作成しました。

#!/usr/bin/node

//標準入力処理
process.stdin.on('readable', () => {
  var input = []
  var chunk
  while (chunk = process.stdin.read()) {
    input.push(chunk)
  }
  input = Buffer.concat(input)

  var msgLen = input.readUInt32LE(0)
  var dataLen = msgLen + 4

  if (input.length >= dataLen) {
    var content = input.slice(4, dataLen)
    var json = JSON.parse(content.toString())
    handleMessage(json)
  }
})

function handleMessage (req) {
  if (req.message === 'ping') {
    sendMessage({message: 'pong', body: 'hello from nodejs app',ping_body:req.body})
  }
}

//標準出力処理
function sendMessage(msg) {
  var buffer = Buffer.from(JSON.stringify(msg))

  var header = Buffer.alloc(4)
  header.writeUInt32LE(buffer.length, 0)

  var data = Buffer.concat([header, buffer])
  process.stdout.write(data)
}

//エラー処理
process.on('uncaughtException', (err) => {
  sendMessage({error: err.toString()})
})

1行目のshebangには、自身の環境のnodeのパスを指定してください。
アプリ側では、標準入力を用いてメッセージの受信、
標準出力でメッセージを送信しています。
メッセージはシリアライズ、UTF-8エンコード、
メッセージ長の付加をして使用しています。

動作確認

nodeアプリができて、manifestファイルのpathで指定した位置に配置したら
Chrome Extensionを更新(再度インストール)します。
Service Workerリンクを開いてみると、
↓のようにメッセージのやり取りができているのがわかります。

body: "hello from nodejs app"
message: "pong"
ping_body: "hello from browser extension"

また、このタイミングでpsコマンドでnodeのプロセスをみてみると、
起動されたプロセスがわかります。  

% ps -ax | grep node

27211 /usr/bin/node /path/your/script.js chrome-extension://<chrome extension id>/

上記プロセスをkillすると、ウィンドウにDisconnectedメッセージが表示されます。

Summary

今回はChrome Extensionとローカルアプリの通信について紹介しました。
Extensionだけでは実現できない機能も、
この仕組みをつかえば実現できる(かもしれない)。

References