はじめに

Electron アプリは、Windows / macOS などクロスプラットフォーム向けのデスクトップアプリを、JavaScript をベースに作れる非常に有名なフレームワークです。

内部は、画面表示にブラウザ(Chromium)、アプリ自体の操作は Node.js と棲み分けがされており、画面表示は React や Vue、ファイル操作を伴う処理は Node.js のように分散して開発を行うことができます。

ただ、このブラウザとNode.js を結びつける部分は preload という仕組みで双方向の通信を行うのですが、簡単なサンプルはあっても、管理方法についてあまり実用的なサンプルがなかった印象です。

そこで、自分がいつも使っているパターンをサンプルにしてみました。

GitHub | electron-typescript-preload-example

前提

  • Node.js v20.11.1

サンプルの動かし方

1
2
3
4
git clone https://github.com/niroro/electron-typescript-preload-example.git
cd electron-typescript-preload-example
npm install
npm start

これだけで以下のアプリが起動します

preload-template-start

4つのボタンがついていますが、それぞれ Node.js で書かれた処理を呼び出します

また、実際に実行ファイルを作りたいときは以下のコマンドで作成できます

1
npm package

out ディレクトリ以下に実行形式のファイルが展開されます

Windows 環境下なら、out/electron-typescript-preload-example-win32-x64 に展開されます

この動作は Electron Forge のテンプレートを使っているので、公式ドキュメントを確認してください

コード解説

main / renderer のディレクトリ自体の分離

Node.js (main プロセス) とブラウザ上の JavaScript (renderer プロセス) では挙動が異なります。
過去にはインテグレーションが標準で使われていましたが、挙動の違い以外にもセキュリティの問題などがあり、preload という仕組みを使うのが推奨されています。

簡単なサンプルでは、一つのディレクトリにまとめているケースも多いですが、初めから分けておいたほうが望ましいと思います。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
src
├─channels.ts

├─main
│ index.ts
│ ipcHandlers.ts

├─preload
│ preload.ts

└─renderer
bridges.ts
index.css
index.html
index.ts

動作の順序としては、main/index.ts にてウィンドウの立ち上げ処理を行い、それを受けてウィンドウ内として起動するブラウザ内で renderer/index.ts を実行していく手順となります

IPC通信 と preload

この2つのプロセスの通信には IPC通信 が用いられます

main プロセスでは ipcMain / renderer プロセスでは、ipcRenderer を用いて、双方向にデータのやり取りを行いますが、ipcRenderer のスコープを秘匿化するために、prelaod/preload.ts を用意します

なお、IPC通信では、独自クラスのインスタンスを渡すことができないなど制限があります
JSONメッセージで表せるようなデータを送るものと割り切って使う必要があります

IPC通信での処理実装例

今回はウィンドウクローズを行う処理の流れを説明します

renderer プロセスからの main プロセスへ指示を送る場合に、双方で共通に使う名称を決めます

1
2
3
4
5
6
7
/**
* Ipc Channels name
*/
export enum Channels {
/** Close window */
Close = 'close',
}

preload/preload.ts には、その処理を呼び出す処理を記述します
(戻り値も受け取れますが、今回は void です)

1
2
3
4
5
6
7
8
9
10
11
12
/**
* Secure IPC communication bridge
* @see https://www.electronjs.org/ja/docs/latest/api/context-bridge
*/
contextBridge.exposeInMainWorld("bridge", {
/**
* Close
*/
close: async () => {
await invokeChannel(Channels.Close);
},
});

main プロセス内では、すでに ハンドルされています (main/ipcHandler.ts#addHandler が呼び出し済み)

そのため、ipcMain.handle(Channels.Close, this.close.bind(this)) で定義された close() が呼び出され、ウィンドウクローズ操作が行われます

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
* Ipc handler class
*/
export default class IpcHandler {
/**
* constructor
* @param window browser window
*/
constructor(window: BrowserWindow) {
mainWindow = window
}

/**
* Close
*/
async close() {
mainWindow.close()
}

/**
* Add handlers
* @param ipcMain ipcMain
*/
addHandlers(ipcMain: IpcMain) {
// Remove previous handler
this.removeHandlers(ipcMain)

ipcMain.handle(Channels.Close, this.close.bind(this))
}

/**
* Remove handlers
* @param ipcMain ipcMain
*/
removeHandlers(ipcMain: IpcMain) {
ipcMain.removeHandler(Channels.Close)
}
}

この仕組みで、複雑化しやすい IPC 処理もわかりやすく管理することができます

参考