[Electron] contextBridge経由でIPC通信を行う

Electron v12で破壊的な変更がいくつか行われました。FLASH関係の廃止、レンダープロセスでremoteが非推奨になるあたりが話題になりますが、IPC通信時に一工夫する必要が生じたのが地味に面倒ですw

これまではレンダラープロセスからメインプロセスを呼び出す際にはipcRenderer.invoke()を実行するだけでしたが、これがそのままでは使えなくなりました。

const {ipcRenderer} = require('electron')

(async () => {
  const value = await ipcRenderer.invoke('MyAPI')
})()

上記を実行すると次のようなエラーとなります。これはrequire()でもimportに限らずエラーメッセージが表示されます。

# requireでのエラーメッセージ
Uncaught (in promise) ReferenceError: require is not defined
# importでのエラーメッセージ
Uncaught TypeError: Failed to resolve module specifier "electron". Relative references must start with either "/", "./", or "../".

今回はElectron v12でもIPC通信が行えるコードを書いていきます。

基本的な原理

そもそものお話

ざっくり言うと、これまでは「メインプロセス ⇔ レンダラー」の2者間で直接通信を行っていましたが、これからは「メインプロセス ⇔ contextBridge ⇔ レンダラー」の3者間で通信を行うことになります。

なんでまたそんな面倒なことになったかと言うと、セキュリティを強化するためです。詳細は公式ドキュメントのContext Isolationのページに記載されています。

コンテキスト分離は、あなたのプレロード スクリプトと Electron の内部ロジックの両方が、 webContents でロードしたウェブサイトに対して別のコンテキストで実行されることを保証する機能です。 これは、ウェブサイトが Electron の内部にアクセスできないようにするためのセキュリティ目的や、プリロードスクリプトがアクセスできる強力な API を防ぐために重要です。

※公式ドキュメントより

……はい、よくわからないですねw

簡単に言うとレンダラープロセスからNode.jsの機能を呼び出せなくなります。これまではレンダラーからremote経由でOSに作用する危険なコードも書けましたが、今後そのようなデンジャラスな処理はメインプロセスでしか書けなくなります(厳密には書けますが推奨されません)

この安全装置により、万が一レンダラーにOSを破壊するような危険なJSが紛れ込んだとしても、未然に実行を防ぐことができるというわけです。

これからの書き方

メインプロセス、レンダラーにそれぞれどのような変化があるか見ていきます。

メインプロセス

メインプロセスで変更すべきは1箇所です。11行目にpreload.jsを指定するだけ。このファイルはレンダラープロセスより早く実行されるスクリプトで、メインプロセスとレンダラーの橋渡しを行います。(ファイル名は何でも良いです)

const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')

function createWindow () {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: false,    // v12からのデフォルト値(記述不要)
      contextIsolation: true,    // 〃
      preload: path.join(__dirname, 'preload.js')  // ★ここがポイント★
    }
  })
  win.loadFile('index.html')
}

app.whenReady().then( ()=>{
  createWindow()
})

//----------------------------------------
// IPC通信
//----------------------------------------
// 語尾に "にゃん" を付けて返す
ipcMain.handle('nyan', (event, data) => {
  return(`${data}にゃん`)
})

プリロード

メインプロセスで指定したpreload.jsの中身です。ここにメインプロセスとレンダラーの橋渡しをするコードを書きます。以下の様にcontextBridgeモジュールを利用します。

const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('myapi', {
    nyan: async (data) => await ipcRenderer.invoke('nyan', data)
  }
)

この場合レンダラーでは window.myapi.nyan()というメソッドで実行できるようになります。

今回は1つだけ定義しましたが、実際にはここにAPIがずらーと並ぶことになるわけですね。完成すると定義書チックなファイルになりそうですね。

レンダラー

レンダラーからはpreload.jsで定義した内容に沿ってIPC通信を行います。ここではwindow.myapi.nyan()を実行することでメインプロセスと通信が行えます。

<!DOCTYPE html>
<html>
<head><title>sample</title></head>
<body>

<script>
(async ()=>{
  const message = await window.myapi.nyan('はい')
  console.log(message)  // &quot;はいにゃん&quot;
})()
</script>

</body>
</html>

サンプル「にゃんこ・わんこ語変換」

今回も簡単なサンプルを用意しました。入力した文字列の最後に「にゃん」「わん」を追加するアプリです。


www.youtube.com

ソースコード

最終的なコードはGitHubにあげています。必要に応じて参照ください。 github.com

メインプロセス - index.js

const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')

function createWindow (file) {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: false,  // v12からのデフォルト値(記述不要)
      contextIsolation: true,  // 〃
      preload: path.join(__dirname, 'preload.js')
    }
  })

  win.loadFile(`public/${file}`)
}

app.whenReady().then( ()=>{
  createWindow('index.html')
})

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit()
  }
})

app.on('activate', () => {
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow()
  }
})

//----------------------------------------
// IPC通信
//----------------------------------------
// 語尾に "にゃん" を付けて返す
ipcMain.handle('nyan', (event, data) => {
  return(`${data}にゃん`)
})

// 語尾に "わん" を付けて返す
ipcMain.handle('wan', (event, data) => {
  return(`${data}わん`)
})

レンダラープロセス

preload.js

const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('api', {
    nyan: async (data) => await ipcRenderer.invoke('nyan', data),
    wan:  async (data) => await ipcRenderer.invoke('wan', data)
  }
)

index.html

<!DOCTYPE html>
<html>
<head>
  <meta charset=&quot;UTF-8&quot;>
  <title></title>
  <meta http-equiv=&quot;Content-Security-Policy&quot; content=&quot;script-src 'self' 'unsafe-inline';&quot; />
</head>
<body>

<h1>にゃんこ語/わんこ語変換</h1>
<form>
  <p>
    <input type=&quot;text&quot; id=&quot;nyan&quot; placeholder=&quot;にゃんこ変換&quot;>
    <button id=&quot;btn-nyan&quot; type=&quot;button&quot;>変換</button>
  </p>
  <p>
    <input type=&quot;text&quot; id=&quot;wan&quot; placeholder=&quot;わんこ変換&quot;>
    <button id=&quot;btn-wan&quot; type=&quot;button&quot;>変換</button>
  </p>
</form>


<script>
// にゃんこ変換
document.querySelector('#btn-nyan').addEventListener('click', async ()=>{
  const nyan = document.querySelector('#nyan')
  const buff = await window.api.nyan(nyan.value)
  nyan.value = buff
})

// わんこ変換
document.querySelector('#btn-wan').addEventListener('click', async ()=>{
  const wan = document.querySelector('#wan')
  const buff = await window.api.wan(wan.value)
  wan.value = buff
})
</script>
</body>
</html>

メインプロセスからレンダラーへIPC通信

追加

ここまではレンダラーからメインプロセスに問い合わせていましたが、逆のパターンも試してみます。

簡単なサンプルとしてメインプロセスで乱数を発生させレンダラーへ送信、レンダラーでは乱数の値に応じて背景画像を切り替えます。以降のサンプルは変更した部分のみ掲載します。全体が見たい場合はGitHubを参照してください。

メインプロセス

BrowserWindowオブジェクト内のwebContents.send()でレンダラーへ送信することができます。

function createWindow (file) {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      preload: path.join(__dirname, 'preload.js')
    }
  })

  // 1秒置きに背景画像を変更
  setInterval(()=>{
    const rnd = (Math.floor(Math.random() * 10) % 8) + 1
    win.webContents.send('bgimage', rnd)
  }, 1000)

  win.loadFile(`public/${file}`)
}

レンダラープロセス

preload.js

ipcRenderer.on()でメインプロセスから送られてくるイベントに応じて処理を定義します。ここでは汎用的に利用できる関数を用意しました。

const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('api', {
  // レンダラー → メイン
  nyan: async (data) => await ipcRenderer.invoke('nyan', data),
  wan:  async (data) => await ipcRenderer.invoke('wan', data),

  // メイン → レンダラー
  on: (channel, callback) => ipcRenderer.on(channel, (event, argv)=>callback(event, argv))
  }
)

index.html

レンダラーでは先ほどpreloadで用意したon()を利用して処理を定義するだけです。

// 背景画像をメインプロセスに言われるがままに変更
window.api.on('bgimage', (event, number)=>{
  document.querySelector('body').style.backgroundImage = `url(image/${number}.png)`
});

ここまでのソースコードGitHubへアップしていますので全体像が見たい場合はそちらをご利用ください。

参考ページ