T-CREATOR

Electron アーキテクチャ超図解:Main/Renderer/Preload の役割とデータフロー

Electron アーキテクチャ超図解:Main/Renderer/Preload の役割とデータフロー

デスクトップアプリケーション開発において、Electron は非常に人気のあるフレームワークです。しかし、Main プロセス、Renderer プロセス、Preload スクリプトという 3 つの要素がどのように連携するのか、初めて学ぶ方には少し複雑に感じられるかもしれません。

この記事では、Electron のアーキテクチャを図解を交えながら、それぞれの役割とデータの流れを詳しく解説していきます。これを読めば、Electron アプリケーションの設計思想が手に取るようにわかるでしょう。

背景

Electron が生まれた理由

Electron は、Web 技術(HTML、CSS、JavaScript)を使ってデスクトップアプリケーションを開発できるフレームワークです。Visual Studio Code、Slack、Discord など、多くの有名アプリケーションが Electron で構築されています。

従来、デスクトップアプリケーションを開発するには、プラットフォームごとに異なる言語や技術が必要でした。Windows 向けには C#や C++、Mac 向けには Swift、Linux 向けには C++といった具合です。しかし Electron を使えば、Web 技術だけでクロスプラットフォームなアプリケーションを作成できるようになりました。

Electron の基本構造

Electron は、Chromium と Node.js を組み合わせた構造になっています。Chromium はブラウザエンジンとして画面の描画を担当し、Node.js はファイルシステムへのアクセスやネットワーク通信などのバックエンド処理を担当しますね。

以下の図は、Electron アプリケーションの全体像を示しています。

mermaidflowchart TB
    subgraph electron["Electron アプリケーション"]
        main["Main プロセス<br/>(Node.js 環境)"]

        subgraph window1["ウィンドウ 1"]
            preload1["Preload スクリプト"]
            renderer1["Renderer プロセス<br/>(Chromium 環境)"]
        end

        subgraph window2["ウィンドウ 2"]
            preload2["Preload スクリプト"]
            renderer2["Renderer プロセス<br/>(Chromium 環境)"]
        end
    end

    main -->|"BrowserWindow 生成"| window1
    main -->|"BrowserWindow 生成"| window2
    preload1 -.->|"contextBridge"| renderer1
    preload2 -.->|"contextBridge"| renderer2
    renderer1 <-->|"IPC 通信"| main
    renderer2 <-->|"IPC 通信"| main

図の要点:Main プロセスが中心となり、複数のウィンドウ(Renderer プロセス)を管理し、各ウィンドウに Preload スクリプトが橋渡し役として機能しています。

プロセス分離の必要性

なぜ Electron はプロセスを分離する設計になっているのでしょうか。

これはセキュリティと安定性を確保するためです。Web ページ(Renderer プロセス)から直接ファイルシステムや OS の機能にアクセスできてしまうと、悪意のあるコードによってユーザーのシステムが危険にさらされる可能性があります。

そのため、Electron では権限を持つ Main プロセスと、制限された Renderer プロセスを分離し、両者の間で安全な通信を行う仕組みを採用しているのです。

課題

セキュリティリスクの管理

Electron アプリケーション開発における最大の課題は、セキュリティの確保です。Web 技術を使う以上、クロスサイトスクリプティング(XSS)やコードインジェクションなどの脅威にさらされます。

特に以下のような問題が発生しやすいです。

#課題リスク内容
1nodeIntegration の有効化Renderer から直接 Node.js API にアクセス可能になり、任意のコード実行のリスク
2contextIsolation の無効化Preload スクリプトと Renderer が同じコンテキストを共有し、情報漏洩のリスク
3IPC 通信の不適切な実装入力検証の不足により、予期しない動作や権限昇格のリスク

以下の図は、セキュリティ設定が不適切な場合の危険なデータフローを示しています。

mermaidflowchart LR
    web["外部Webコンテンツ"] -->|"XSS攻撃"| renderer["Renderer プロセス"]
    renderer -->|"nodeIntegration: true<br/>制限なしアクセス"| nodejs["Node.js API"]
    nodejs -->|"ファイル削除<br/>情報窃取"| os["OS リソース"]

    style web fill:#ff6b6b
    style nodejs fill:#ff6b6b
    style os fill:#ff6b6b

図の要点:nodeIntegration を有効にすると、Renderer プロセスが直接 Node.js API にアクセスでき、セキュリティホールとなります。

プロセス間通信の複雑さ

Main プロセスと Renderer プロセスが分離されているため、両者間でデータをやり取りする際に IPC(Inter-Process Communication)という仕組みを使う必要があります。

この仕組みは強力ですが、以下のような複雑さを伴います。

初めて実装する際には、以下のような疑問が湧いてくるでしょう。

  • どのプロセスでどのコードを書けばいいのか
  • データはどのように送受信すればいいのか
  • 非同期処理をどう扱えばいいのか

コードの役割分担の不明確さ

Electron アプリケーションでは、Main、Renderer、Preload の 3 つの実行環境があるため、どこに何を書くべきか迷うことがあります。

特に以下のような機能を実装する際に悩みやすいです。

  • ファイルの読み書き
  • データベースへのアクセス
  • 外部 API との通信
  • ウィンドウの制御

これらの役割分担を正しく理解していないと、メンテナンスしにくいコードになってしまいます。

解決策

Main プロセスの役割と実装

Main プロセスは、Electron アプリケーションの中核となる部分です。Node.js 環境で動作し、アプリケーションのライフサイクルを管理します。

Main プロセスの主な責務

#責務具体例
1アプリケーションライフサイクル管理起動、終了、ウィンドウの生成・破棄
2ウィンドウ管理BrowserWindow の生成、サイズ変更、最小化・最大化
3システムリソースへのアクセスファイルシステム、ネイティブダイアログ、メニュー
4IPC 通信のハンドリングRenderer からのリクエスト処理、イベント発火

まず、基本的な Main プロセスのエントリーポイントを見ていきましょう。

typescript// main.ts - アプリケーションのエントリーポイント
import { app, BrowserWindow } from 'electron';
import * as path from 'path';

// アプリケーションの準備完了時に実行
app.whenReady().then(() => {
  createWindow();
});

このコードでは、Electron アプリケーションが起動準備を完了したタイミングでウィンドウを生成しています。app.whenReady()は Promise を返すため、非同期処理として扱えますね。

次に、ウィンドウを生成する関数を実装します。

typescript// main.ts - ウィンドウ生成関数
function createWindow(): void {
  // メインウィンドウの作成
  const mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    webPreferences: {
      // セキュリティのための重要な設定
      nodeIntegration: false, // Rendererから直接Node.jsを使わせない
      contextIsolation: true, // PreloadとRendererのコンテキストを分離
      preload: path.join(__dirname, 'preload.js'), // Preloadスクリプトのパス
    },
  });

  // HTMLファイルを読み込み
  mainWindow.loadFile('index.html');
}

webPreferencesの設定が非常に重要です。nodeIntegration: falsecontextIsolation: trueにより、Renderer プロセスからの不正なアクセスを防ぎます。

続いて、アプリケーションのライフサイクルを管理するコードを追加しましょう。

typescript// main.ts - ライフサイクル管理
// すべてのウィンドウが閉じられたときの処理
app.on('window-all-closed', () => {
  // macOS以外ではアプリケーションを終了
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

// アプリがアクティブになったときの処理(macOS向け)
app.on('activate', () => {
  // ウィンドウがない場合は新規作成
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow();
  }
});

macOS では、すべてのウィンドウが閉じられてもアプリケーションは終了しないという慣習があるため、プラットフォームごとに挙動を変えています。

Renderer プロセスの役割と実装

Renderer プロセスは、ユーザーインターフェースを描画する部分です。Chromium 環境で動作し、通常の Web ページと同じように HTML、CSS、JavaScript を使えます。

Renderer プロセスの主な責務

#責務具体例
1UI の描画とレンダリングHTML/CSS による画面表示
2ユーザー操作の処理クリック、入力、ドラッグ&ドロップ
3Main プロセスへのリクエストIPC 経由でのデータ取得・保存要求
4受信データの表示Main から受け取ったデータを UI に反映

まず、基本的な HTML ファイルを作成します。

html<!-- index.html - Rendererプロセスで表示されるHTML -->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self'"
    />
    <title>Electron App</title>
  </head>
  <body>
    <h1>Electronアプリケーション</h1>
    <button id="readFileBtn">ファイルを読み込む</button>
    <div id="fileContent"></div>

    <!-- Rendererプロセスのスクリプト -->
    <script src="renderer.js"></script>
  </body>
</html>

Content Security Policy を設定することで、外部からのスクリプト実行を防ぎ、セキュリティを強化しています。

次に、Renderer プロセスで動作する JavaScript を実装します。

typescript// renderer.ts - Rendererプロセスのスクリプト
// PreloadスクリプトでwindowオブジェクトにセットされたAPIを使用
const { electronAPI } = window as any;

// ボタンクリック時の処理
document
  .getElementById('readFileBtn')
  ?.addEventListener('click', async () => {
    try {
      // Mainプロセスにファイル読み込みを依頼
      const content = await electronAPI.readFile(
        'sample.txt'
      );

      // 取得したコンテンツを表示
      const contentDiv =
        document.getElementById('fileContent');
      if (contentDiv) {
        contentDiv.textContent = content;
      }
    } catch (error) {
      console.error('ファイル読み込みエラー:', error);
    }
  });

Renderer プロセスでは、直接 Node.js の API を呼び出すことはできません。代わりに、Preload スクリプトで公開された API を通じて Main プロセスと通信します。

UI の状態を管理する処理も追加してみましょう。

typescript// renderer.ts - UI状態管理
// Mainプロセスからの通知を受け取る
electronAPI.onFileUpdate((fileName: string) => {
  console.log(`ファイルが更新されました: ${fileName}`);

  // UIに通知を表示
  const notification = document.createElement('div');
  notification.className = 'notification';
  notification.textContent = `${fileName} が更新されました`;
  document.body.appendChild(notification);

  // 3秒後に通知を削除
  setTimeout(() => {
    notification.remove();
  }, 3000);
});

この仕組みにより、Main プロセスからの双方向通信が可能になります。

Preload スクリプトの役割と実装

Preload スクリプトは、Main プロセスと Renderer プロセスの橋渡し役として機能します。Node.js 環境で動作しながら、Renderer プロセスのコンテキストにアクセスできる特殊な位置づけです。

Preload スクリプトの主な責務

#責務具体例
1安全な API の公開contextBridge で Renderer に必要最小限の API を提供
2IPC 通信の抽象化ipcRenderer を直接公開せず、ラップした関数を提供
3データの検証とサニタイズRenderer から受け取ったデータの妥当性確認

以下の図は、Preload スクリプトがどのように機能するかを示しています。

mermaidflowchart LR
    renderer["Renderer プロセス"] -->|"window.electronAPI"| bridge["contextBridge"]
    bridge -->|"expose API"| preload["Preload スクリプト"]
    preload -->|"ipcRenderer.invoke"| main["Main プロセス"]
    main -->|"ipcMain.handle"| handler["ハンドラ関数"]
    handler -->|"return value"| main
    main -->|"response"| preload
    preload -->|"return"| renderer

    style bridge fill:#4ecdc4
    style preload fill:#4ecdc4

図の要点:contextBridge が Renderer と Preload の間で安全に API を公開し、ipcRenderer を通じて Main プロセスと通信します。

それでは、Preload スクリプトの実装を見ていきましょう。

typescript// preload.ts - 基本構造とインポート
import { contextBridge, ipcRenderer } from 'electron';

// Rendererプロセスに公開するAPIの型定義
interface ElectronAPI {
  readFile: (fileName: string) => Promise<string>;
  writeFile: (
    fileName: string,
    content: string
  ) => Promise<void>;
  onFileUpdate: (
    callback: (fileName: string) => void
  ) => void;
}

型定義により、TypeScript の恩恵を受けながら安全に API を設計できます。

次に、contextBridge を使って API を公開します。

typescript// preload.ts - contextBridgeでAPIを公開
contextBridge.exposeInMainWorld('electronAPI', {
  // ファイル読み込みAPI
  readFile: (fileName: string): Promise<string> => {
    // Mainプロセスの'read-file'ハンドラを呼び出し
    return ipcRenderer.invoke('read-file', fileName);
  },

  // ファイル書き込みAPI
  writeFile: (
    fileName: string,
    content: string
  ): Promise<void> => {
    // Mainプロセスの'write-file'ハンドラを呼び出し
    return ipcRenderer.invoke(
      'write-file',
      fileName,
      content
    );
  },

  // Mainプロセスからの通知を受け取るAPI
  onFileUpdate: (
    callback: (fileName: string) => void
  ): void => {
    // 'file-updated'イベントをリスン
    ipcRenderer.on('file-updated', (_event, fileName) => {
      callback(fileName);
    });
  },
} as ElectronAPI);

contextBridge.exposeInMainWorldにより、Renderer プロセスのwindowオブジェクトに安全に API が追加されます。ipcRendererを直接公開しないことで、セキュリティが確保されますね。

IPC 通信の実装

IPC は、Main プロセスと Renderer プロセス間でデータをやり取りするための仕組みです。主に以下の 2 つの方法があります。

#方法用途特徴
1invoke / handle非同期リクエスト-レスポンス戻り値が必要な処理(ファイル読み込みなど)
2send / onイベント送信一方向の通知(進捗更新など)

まず、Main プロセス側で IPC ハンドラを実装します。

typescript// main.ts - IPC ハンドラの実装(インポート部分)
import { app, BrowserWindow, ipcMain } from 'electron';
import * as fs from 'fs/promises';
import * as path from 'path';

必要なモジュールをインポートしています。fs​/​promisesを使うことで、非同期処理が簡潔に書けます。

次に、ファイル読み込みのハンドラを実装します。

typescript// main.ts - ファイル読み込みハンドラ
// アプリ起動時にIPCハンドラを登録
app.whenReady().then(() => {
  // 'read-file'リクエストのハンドラ
  ipcMain.handle(
    'read-file',
    async (_event, fileName: string) => {
      try {
        // ファイルパスの検証(セキュリティ対策)
        const safePath = path.join(
          app.getPath('userData'),
          fileName
        );

        // ファイル内容を読み込んで返す
        const content = await fs.readFile(
          safePath,
          'utf-8'
        );
        return content;
      } catch (error) {
        console.error('ファイル読み込みエラー:', error);
        throw new Error(
          `ファイル読み込み失敗: ${fileName}`
        );
      }
    }
  );
});

app.getPath('userData')を使うことで、アプリケーション専用のディレクトリ内に限定し、任意のパスへのアクセスを防いでいます。

ファイル書き込みのハンドラも実装しましょう。

typescript// main.ts - ファイル書き込みハンドラ
ipcMain.handle(
  'write-file',
  async (_event, fileName: string, content: string) => {
    try {
      // ファイルパスの検証
      const safePath = path.join(
        app.getPath('userData'),
        fileName
      );

      // 入力値の検証
      if (typeof content !== 'string') {
        throw new Error(
          'コンテンツは文字列である必要があります'
        );
      }

      // ファイルに書き込み
      await fs.writeFile(safePath, content, 'utf-8');

      // すべてのウィンドウに更新通知を送信
      BrowserWindow.getAllWindows().forEach((window) => {
        window.webContents.send('file-updated', fileName);
      });
    } catch (error) {
      console.error('ファイル書き込みエラー:', error);
      throw new Error(`ファイル書き込み失敗: ${fileName}`);
    }
  }
);

ファイル書き込み後、すべてのウィンドウに通知を送ることで、複数ウィンドウ間でのデータ同期が実現できます。

以下は、双方向の IPC 通信フローを示した図です。

mermaidsequenceDiagram
    participant R as Renderer
    participant P as Preload
    participant M as Main
    participant FS as FileSystem

    R->>P: electronAPI.readFile('sample.txt')
    P->>M: ipcRenderer.invoke('read-file', 'sample.txt')
    M->>M: パス検証
    M->>FS: fs.readFile()
    FS-->>M: ファイル内容
    M-->>P: return content
    P-->>R: return content
    R->>R: UIに表示

    Note over R,FS: 書き込み時の通知フロー
    R->>P: electronAPI.writeFile('sample.txt', 'new content')
    P->>M: ipcRenderer.invoke('write-file', ...)
    M->>FS: fs.writeFile()
    FS-->>M: 完了
    M->>R: send('file-updated', 'sample.txt')
    R->>R: 通知表示

図の要点:リクエスト-レスポンスの流れと、書き込み後の通知がどのように伝播するかが明確になっています。

具体例

ファイル管理アプリケーションの実装

ここでは、実際にファイルの一覧表示、読み込み、書き込み機能を持つシンプルなアプリケーションを実装してみましょう。

まず、プロジェクトの構造を確認します。

perlmy-electron-app/
├── src/
│   ├── main.ts          # Mainプロセス
│   ├── preload.ts       # Preloadスクリプト
│   ├── renderer.ts      # Rendererプロセス
│   └── index.html       # UIのHTML
├── package.json
└── tsconfig.json

それでは、Main プロセスに複数の機能を追加していきます。

typescript// main.ts - ファイル一覧取得機能
import {
  app,
  BrowserWindow,
  ipcMain,
  dialog,
} from 'electron';
import * as fs from 'fs/promises';
import * as path from 'path';

// ファイル一覧を取得するハンドラ
ipcMain.handle('list-files', async () => {
  try {
    const userDataPath = app.getPath('userData');
    const files = await fs.readdir(userDataPath);

    // ファイル情報を詳細に取得
    const fileInfos = await Promise.all(
      files.map(async (fileName) => {
        const filePath = path.join(userDataPath, fileName);
        const stats = await fs.stat(filePath);

        return {
          name: fileName,
          size: stats.size,
          modified: stats.mtime,
          isDirectory: stats.isDirectory(),
        };
      })
    );

    return fileInfos;
  } catch (error) {
    console.error('ファイル一覧取得エラー:', error);
    throw new Error('ファイル一覧の取得に失敗しました');
  }
});

Promise.allを使うことで、複数のファイル情報を並列で取得し、パフォーマンスを向上させています。

次に、ファイル選択ダイアログを表示する機能を追加します。

typescript// main.ts - ファイル選択ダイアログ
ipcMain.handle('select-file', async (event) => {
  // ダイアログを開く
  const result = await dialog.showOpenDialog({
    properties: ['openFile'],
    filters: [
      {
        name: 'テキストファイル',
        extensions: ['txt', 'md'],
      },
      { name: 'すべてのファイル', extensions: ['*'] },
    ],
  });

  // キャンセルされた場合
  if (result.canceled) {
    return null;
  }

  // 選択されたファイルのパス
  const filePath = result.filePaths[0];

  // ファイル内容を読み込んで返す
  const content = await fs.readFile(filePath, 'utf-8');

  return {
    path: filePath,
    name: path.basename(filePath),
    content: content,
  };
});

ネイティブのファイル選択ダイアログを使うことで、OS に統合された自然な UI を提供できます。

Preload スクリプトに新しい API を追加しましょう。

typescript// preload.ts - 拡張されたAPI定義
interface FileInfo {
  name: string;
  size: number;
  modified: Date;
  isDirectory: boolean;
}

interface SelectedFile {
  path: string;
  name: string;
  content: string;
}

interface ElectronAPI {
  // 既存のAPI
  readFile: (fileName: string) => Promise<string>;
  writeFile: (
    fileName: string,
    content: string
  ) => Promise<void>;
  onFileUpdate: (
    callback: (fileName: string) => void
  ) => void;

  // 新しいAPI
  listFiles: () => Promise<FileInfo[]>;
  selectFile: () => Promise<SelectedFile | null>;
}

型定義を明確にすることで、Renderer プロセスでの実装時に補完が効き、ミスを減らせますね。

contextBridge で新しい API を公開します。

typescript// preload.ts - 新しいAPIの公開
contextBridge.exposeInMainWorld('electronAPI', {
  // 既存のAPI(省略)

  // ファイル一覧取得
  listFiles: (): Promise<FileInfo[]> => {
    return ipcRenderer.invoke('list-files');
  },

  // ファイル選択ダイアログ
  selectFile: (): Promise<SelectedFile | null> => {
    return ipcRenderer.invoke('select-file');
  },
} as ElectronAPI);

次に、Renderer プロセスでこれらの機能を使って UI を実装します。

html<!-- index.html - UIの拡張 -->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>ファイル管理アプリ</title>
    <style>
      body {
        font-family: sans-serif;
        padding: 20px;
      }
      .file-list {
        margin: 20px 0;
      }
      .file-item {
        padding: 10px;
        border-bottom: 1px solid #ddd;
      }
      .file-item:hover {
        background-color: #f0f0f0;
      }
      button {
        margin: 5px;
        padding: 10px 20px;
      }
      #editor {
        width: 100%;
        height: 300px;
        margin-top: 20px;
      }
    </style>
  </head>
  <body>
    <h1>ファイル管理アプリケーション</h1>

    <div>
      <button id="listFilesBtn">ファイル一覧を更新</button>
      <button id="selectFileBtn">ファイルを開く</button>
      <button id="saveFileBtn">保存</button>
    </div>

    <div id="fileList" class="file-list"></div>

    <textarea
      id="editor"
      placeholder="ここにテキストを入力..."
    ></textarea>

    <script src="renderer.js"></script>
  </body>
</html>

シンプルながら必要な機能がすべて揃った UI です。

Renderer プロセスでファイル一覧表示機能を実装します。

typescript// renderer.ts - ファイル一覧表示
const { electronAPI } = window as any;

// ファイル一覧を表示する関数
async function displayFileList(): Promise<void> {
  try {
    const files = await electronAPI.listFiles();
    const fileListDiv = document.getElementById('fileList');

    if (!fileListDiv) return;

    // ファイル一覧をHTMLに変換
    fileListDiv.innerHTML = files
      .filter((file: any) => !file.isDirectory)
      .map((file: any) => {
        const sizeKB = (file.size / 1024).toFixed(2);
        const modified = new Date(
          file.modified
        ).toLocaleString('ja-JP');

        return `
          <div class="file-item">
            <strong>${file.name}</strong><br>
            サイズ: ${sizeKB} KB | 更新: ${modified}
          </div>
        `;
      })
      .join('');
  } catch (error) {
    console.error('ファイル一覧表示エラー:', error);
  }
}

// ボタンクリック時にファイル一覧を更新
document
  .getElementById('listFilesBtn')
  ?.addEventListener('click', displayFileList);

// 初期表示
displayFileList();

ファイル情報を見やすくフォーマットして表示しています。

ファイル選択と編集機能も実装しましょう。

typescript// renderer.ts - ファイル選択と編集
let currentFilePath: string | null = null;

// ファイル選択ボタンのクリックハンドラ
document
  .getElementById('selectFileBtn')
  ?.addEventListener('click', async () => {
    try {
      const file = await electronAPI.selectFile();

      if (file) {
        // エディタにファイル内容を表示
        const editor = document.getElementById(
          'editor'
        ) as HTMLTextAreaElement;
        if (editor) {
          editor.value = file.content;
          currentFilePath = file.path;
        }

        console.log(`ファイルを開きました: ${file.name}`);
      }
    } catch (error) {
      console.error('ファイル選択エラー:', error);
    }
  });

選択したファイルの内容をエディタに読み込み、編集可能にしています。

最後に、保存機能を実装します。

typescript// renderer.ts - ファイル保存
document
  .getElementById('saveFileBtn')
  ?.addEventListener('click', async () => {
    try {
      const editor = document.getElementById(
        'editor'
      ) as HTMLTextAreaElement;

      if (!editor || !currentFilePath) {
        alert('ファイルが選択されていません');
        return;
      }

      // エディタの内容を取得
      const content = editor.value;
      const fileName =
        currentFilePath.split('/').pop() || 'file.txt';

      // ファイルに書き込み
      await electronAPI.writeFile(fileName, content);

      alert('保存しました');

      // ファイル一覧を更新
      await displayFileList();
    } catch (error) {
      console.error('ファイル保存エラー:', error);
      alert('保存に失敗しました');
    }
  });

これで基本的なファイル管理アプリケーションが完成しました。

アーキテクチャ全体のデータフロー

ここまで実装してきた機能全体のデータフローを図で確認しましょう。

mermaidflowchart TB
    subgraph ui["UI層 (Renderer)"]
        button["ボタンクリック"]
        editor["テキストエディタ"]
        list["ファイル一覧"]
    end

    subgraph api["API層 (Preload)"]
        bridge["contextBridge API"]
        validate["データ検証"]
    end

    subgraph logic["ロジック層 (Main)"]
        ipc["IPC ハンドラ"]
        business["ビジネスロジック"]
    end

    subgraph resource["リソース層"]
        fs["ファイルシステム"]
        dialog["ネイティブダイアログ"]
        os["OS API"]
    end

    button --> bridge
    bridge --> validate
    validate --> ipc
    ipc --> business
    business --> fs
    business --> dialog
    business --> os

    fs -.->|"データ返却"| business
    dialog -.->|"ユーザー選択"| business
    business -.->|"レスポンス"| ipc
    ipc -.->|"結果"| bridge
    bridge -.->|"データ"| editor
    bridge -.->|"データ"| list

図の要点:各層が明確に分離され、データが一方向に流れることで、保守性の高いアーキテクチャが実現されています。

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

実装したアプリケーションにセキュリティ対策を追加しましょう。

typescript// main.ts - セキュリティ強化されたウィンドウ設定
function createSecureWindow(): BrowserWindow {
  const mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    webPreferences: {
      // 必須のセキュリティ設定
      nodeIntegration: false, // Node.js統合を無効化
      contextIsolation: true, // コンテキスト分離を有効化
      sandbox: true, // サンドボックスを有効化

      // 追加のセキュリティ設定
      webSecurity: true, // 同一オリジンポリシーを有効化
      allowRunningInsecureContent: false, // 混在コンテンツを禁止

      preload: path.join(__dirname, 'preload.js'),
    },
  });

  // Content Security Policy を設定
  mainWindow.webContents.session.webRequest.onHeadersReceived(
    (details, callback) => {
      callback({
        responseHeaders: {
          ...details.responseHeaders,
          'Content-Security-Policy': [
            "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'",
          ],
        },
      });
    }
  );

  return mainWindow;
}

これらの設定により、XSS 攻撃やコードインジェクションのリスクを大幅に軽減できます。

Preload スクリプトでの入力検証も重要です。

typescript// preload.ts - 入力検証付きAPI
contextBridge.exposeInMainWorld('electronAPI', {
  writeFile: (
    fileName: string,
    content: string
  ): Promise<void> => {
    // ファイル名の検証
    if (!fileName || typeof fileName !== 'string') {
      return Promise.reject(
        new Error('無効なファイル名です')
      );
    }

    // パストラバーサル攻撃を防ぐ
    if (
      fileName.includes('..') ||
      fileName.includes('/') ||
      fileName.includes('\\')
    ) {
      return Promise.reject(
        new Error('ファイル名に不正な文字が含まれています')
      );
    }

    // コンテンツの検証
    if (typeof content !== 'string') {
      return Promise.reject(
        new Error('コンテンツは文字列である必要があります')
      );
    }

    // サイズ制限(例: 10MB)
    if (content.length > 10 * 1024 * 1024) {
      return Promise.reject(
        new Error('ファイルサイズが大きすぎます')
      );
    }

    return ipcRenderer.invoke(
      'write-file',
      fileName,
      content
    );
  },
});

クライアント側での検証は必須です。悪意のあるデータが Main プロセスに到達する前にブロックできますね。

以下は、セキュリティが確保されたアーキテクチャの図です。

mermaidflowchart TB
    subgraph secure["セキュアな境界"]
        renderer["Renderer<br/>(制限された環境)"]

        subgraph isolation["コンテキスト分離"]
            preload["Preload<br/>(検証層)"]
        end

        main["Main<br/>(特権環境)"]
    end

    subgraph checks["セキュリティチェック"]
        csp["CSP ヘッダー"]
        validate["入力検証"]
        sanitize["パス検証"]
    end

    renderer -->|"contextBridge のみ"| preload
    csp -.->|"保護"| renderer
    preload -->|"検証済みデータ"| validate
    validate --> sanitize
    sanitize --> main

    style isolation fill:#90EE90
    style checks fill:#FFD700

図の要点:各層でセキュリティチェックを行い、多層防御によって安全性を確保しています。

まとめ

Electron アーキテクチャの核心は、Main、Renderer、Preload という 3 つのプロセス間の役割分担とデータフローにあります。

Main プロセスは、Node.js 環境で動作し、アプリケーションのライフサイクル管理、ウィンドウ制御、システムリソースへのアクセスを担当します。特権を持つプロセスとして、セキュリティ上重要な処理を一手に引き受けますね。

Renderer プロセスは、Chromium 環境で動作し、ユーザーインターフェースの描画とユーザー操作の処理を担います。セキュリティのために制限された環境で動作し、システムリソースへの直接アクセスはできません。

Preload スクリプトは、両者の橋渡し役として機能し、contextBridge を通じて安全な API を Renderer に公開します。この層で入力検証を行うことで、セキュリティが大幅に向上します。

IPC 通信により、プロセス間で安全にデータをやり取りでき、invoke​/​handleパターンで非同期リクエスト-レスポンスを、send​/​onパターンでイベント通知を実現できました。

セキュリティのベストプラクティスとして、nodeIntegration: falsecontextIsolation: truesandbox: trueの設定は必須です。さらに、Content Security Policy の適用、入力検証、パストラバーサル対策も欠かせません。

このアーキテクチャを理解すれば、保守性が高く、セキュアな Electron アプリケーションを設計できるようになるでしょう。

関連リンク