実例で解説する Electron IPC 通信の仕組み

Electron プロセス間通信(IPC)の図

Electron は、HTML、CSS、JavaScript といった Web 技術を使用して、クロスプラットフォームのデスクトップアプリケーションを開発するための最も人気のあるフレームワークの 1 つです。内部的には、Node.js を実行する メインプロセス(Main Process) と、UI を描画するために Chromium を実行する 1 つ以上の レンダラープロセス(Renderer Process) からなるマルチプロセスアーキテクチャを採用しています。

セキュリティ上のリスクを排除するため、モダンな Electron アプリケーションでは、レンダラープロセスをオペレーティングシステムから隔離しています。つまり、レンダラーの UI から直接 Node.js モジュールやシステムリソース(ファイルの読み込みやデータベースへのクエリなど)にアクセスすることはできません。

このギャップを安全に埋めるために、Electron は プロセス間通信(IPC: Inter-Process Communication) を利用しています。

本ガイドでは、Electron の IPC がどのように動作するのかを解説し、本番環境でそのまま使用できる実用的なコード例とともに、3 つの基本的な通信パターンを紹介します。


1. レンダラープロセスからメインプロセスへ(単方向 / One-Way)

このパターンは、レンダラーがレスポンスを待つことなく、メインプロセスにコマンドやアクションを送信したい場合に使用されます。一般的な例としては、UI 上のボタンをクリックしてアプリケーションウィンドウを最小化または閉じる操作が挙げられます。

これらが 3 つの主要なファイル(main.jspreload.jsrenderer.js)でどのように実装されるかを見てみましょう。

メインプロセス (main.js)

レンダラープロセスからのイベントを監視するために ipcMain.on を使用します。

const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');

function createWindow() {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false
    }
  });
  win.loadFile('index.html');
}

// レンダラーからの 'close-app' イベントを購読
ipcMain.on('close-app', () => {
  app.quit();
});

プリロードスクリプト (preload.js)

ipcRenderer モジュール全体をレンダラーに露出させるのではなく、contextBridge.exposeInMainWorld を使って安全なラッパー関数のみを露出させます。

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

contextBridge.exposeInMainWorld('electronAPI', {
  closeApp: () => ipcRenderer.send('close-app')
});

レンダラープロセス (renderer.js)

window オブジェクト上に露出された関数を呼び出します。

const closeButton = document.getElementById('close-btn');

closeButton.addEventListener('click', () => {
  window.electronAPI.closeApp();
});

2. レンダラープロセスからメインプロセスへ(双方向 / リクエスト - レスポンス)

このパターンは、レンダラーがメインプロセスにデータを要求するか、システム操作を実行させ、その結果を待つ必要がある場合(例:ファイルの読み込み、データベースへのセキュアな問い合わせなど)に使用されます。

メインプロセスで ipcMain.handle を使用し、プリロードスクリプトで ipcRenderer.invoke を使用します。

メインプロセス (main.js)

ipcMain.handle を使用してリクエストを待ち受け、非同期でデータを返します。

const { ipcMain } = require('electron');
const fs = require('fs/promises');

// 'read-file' の呼び出しを非同期で処理
ipcMain.handle('read-file', async (event, filePath) => {
  try {
    const data = await fs.readFile(filePath, 'utf-8');
    return { success: true, content: data };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

プリロードスクリプト (preload.js)

Promise を返す非同期のラッパーを露出させます。

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

contextBridge.exposeInMainWorld('electronAPI', {
  readFile: (filePath) => ipcRenderer.invoke('read-file', filePath)
});

レンダラープロセス (renderer.js)

await を使用して、返された Promise が解決されるのを待ちます。

const readBtn = document.getElementById('read-btn');

readBtn.addEventListener('click', async () => {
  const result = await window.electronAPI.readFile('/path/to/file.txt');
  if (result.success) {
    console.log('ファイル内容:', result.content);
  } else {
    console.error('ファイルの読み込みに失敗しました:', result.error);
  }
});

3. メインプロセスからレンダラープロセスへ(単方向の通知)

このパターンは、メインプロセスからレンダラープロセスへ、更新や通知を送信する必要がある場合(例:ダウンロード進捗バーの更新、アプリメニューのクリック、バックグラウンドサービスのステータス更新など)に使用されます。

メインプロセスで webContents.send を使用し、プリロードスクリプトで ipcRenderer.on を使用します。

メインプロセス (main.js)

アクティブなウィンドウの webContents を取得し、メッセージを送信します。

// 例:ダウンロードの進捗状況を送信
function trackDownloadProgress(mainWindow) {
  let progress = 0;
  const interval = setInterval(() => {
    progress += 10;
    mainWindow.webContents.send('download-progress', progress);
    
    if (progress >= 100) {
      clearInterval(interval);
    }
  }, 1000);
}

プリロードスクリプト (preload.js)

コールバック関数を受け取る購読用メソッドを露出させます。メモリリークを防ぐために、クリーンアップ(リスナーの削除)関数を返すのが良い設計です。

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

contextBridge.exposeInMainWorld('electronAPI', {
  onDownloadProgress: (callback) => {
    const subscription = (event, value) => callback(value);
    ipcRenderer.on('download-progress', subscription);
    
    // メモリリークを防ぐためのクリーンアップ関数を返す
    return () => {
      ipcRenderer.removeListener('download-progress', subscription);
    };
  }
});

レンダラープロセス (renderer.js)

イベントを購読し、UI を更新します。

const progressBar = document.getElementById('progress-bar');

const unsubscribe = window.electronAPI.onDownloadProgress((progress) => {
  progressBar.style.width = `${progress}%`;
  progressBar.textContent = `${progress}%`;
  
  if (progress === 100) {
    console.log('ダウンロード完了!');
    unsubscribe(); // メモリリークを防ぐためにリスナーを削除
  }
});

4. セキュリティのベストプラクティス

Electron IPC を扱う上で、セキュリティは最優先事項です。IPC が適切に保護されていない場合、レンダラープロセスに注入された悪意のあるコードによって、オペレーティングシステム全体が危険にさらされる可能性があります。

以下の極めて重要なルールに従ってください。

  1. ipcRenderer を絶対に直接露出させない: contextBridge.exposeInMainWorld('electron', ipcRenderer) のように記述しないでください。これを行うと、レンダラーがセキュリティの境界線をバイパスして、あらゆる IPC メッセージを自由に送信できてしまいます。
  2. コンテキストの分離(Context Isolation)を常に有効にする: webPreferences 内で、常に contextIsolation: truenodeIntegration: false を設定してください。
  3. 入力を検証する: メインプロセスで受信した引数(ファイルパスやデータベースのクエリなど)を実行する前に、必ず検証してください。
  4. ipcMain.on + webContents.send よりも ipcMain.handle を優先する: リクエスト - レスポンスの通信パターンでは、invoke/handle の方が記述がすっきりし、非同期 Promise がネイティブに扱えるため、複数のコールバックが混在するのを避けることができます。

結論

IPC 通信の仕組みを理解することは、安全で高速、かつ堅牢な Electron アプリケーションを開発するための鍵です。適切なパターンを選択し、コンテキストの分離を徹底することで、ユーザーの安全を確保しながらデスクトップ環境における Node.js のパワーを最大限に活用することができます。


Ghaznix ブログで開発者のためのインサイトをさらに探索する →