Chapter 05

メインプロセスとレンダラープロセス

Kei Touge
Kei Touge
2021.09.18に更新

前章のまとめ

おさらいをすると、メインプロセスとレンダラープロセスの特徴はそれぞれ以下のようになります。

メインプロセス:

  • CommonJS で記述される
  • 起動・終了といったアプリ全体の挙動を制御する
  • Node.js を通して OS が持つ機能をフルに利用できる

レンダラープロセス:

  • ECMAScript で記述される
  • 通常の Web アプリ/サイトと(ほぼ)同じ
  • 利用できる OS やブラウザの機能は、そのサイトやブラウザに与えられた権限内のみに限られる

これらは独立した別個のプロセスです。そして原則として、メインプロセスが持つ機能を、レンダラープロセスから利用することはできません

Electron の過去のバージョンにおいては、これが可能でした。現在もメインプロセスにオプションを与えることで、レンダラープロセスに Node.js の機能を利用させることは可能です(後述)。

なぜでしょうか?

以下では、この仕組みについて詳しく見ていきます。

レンダラープロセスのセキュリティ

これまで見てきた通り、メインプロセスでは OS が持つ機能をフルに利用できます。一方でレンダラープロセスは通常の Web サイトとほぼ変わりません。

想像してみてください。もしもウェブブラウザで、あるサイトにアクセスして、そのサイトに OS の機能を無制限に利用されるとしたら?

「OS の機能が使える」ということは、C:\ ドライブをまるごと消し去ったり、通信を乗っ取ったりできるということです[1]

これがレンダラープロセスに Node.js の機能を(デフォルトでは)使わせない仕組みになっている理由です。

では、この両プロセスはどうやって連携をとるのでしょうか?

IPC 通信(プロセス間通信)

Electron では両プロセス間の連携に IPC 通信を利用します。これもデフォルトでは、 preload スクリプト(後述) を用意しない限り、そのままでは利用できません。

しかし、ここではこの制限をいったん解除して話を続けていきます。メインプロセスへオプションを追加しましょう。

main.js 内の createWindow() 関数に以下を追記します。

main.js
 const createWindow = () => {
   const mainWindow = new BrowserWindow({
     width: 600,
     height: 400,
     title: 'マイアプリ',
+    webPreferences: {
+      nodeIntegration: true,
+      contextIsolation: false,
+    },
   });

   mainWindow.loadFile('index.html');
 };

webPreferences では、ウェブページの機能の設定を行います。

  • nodeIntegration : レンダラープロセスが Node.js の機能を利用できるようにします(デフォルトは false
  • contextIsolation : メインプロセスとレンダラープロセスの JavaScript コンテキストを分離します(デフォルトは true

https://www.electronjs.org/docs/tutorial/security#2-do-not-enable-nodejs-integration-for-remote-content

https://www.electronjs.org/docs/latest/tutorial/context-isolation/

ipcMainipcRenderer

  • メインプロセスでのメッセージの受信・返信には ipcMain モジュール、送信には BrowserWindow.webcontents.send インスタンスメソッドを利用します
  • 一方、レンダラープロセスでのメッセージの送受信には ipcRenderer モジュールが用意されています

ipcMain.handle()

メインプロセスへ ipcMain モジュールと dialog モジュールをインポートしましょう。

main.js
- const { app, BrowserWindow } = require('electron');
+ const { app, BrowserWindow, ipcMain, dialog } = require('electron');

この本では dialog API の詳解は省略します。
メインプロセスで使える API については、公式ドキュメントの API セクションをあたることをお勧めします。

createWindow() メソッドに ipcMain による処理を追記しましょう。

レンダラープロセスから 'open-dialog' チャンネル へのメッセージを受け取るとファイル選択ダイアログを表示し、選択されたファイルのフルパスを返信します。

main.js
 const createWindow = () => {
   // 省略

+  ipcMain.handle('open-dialog', async (_e, _arg) => {
+    return dialog
+      .showOpenDialog(mainWindow, {
+        properties: ['openFile'],
+      })
+      .then((result) => {
+        if (result.canceled) return '';
+        return result.filePaths[0];
+      });
+  });

   // 省略
 };

非同期メソッドである ipcMain.handle() は、レンダラープロセスから 指定のチャンネルイベントと引数(ここでは空です)を受信すると、その後の処理結果をレンダラープロセスへ返信として送信します。

ipcMain.handle() 構文
ipcMain.handle(
  channel: string,
  listener: (event: Electron.IpcMainInvokeEvent, ...arg: any[]) => Promise<any>
) => void;

ipcRenderer.invoke()

レンダラープロセスへは、ボタンと文字列置き場を追加しましょう。

index.html
   <body>
     <h1>Hello, world.</h1>
+    <button id="button">Open</button>
+    <p id="text"></p>

     <script src="app.js"></script>
   </body>

fig11

レンダラープロセスのスクリプトでも ipcRenderer モジュールをインポートします。

app.js
const { ipcRenderer } = require('electron');

メインプロセスで nodeIntegration: true を設定しているため、レンダラープロセスでも一時的に CommonJSrequire 文 が利用できます。

ボタンとテキスト置き場のエレメントを取得し、button へクリックイベントリスナーを設定します。

app.js
const button = document.getElementById('button');
const text = document.getElementById('text');

ボタンがクリックされると、メインプロセスの open-dialog チャンネルへリクエストを送信します。

app.js
button.addEventListener('click', async () => {
  text.textContent = ipcRenderer.invoke('open-dialog');
});

同じく非同期メソッドである ipcRenderer.invoke() は、メインプロセスの 指定のチャンネルイベントと引数(ここでは空です)を送信し、その処理結果をメインプロセスから受信します。

ipcRenderer.invoke() 構文
ipcRenderer.invoke(channel: string, ...args: any[]) => Promise<any>;

実行結果

メインプロセスにより開かれたダイアログで選択したファイルのフルパスが、レンダラープロセスのテキスト置き場エレメント内に描画されます。

fig12

この章でのソースコード全文

index.html (クリックで展開)
index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="Content-Security-Policy" content="default-src 'self'" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>マイアプリ</title>
  </head>
  <body>
    <h1>Hello, world.</h1>
    <button id="button">Open</button>
    <p id="text"></p>

    <script src="app.js"></script>
  </body>
</html>
main.js (クリックで展開)
main.js
const { app, BrowserWindow, ipcMain, dialog } = require('electron');

const createWindow = () => {
  const mainWindow = new BrowserWindow({
    width: 600,
    height: 400,
    title: 'マイアプリ',
    webPreferences: {
      nodeIntegration: true,
      contextIsolation: false,
    },
  });

  ipcMain.handle('open-dialog', async (_e, _arg) => {
    return dialog
      .showOpenDialog(mainWindow, {
        properties: ['openFile'],
      })
      .then((result) => {
        if (result.canceled) return '';
        return result.filePaths[0];
      });
  });

  mainWindow.loadFile('index.html');
};

app.once('ready', () => {
  createWindow();
});

app.once('window-all-closed', () => app.quit());
app.js (クリックで展開)
app.js
const { ipcRenderer } = require('electron');

const button = document.getElementById('button');
const text = document.getElementById('text');

button.addEventListener('click', async () => {
  text.textContent = ipcRenderer.invoke('open-dialog');
});
脚注
  1. あくまでも説明のための雑な表現です。 ↩︎