Electron のプリロードスクリプトによるプロセス間通信

プログラミング

Electron アプリケーションはメインプロセスとレンダラプロセスから構成されます。
各ウィンドウは独立したレンダラプロセスで実行され、UI やウェブページを表示するために使用されます。
ここではメインプロセスとレンダラプロセスの通信方法について、ESM 形式TypeScriptwebpack + electron-builder による配布といった観点から解説したいと思います。


コンテキスト分離
レンダラプロセスはメインプロセスから起動されます。
かつてはレンダラプロセスでもメインプロセスと同様に完全な Node.js の機能を扱えましたが、セキュリティの観点からこれは推奨されなくなりました
よりセキュアなアプリケーションの実装として、メインプロセスとレンダラプロセスのコンテキストは明確に分離されなければなりません。
レンダラプロセスのイベントを拾ってメインプロセスに伝え、メインプロセス側で処理を行うよう実装すべきです。

コンテキストの分離は Electron v12 よりデフォルトで有効になっていますが、レンダラプロセスの起動メソッドであるBrowserWindow()には、現在でもコンテキスト分離を行わない設定値が存在します。

const mainWindow = new BrowserWindow({
	webPreferences: {
		contextIsolation: false,
		nodeIntegration: true
	}
});

contextIsolation: false
メインプロセスのコードとレンダラプロセス内のウェブページのコードが同じグローバルスコープを共有します。
これによりウェブページのスクリプトが Electron API や Node.js の機能に直接アクセスできるようになります。
デフォルトではtrueに設定されており、可能なかぎりfalseにすべきではありません。

nodeIntegration: true
レンダラプロセスから Node.js の機能を利用できるようになります。
デフォルトではfalseに設定されており、可能なかぎりtrueにすべきではありません。


プリロードスクリプト
メインプロセスとレンダラプロセス間の通信は IPC【Inter-Process Communication】を通じて行います。
よりセキュアな IPC 通信を可能とするのがプリロードスクリプトによるcontextBridgeの実装です。
プリロードスクリプトはレンダラプロセスが HTML を読み込む前に実行され、IPC 通信を行うカスタム API を公開します。
レンダラプロセスはこの API を利用してメインプロセスへ通信を行います。

以下はレンダラプロセスからメインプロセスへ送信を行う API が記述されたプリロードスクリプトのコード例です。

const { contextBridge, ipcRenderer } = require("electron");

contextBridge.exposeInMainWorld("myAPI", {
	sendMain: (arg:string) => ipcRenderer.invoke("sendMain", arg)
});

このスクリプトが CommonJS であることに注意してください。


レンダラプロセスのサンドボックス化
Electron v20.0.0 よりレンダラプロセスがデフォルトでサンドボックス化されました。
サンドボックスは Chromium の機能を利用していますが、Chromium が提供するサンドボックスでは ESM ローダーがサポートされないため、レンダラプロセスにアタッチされるプリロードスクリプトはrequireでモジュールをインポートする必要があります。
これはプリロードスクリプトが CommonJS であることを意味します。

なおプリロードスクリプトでは、Electron のレンダラプロセス用のモジュールしかインポートできません。
サンドボックス化に伴い、システムリソースへのアクセスはメインプロセス以外ではほぼ制限されるようになりました。


グローバルスコープの型定義
Electron アプリケーションを TypeScript で記述する場合、プリロードスクリプトによるcontextBridgeを実装するためにはアンビエント宣言が必要になります。
これはレンダラプロセスがカスタム API を呼び出すときの型情報の宣言であり、TypeScript がトランスパイル時に適切な型チェックを行うためのものです。

通常プリロードスクリプトが公開する API は、ブラウザの持つグローバルなWindowインターフェースを拡張し、カスタムメソッドを追加することで実装します。
例えば先ほどプリロードスクリプトのコード例として挙げたsendMain()は、下記のように宣言されます。

declare global{
	interface Window{
		myAPI:{
			sendMain: (arg:string) => string;
		};
	}
}
export {};

sendMain
string型の引数argを持ちstring型の値を返すメソッドを宣言しています。
レンダラプロセスからwindow.myAPI.sendMain("");という形で呼び出すことができます。

export
このファイルがモジュールとして扱われることを TypeScript に伝えるために必要で、これによりファイルのトップレベルのスコープがグローバルスコープとは異なるものになります。


レンダラプロセスの実装
レンダラプロセス側の記述例です。
ここではsendBtnがクリックされたときに公開されたカスタム API であるsendMain()を呼び出してメインプロセスへメッセージを送信しています。
返ってきた値はmessageの領域に出力されます。

window.addEventListener("load", function(){
	const ele = document.getElementById("sendBtn");
	if(ele){
		ele.addEventListener("click", async () => {
			const res = await window.myAPI.sendMain("renderer -> main メッセージ送信");
			const mesEle = document.getElementById("message");
			if(mesEle) mesEle.innerText = "renderer: " + res;
		});
	}
});


UI(ウェブページ)側は以下のような感じになります。

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8"/>
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline';"/>
<title>test</title>
<script type="module" src="renderer.mjs"></script><!-- ★ -->
</head>
<body style="background: #fdd;">

<div id="app">
 <h1>メインウィンドウ</h1>
 <button id="sendMain" type="button">メインプロセスへ送信</button>
</div>
<div id="message"></div>

</body>
</html>

HTML は<script>タグでtype="module"とすることで ESM 形式のモジュールを読み込むことが可能です。
拡張子が.mjsであることに注意してください。


メインプロセスの実装
メインプロセス側では以下のような感じで実装します。

import { app, BrowserWindow, ipcMain } from "electron";
import path from "path";

let mainWindow: BrowserWindow | null = null;

async function createWindow(){
	const __dirname = app.getAppPath(); // 1
	const params = {width:1024, height:600, x:50, y:50, title:"Main Window",
		webPreferences: {
			preload: path.join(__dirname, "preload.cjs") // 2
		}
	};
	mainWindow = new BrowserWindow(params);

	ipcMain.handle("sendMain", async (e, arg:string) => { // 3
		console.log("main: " + arg);
		return "main -> renderer メッセージ返信";
	});

	await mainWindow.loadFile(path.join(__dirname, "main.html")); // 4
	mainWindow.webContents.openDevTools();
}

app.on("ready", function(){
	createWindow();
});

__dirname
ESM 形式では自身のディレクトリを指す__dirname変数(自動設定されるローカル変数)が使用できませんので、例のように定義します。

webPreferences.preload
プリロードスクリプトのファイル名を絶対パスで設定します。

ipcMain.handle
ipcMain.handle()を使用して、メッセージハンドラを登録します。
レンダラプロセスからのsendMainイベントを受け取り、受け取った引数をコンソールログに出力して応答メッセージを返しています。
応答メッセージはレンダラプロセスへ送信されます。

BrowserWindow.loadFile
レンダラプロセスで表示するウェブページのファイルをロードします。
ここでは Electron アプリケーションの UI である main.html をロードしています。
ファイル名は相対パスでも構いません。


プリロードスクリプトの絶対パスについて
プリロードスクリプトの読み込みには絶対パスが必要ですが、ESM 形式でかつ webpack によるパッケージングが前提の場合、CommonJS でよく見かける以下の構文は使用できませんので注意が必要です。

// NG
preload: path.resolve(__dirname, “preload.js");

ESM 形式の場合は__dirnameを使えませんが、__dirnameの代替であるimport.meta.urlは webpack のバンドル時にパスが解決されるため、それが指し示す場所は元のソースファイルの場所となってしまいます。

// NG
const __dirname = path.dirname(fileURLToPath(import.meta.url)); // ソースの場所
import.meta.urlのパス

__dirnameの代替にはapp.getAppPath()を利用してください。

const __dirname = app.getAppPath(); // 実行ディレクトリ

app.getAppPath()は Electron アプリケーションの現在の実行パスを返す関数です。
このパスはアプリケーションのメインファイルやパッケージが存在するディレクトリを指し、アプリケーションのリソースへのアクセスや設定ファイルの読み込みなどに使用されます。


メインプロセスからレンダラプロセスへの送信
これまではレンダラプロセスからメインプロセスへの送信を説明してきましたが、逆方向ではどうなるでしょうか。
詳細は省略しますが、ここではコードの例だけを紹介いたします。

・・・
contextBridge.exposeInMainWorld("myAPI", {
・・・
	onRecvRenderer: (callback:(arg:string) => void) => {
		ipcRenderer.on("sendRenderer", (_event, arg:string) => callback(arg));
	}
・・・
});
・・・
declare global{
	interface Window{
		myAPI:{
・・・
			onRecvRenderer: (callback:(arg:string)=> void) => void;
・・・
		};
	}
}
export {};
・・・
window.myAPI.onRecvRenderer((arg:string) => {
	const mesEle = document.getElementById("message");
	if(mesEle) mesEle.innerText = "renderer: " + res;
});
・・・
・・・
mainWindow.webContents.send("sendRenderer", "main -> renderer メッセージ送信");
・・・


トランスパイルと実行
この時点でソースコードをトランスパイルし、実行することができます。
package.json に以下のスクリプトを組み込み、実行してみます。
なお、TypeScript の設定ファイルである tsconfig.json は前記事のものを使用しています。

・・・
"scripts": {
	"tsc": "tsc --project tsconfig_nopack.json",
	"start": "electron --trace-warnings ./dist/main.mjs",
},
・・・

オプション--projectは、TypeScript のカスタムな設定ファイルを指定します。
webpack のts-loaderを利用することが前提となっている tsconfig.json では"noEmit": trueになっているので、
webpack を通さずに実行させたい場合はこれをfalseとする設定ファイルを読み込まなければなりません。
以下のように元の tsconfig.json を継承する形で tsconfig_nopack.json を作成します。

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "noEmit": false
  }
}

このプログラムはカレントディレクトリにある main.html をloadFileするので、これを main.mjs と同じ場所(dist ディレクトリ)に配置します。

>cp src/main.html dist # main.htmlの配置
>npm run tsc # トランスパイル
>npm run start # 実行

うまく動作しました。

Electron アプリケーションの実行


webpack によるバンドル
以下は webpack の設定ファイル webpack.config.mjs の記述例です。
TypeScript の設定ファイルである tsconfig.json は前記事のものを使用しています。

import path from "path";
import { fileURLToPath } from "url";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const srcDir = path.resolve(__dirname, "src");
const bundleDir = path.resolve(__dirname, "bundle");

const main = {
	mode: "production",
	target: "electron-main",
	entry: path.resolve(srcDir, "main.mts"),
	output: {
		path: bundleDir,
		filename: "main.mjs",
		module: true,
		chunkFormat: "module"
	},
	resolve: {
		extensions:[".mts", ".cts", ".mjs", ".cjs", ".ts", ".js"]
	},
	module: {
		rules: [{
			test: /\.(mts|cts)$/,
			use: {
				loader: "ts-loader",
				options: {
					transpileOnly: true
				}
			},
			include: srcDir,
			exclude: /node_modules/
		}]
	},
	experiments: {
		outputModule: true
	}
};

const renderer = {
	mode: "production",
	target: "electron-renderer",
	entry: path.resolve(srcDir, "renderer.mts"),
	output: {
		path: bundleDir,
		filename: "renderer.mjs",
		module: true,
		chunkFormat: "module"
	},
	resolve: {
		extensions:[".mts", ".cts", ".mjs", ".cjs", ".ts", ".js"]
		fullySpecified: true
	},
	module: {
		rules: [{
			test: /\.mts$/,
			use: {
				loader: "ts-loader",
				options: {
					transpileOnly: true,
					configFile: "tsconfig_nopack.json"
				}
			},
			include: srcDir,
			exclude: /node_modules/
		}]
	},
	experiments: {
		outputModule: true
	}
};

const preload = {
	mode: "production",
	target: "electron-preload",
	entry: path.resolve(srcDir, "preload.cts"),
	output: {
		path: bundleDir,
		filename: "preload.cjs"
	},
	resolve: {
		extensions:[".mts", ".cts", ".mjs", ".cjs", ".ts", ".js"]
	},
	module: {
		rules: [{
			test: /\.cts$/,
			use: {
				loader: "ts-loader",
				options: {
					transpileOnly: true,
					configFile: "tsconfig_nopack.json"
				}
			},
			include: srcDir,
			exclude: /node_modules/
		}]
	}
};
export default [main, renderer, preload];

target: “electron-main”
メインプロセスのバンドル設定です。
詳細は以下を参照してください。

参考: Electron + ESM + TypeScript + webpack + electron-builder

target: “electron-renderer”
レンダラプロセスのバンドル設定です。
メインプロセスとほぼ変わらないですが、"noEmit": falseである TypeScript の設定ファイル(tsconfig_nopack.json)を読み込んでいます。

target: “electron-preload”
プリロードスクリプトのバンドル設定です。
メインプロセスとほぼ変わらないですが、CommonJS なのでoutput.moduleoutput.chunkFormatの指定がありません。
また、"noEmit": falseである TypeScript の設定ファイル(tsconfig_nopack.json)を読み込んでいます。


development モードの場合の注意点
webpack のdevelopmentモードは、eval()を使用してモジュールをラップするため、そのままだと CSP のunsafe-eval制約に違反して実行時にエラーとなります。

unsafe-eval制約違反


これを回避するにはソースマップファイルを生成する必要があります。
本番用との切り分けのため、developmentモード用の webpack.config.dev.mjs を作成します。
devtoolオプションは、ソースマップの生成方法を制御します。

・・・
const renderer = {
・・・
	devtool: "source-map"
・・・
};

devtool: source-map
完全なソースマップを別のファイルとして生成します。
これはデバッグに適していますが、ビルドプロセスを遅くする可能性があります。
nosources-source-map(ソースコードの内容を含まないソースマップ)を指定しても構いません。


パッケージングと実行
package.json に以下のスクリプトを組み込み、実行します。

・・・
"scripts": {
・・・
	"dev": "webpack --config webpack.config.dev.mjs --stats-error-details",
	"pro": "webpack --stats-error-details",
	"start2": "electron --trace-warnings ./bundle/main.mjs",
・・・
},
・・・

オプション--configは、webpack のカスタムな設定ファイルを指定します。
developmentモードの場合は webpack.config.dev.mjs、productionモードの場合はデフォルトの設定ファイル(webpack.config.xx)を読み込むように設定しています。

なお、このプログラムはカレントディレクトリにある main.html をloadFileするので、これを出力された main.mjs と同じ場所(bundle ディレクトリ)に配置します。

>cp src/main.html bundle # main.htmlの配置
>npm run dev # パッケージング(developmentモード)
>npm run pro # パッケージング(productionモード)
>npm run start2 # 実行


electron-builder による配布
electron-builder の設定は package.json に記述します。
設定値については前記事を参照してください。

リソースへのパスが変更されますので、プリロードスクリプトや main.html をロードするパスを以下のように修正します

const __dirname = app.getAppPath();
 ↓
const __dirname = path.join(app.getAppPath(), "bundle");

package.json に以下のスクリプトを組み込み、ビルドしてみます。

・・・
"scripts": {
	"buildDev": "electron-builder --win --x64 --dir",
	"build": "electron-builder --win --x64"
},
・・・

以下のコマンドで実行します。

>npm run build

outputで指定した出力ディレクトリに{productName} Setup {version}.exeというインストーラが生成されますので、これを配布します。

コメント