Electron を試す 12 - IPC を contextBridge へ移行する
私はこれまで Electron における Renderer プロセスからの IPC は window.require
で参照した ipcRenderer
により実行していた。しかしこの方法は BrowserWindow
で nodeIntegration: true
にする必要があり、安全性の面から好ましくない。そのため今後の推奨である contextBridge
による設計へ移行してみる。
- サンプル プロジェクト
- 参考記事
webPreferences
はじめに Main プロセスにおける BrowserWindow
の webPreferences
を定義。これまでの実装を
const window = new BrowserWindow({
webPreferences: {
nodeIntegration: true,
}
})
以下のように変更する。
const window = new BrowserWindow({
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
worldSafeExecuteJavaScript: true,
preload: path.join(__dirname, 'preload.js')
}
})
個別に解説する。
nodeIntegration
は Renderer プロセスに Node.js 機能の利用を許可するための値。true
にすると window.require
で Electron も含む Node.js モジュールが参照可能となる。まずはこれを false
にして抑止。
contextIsolation
は Electron 本体の処理と BrowserWindow
の webContents
に読み込まれた Web サイトの JavaScript 実行コンテキストを分離するための値。true
にすることで Web サイトから Node.js や Electron の機能実行を抑止する。例えば後述する preload
で window.myAPI
というプロパティーなどを定義しても Web サイトによる参照は undefined
となるので安全だ。この値の既定値は Electron v12 以降で true
となり、特別な設定をしない限り false
を設定できないようにするとのこと。
worldSafeExecuteJavaScript
を true
にすると webFrame.executeJavaScript
から返された値はサニタイズされる。JavaScript 実行コンテキスト間で値をやりとりする際の安全性が向上するとのこと。contextIsolation: true
にしたなら同時に指定するとよいだろう。これも Electron v12 以降で規定値が true
となる予定。
preload
はその名のとおり BrowserWindow
が読み込む JavaScript よりも前に実行される JavaScript ファイルの絶対パスを指定する。loadFile
と異なり相対パスは指定不能。なお Electron の path
はリリース用にパッケージ化された ASAR ファイル内に対しても有効なので __dirname
直下を指定しても OK。ファイル名に制約はないが preload
であることを明示するため、そのまま preload.js
とした。
preload.js
このスクリプトへ Renderer プロセスからの IPC 処理を実装。TypeScript で書いて webpack
+ ts-loader
によりビルドする。まずは実装から。Renderer から Main への送信だけでなく、逆方向の受信もあったほうがよいのでサンプルのうち multiple-window プロジェクトから引用する。
import { contextBridge, ipcRenderer } from 'electron'
import { IpcRendererEvent } from 'electron/main'
import { IPCKey } from './Constants'
contextBridge.exposeInMainWorld('myAPI', {
sendMessage: async (targetWindowId: number, message: string): Promise<void> =>
await ipcRenderer.invoke(IPCKey.SendMessage, targetWindowId, message),
createNewWindow: async (): Promise<void> =>
await ipcRenderer.invoke(IPCKey.CreateNewWindow),
getWindowIds: async (): Promise<number[]> =>
await ipcRenderer.invoke(IPCKey.GetWindowIds),
onUpdateMessage: (listener: (message: string) => void) =>
ipcRenderer.on(
IPCKey.UpdateMessage,
(ev: IpcRendererEvent, message: string) => listener(message)
),
onUpdateWindowIds: (listener: (windowIds: number[]) => void) =>
ipcRenderer.on(
IPCKey.UpdateWindowIds,
(ev: IpcRendererEvent, windowIds: number[]) => listener(windowIds)
)
})
contextBridge の exposeInMainWorld
により JavaScript 実行コンテキスト間の仲介処理 (Bridge) を定義する。第 1 引数 apiKey
は Renderer プロセスの window
配下として公開 (Expose) される名前。DOM の window
配下 (window.location
など) と衝突しないように注意すること。ベタだが myAPI
のような命名なら今後も標準規格になることはないだろう。
第 2 引数 api
へ Object
として API やプロパティーを定義してゆく。ここで注意。Context Isolation で指摘されているように IPC 処理を汎用化してはいけない。
// ❌ Bad code
contextBridge.exposeInMainWorld('myAPI', {
send: ipcRenderer.send
})
これは ipcRenderer.send
を直に公開しているようなもので、非常に危険だ。そのため用途ごとに API を定義し、入力 (引数) の検証を可能とするため関数で包むことを推奨している。
// ✅ Good code
contextBridge.exposeInMainWorld('myAPI', {
loadPreferences: () => ipcRenderer.invoke('load-prefs')
})
以上を踏まえて私もそのようにした。
受信について。Main プロセスからの送信を Renderer プロセスで受信する場合は以下のようにリスナーを登録するための API を定義すればよい。
contextBridge.exposeInMainWorld('myAPI', {
onUpdateMessage: (listener: (message: string) => void) =>
ipcRenderer.on(
IPCKey.UpdateMessage,
(ev: IpcRendererEvent, message: string) => listener(message)
)
})
厳密にはリスナーの重複管理とか、そのオーナーである BrowserWindow
が破棄された際の解除も実装したほうがよいのかもしれない。こうしたライフサイクル関連についての言及が公式ドキュメントから見つからなかったので楽観的な実装としている。
本記事を読まれた方でこのあたりの情報をお持ちの方は @akabekobeko にお知らせくださると助かります。情報があれば本記事へ追記する予定です。
preload.js をビルドする
preload.js
のビルドについて。このファイルは Main プロセスと同様に Electron を参照する。そのため Main 系としてビルドすることにした。webpack としては Main と Renderer に続き、エントリー ポイントが追加されたことになる。webpack.config.ts
実装は以下。
import { Configuration } from 'webpack'
export default (env: any, argv: Configuration) => {
const MAIN = !!(env && env.main)
const PRELOAD = !!(env && env.preload)
const PROD = !!(argv.mode && argv.mode === 'production')
if (PROD) {
process.env.NODE_ENV = 'production'
}
return {
target: MAIN || PRELOAD ? 'electron-main' : 'electron-renderer',
entry: MAIN
? './src/main/AppMain.ts'
: PRELOAD
? './src/common/Preload.ts'
: './src/renderer/AppRenderer.tsx',
output: {
path: PROD ? `${__dirname}/dist/src/assets` : `${__dirname}/src/assets`,
filename: MAIN ? 'main.js' : PRELOAD ? 'preload.js' : 'renderer.js'
},
devtool: PROD ? undefined : 'inline-source-map',
node: {
__dirname: false,
__filename: false
},
resolve: {
extensions: ['*', '.js', '.jsx', '.ts', '.tsx']
},
module: {
rules: [
{
test: /\.(ts|js)x?$/,
exclude: /node_modules/,
use: [
{
loader: 'ts-loader',
options: {
transpileOnly: true
}
},
{
loader: 'ifdef-loader',
options: {
env: PROD ? 'PRODUCTION' : 'DEBUG'
}
}
]
}
]
},
externals: MAIN || PRELOAD ? [] : ['electron']
}
}
単一の設定ファイルで Main、Renderer、Preload を env
の値で分岐。これは package.json
の npm-scripts から以下のように指定している。
{
"scripts": {
"build:js-main": "webpack --env main --mode development",
"build:js-preload": "webpack --env preload --mode development",
"build:js-renderer": "webpack --mode development",
"watch:js-main": "webpack --env main --mode development --watch",
"watch:js-preload": "webpack --env preload --mode development --watch",
"watch:js-renderer": "webpack --mode development --watch",
"release:js-main": "webpack --env main --mode production",
"release:js-preload": "webpack --env preload --mode production",
"release:js-renderer": "webpack --mode production",
}
}
webpack 4 までは --env.main
としていたが webpack 5 からは --env main
のように引数の値として指定する形式に変更された。5 で従来式を実行するとエラーになるので注意すること。これらの設定により src/assets/
直下へ main.js
、preload.js
、renderer.js
が揃う。
Renderer プロセスから参照する
preload.js
の定義により window.myAPI
が参照可能となった。しかし TypeScript で実装しているなら VS Code などから入力補完を効かせたい。そのためには型を定義する。
/**
* Declare a type that depends on the renderer process of Electron.
*/
declare global {
interface Window {
myAPI: MyAPI
}
}
/**
* Provides an application-specific API.
*/
export type MyAPI = {
/**
* Create a new window.
*/
createNewWindow: () => Promise<void>
/**
* Gets all existing window identifiers.
* @returns Collection of window identifiers.
*/
getWindowIds: () => Promise<number[]>
/**
* Sends a message to the specified window.
* @param targetWindowId The identifier of target window.
* @param message Message to be sent
*/
sendMessage: (targetWindowId: number, message: string) => Promise<void>
/**
* Occurs when a message is sent to its own window.
* @param listener Event listener.
*/
onUpdateMessage: (listener: (message: string) => void) => void
/**
* Occurs when the list of window identifiers is updated by new creation or deletion.
* @param listener Event listener.
*/
onUpdateWindowIds: (listener: (windowIds: number[]) => void) => void
}
こんな感じの定義をしておくと window.
から順に window.myAPI.sendMessage
まで入力補完される。TSDoc でコメントを書けばそれも表示されるので便利だ。これらは以下のように呼び出せる。
const windowIds = await window.myAPI.getWindowIds()
React を利用しているなら Hooks か Redux の Action あたりで処理するのがよいだろう。私は後者を採用している。
import { Dispatch } from 'redux'
import { ActionType } from '../Types'
export const finishGetWindowIds = (windowIds: number[]) => ({
type: ActionType.GetWindowIds as ActionType.GetWindowIds,
payload: {
windowIds
}
})
export const getWindowIds = () => async (dispatch: Dispatch) => {
const windowIds = await window.myAPI.getWindowIds()
dispatch(finishGetWindowIds(windowIds))
}
まとめ
これまで仕方なく Renderer へ露出させていた require
を隠蔽できて嬉しい。IPC というプロセス間を仲介する部分が独立したファイルとして括りだされた点も、運用面で好ましく感じる。
昔から Electron の nodeIntegration
は危険視されていた。この問題について「なんとかできないものか?それもスマートに」と悩んでいたのだけど、公式からよい方法が提供されたことへ感謝したい。