electron-forgeでpreload.ts

はじめに

この記事は、electron-forgeで作成したアプリケーションにおいて、preloadスクリプトをTypeScript化する手順を記録、共有するためのものです。

セキュリティの強化を目的として、Electronではプロセス間通信の隔離が進んでいます。プロセス間通信がpreloadスクリプトに分離された結果、関連するファイル数が増え、メンテナンス工数も増えました。

preloadスクリプトをTypeScript化すると

  • コード補完による生産性向上
  • TypeScriptコンパイラー警告によるメンテナンス性向上

という利点があります。

想定する読者

この記事はすでにElectronで開発をしているユーザーに向けて書かれています。そのため、Electron自体の解説やインストール、開発環境構築に関する内容は取り扱いません。

想定する環境

  • node.js : 14.15.0
  • yarn : 1.22.5
  • typescript : 4.0.2
  • electron : 11.1.1
  • electron-forge : 6.0.0-beta.54

バージョンが異なる場合、この記事の内容はそのまま適用できない場合があります。お手元の環境のバージョンを確かめてからお読みください。

preloadスクリプトとは

preloadスクリプトとは、Electronのセキュリティを向上させる機能です。

Electronはnode.jsを内蔵しています。そのためElectronアプリケーションは自由にファイルシステムにアクセスできます。ファイルシステムは、悪意のある攻撃者にとって格好の攻撃対象となります。たとえば悪意のあるスクリプトをWeb上に公開し、次にこのスクリプトをElectronのメインプロセスからBrowserWindow内部で読み込ませます。こうした方法で攻撃者はnode.jsの機能にアクセスできてしまいます。

この問題に対処するのがpreloadスクリプトとContext Isolation(コンテキスト隔離)という機能です。コンテキスト隔離はメインプロセスとpreloadスクリプトにのみnode.js APIの実行を許可し、レンダラープロセスにはnode.jsの実行を許可しません。この隔離により、仮にレンダラープロセスに悪意のあるスクリプトを注入されても、node.jsへのアクセスを防げます。公式ドキュメントのリモートコンテンツのNode.js統合を有効にしないでくださいに理由とサンプルが示されています。

preloadスクリプトはBrowserWindowのコンストラクターオプションで指定します。メインプロセス側で読み込むpreloadスクリプトを指定できるので、悪意のあるスクリプトの混入を防げます。

electron-forgeでpreload.ts

electron-forge

A complete tool for building modern Electron applications.

electron-forgeは、webpackやTypeScriptなど最新の環境でElectronアプリケーションを構築するためのツールです。yarn create electron-appコマンドで開発から配布までをサポートしたアプリケーションテンプレートを展開してくれます。Electronの公式ドキュメントにもパッケージングツールとして取り上げられています。

このelectron-forgeでpreloadスクリプトをTypeScript化します。

preloadスクリプトを構成する

以下のようにwebpackテンプレートを使っている場合、electron-forgeは@electron-forge/plugin-webpackというプラグインを経由してスクリプトをバンドルします。

yarn create electron-app my-new-app --template=webpack
                                    ~~~~~~~~~~~~~~~~~~

このプラグインの設定はpackage.jsonにあります。

▼package.json

  "config": {
    "forge": {
      ...
      "plugins": [
        [
          "@electron-forge/plugin-webpack",
          {
            ...
            "renderer": {
              "config": "./webpack.renderer.config.js",
              "entryPoints": [
                {
                  "html": "./src/index.html",
                  "js": "./src/renderer.ts",
                  "name": "main_window",
                  "preload": {
                    "js": "./src/preload/preload.ts"
                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                  }
                }
              ]
            }
          }
        ]
      ]
    }
  }

preloadオプションを指定すると、packageコマンド時でpreloadスクリプトがwebpackバンドルされます。公式ドキュメントには記述がありませんが、preloadオプションはTypeScriptに対応しています。(関連するissue)このオプションにTypeScriptファイルを渡せば、プラグインがバンドル処理をします。

バンドルされたファイルをメインプロセススクリプトで読み込みます。@electron-forge/plugin-webpackプラグインは"name": "main_window"の設定にしたがって、生成されたファイルのパスを定数としてメインプロセススクリプトに埋め込みます。たとえばnameが"main_window"だった場合

▼src/index.ts

declare const MAIN_WINDOW_WEBPACK_ENTRY: string; //レンダラープロセススクリプトのファイルパス
declare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: string; //preloadスクリプトのファイルパス

の2つの定数がindex.tsに展開されます。この定数はelectron-forge startelectron-forge packageコマンドで、環境に合わせて自動的に置き換えられます。

このパスを参照してBrowserWindowをインスタンス化します。

▼src/index.ts

const mainWindow = new BrowserWindow({
  webPreferences: {
    preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY,
    contextIsolation: true,
  },
});
mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY);

これでpreload.tsの開発環境の準備が完了しました。

preload.tsの実装

まずは、メインプロセスとレンダラープロセスで共有するチャネル名を定義します。 @matsukazさまのコメントでご指摘いただいた通り、メインプロセスにipcRendererをimportするとwebpackバンドル時にエラーが発生します。メインプロセスとレンダラープロセスで共有するTypeScriptファイルはelectronモジュールをimportしてはいけません。

▼src/preload/IpcChannelType.ts

/**
 * ipc通信用チャネル名
 * enumで定義すると、メインプロセスとレンダラープロセスでチャネル名を共有できる。
 */
export enum IpcChannelType {
  TO_MAIN = "to-main",
  TO_RENDERER = "to-renderer",
}

つぎにpreload.tsを実装します。

▼src/preload/preload.ts

import { contextBridge, ipcRenderer, IpcRendererEvent } from "electron";
import { IpcChannelType } from "./IpcChannelType";

/**
 * APIクラス
 */
export class ContextBridgeApi {
  public static readonly API_KEY = "api";

  constructor() {}

  /**
   * サンプル : レンダラープロセスからメインプロセスへのメッセージ送信
   * @return Promiseオブジェクト
   */
  public sendToMainProcess = () => {
    return ipcRenderer
      .invoke(IpcChannelType.TO_MAIN, {
        message: "レンダラープロセスからメインプロセスへのメッセージです",
      })
      .then((result: string) => result)
      .catch((e: Error) => console.log(e));
  };

  /**
   * サンプル : メインプロセスからレンダラープロセスへのメッセージを受信した時の処理
   * @param rendererListener IpcRendererEvent受信時に実行されるコールバック関数
   */
  public onSendToRenderer = (
    rendererListener: (message: string) => void
  ) => {
    ipcRenderer.on(
      IpcChannelType.TO_RENDERER,
      (event: IpcRendererEvent, arg: string) => {
        rendererListener(arg);
      }
    );
  };
}

/**
 * contextBridgeにAPIを登録する。
 */
contextBridge.exposeInMainWorld(
  ContextBridgeApi.API_KEY,
  new ContextBridgeApi()
);

この例ではContextBridgeApiクラスをcontextBridgeとは分離して定義しています。そのため、このクラスをrenderer.tsやindex.tsからimportして型定義として使えます。

▼src/renderer.ts

import { ContextBridgeApi } from "./preload/preload";

/**
 * contextBridge.exposeInMainWorldで設定したapiオブジェクトは
 * グローバル変数windowに追加されます。
 * keyはcontextBridge.exposeInMainWorldの第一引数です。
 * このサンプルでは第一引数を"api"としています。
 */
// @ts-ignore
const api: ContextBridgeApi = window.api;

const callback = (arg: string) =>{
  console.log(arg); // => メインプロセスからレンダラープロセスへのメッセージです。
}
api.onSendToRenderer(callback);

const send = async () => {
  const result = await api.sendToMainProcess();
  console.log("result :", result); // => result : メインプロセスからの返答です。
};
send();

▼src/index.ts

import { IpcChannelType } from "./preload/IpcChannelType";

mainWindow.on("ready-to-show", () => {
  mainWindow.webContents.send(
    IpcChannelType.TO_RENDERER,
    "メインプロセスからレンダラープロセスへのメッセージです。"
  );
});

ipcMain.handle(
  IpcChannelType.TO_MAIN,
  (event, message) => {
    console.log(message); // => {message: "レンダラープロセスからメインプロセスへのメッセージです"}
    return "メインプロセスからの返答です。";
  }
);

このようにIpcChannelTypeの型定義を共有できます。プロセス間通信を変更した場合、メインプロセスやレンダラープロセスの型が違えば、TypeScriptコンパイラーが警告してくれます。

以上、ありがとうございました。