Electron アプリケーションはメインプロセスとレンダラプロセスから構成されます。
各ウィンドウは独立したレンダラプロセスで実行され、UI やウェブページを表示するために使用されます。
ここではメインプロセスとレンダラプロセスの通信方法について、ESM 形式や TypeScript、webpack + 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 {};
sendMainstring
型の引数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.handleipcMain.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)); // ソースの場所
__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 # 実行
うまく動作しました。
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.module
やoutput.chunkFormat
の指定がありません。
また、"noEmit": false
である TypeScript の設定ファイル(tsconfig_nopack.json)を読み込んでいます。
development モードの場合の注意点
webpack のdevelopment
モードは、eval()
を使用してモジュールをラップするため、そのままだと CSP の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
というインストーラが生成されますので、これを配布します。
コメント