アカベコマイリ

HEAR NOTHING SEE NOTHING SAY NOTHING

Electron を試す 12 - IPC を contextBridge へ移行する

December 18, 2020開発Electron, TypeScript, webpack

私はこれまで Electron における Renderer プロセスからの IPC は window.require で参照した ipcRenderer により実行していた。しかしこの方法は BrowserWindownodeIntegration: true にする必要があり、安全性の面から好ましくない。そのため今後の推奨である contextBridge による設計へ移行してみる。

webPreferences

はじめに Main プロセスにおける BrowserWindowwebPreferences を定義。これまでの実装を

const window = new BrowserWindow({
    webPreferences: {
      nodeIntegration: true,
    }
  })

以下のように変更する。

const window = new BrowserWindow({
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      worldSafeExecuteJavaScript: true,
      preload: path.join(__dirname, 'preload.js')
    }
  })

個別に解説する。

nodeIntegration は Renderer プロセスに Node.js 機能の利用を許可するための値。true にすると window.require で Electron も含む Node.js モジュールが参照可能となる。まずはこれを false にして抑止。

contextIsolation は Electron 本体の処理と BrowserWindowwebContents に読み込まれた Web サイトの JavaScript 実行コンテキストを分離するための値。true にすることで Web サイトから Node.js や Electron の機能実行を抑止する。例えば後述する preloadwindow.myAPI というプロパティーなどを定義しても Web サイトによる参照は undefined となるので安全だ。この値の既定値は Electron v12 以降で true となり、特別な設定をしない限り false を設定できないようにするとのこと。

worldSafeExecuteJavaScripttrue にすると webFrame.executeJavaScript から返された値はサニタイズされる。JavaScript 実行コンテキスト間で値をやりとりする際の安全性が向上するとのこと。contextIsolation: true にしたなら同時に指定するとよいだろう。これも Electron v12 以降で規定値が true となる予定。

preload はその名のとおり BrowserWindow が読み込む JavaScript よりも前に実行される JavaScript ファイルの絶対パスを指定する。loadFile と異なり相対パスは指定不能。なお Electron の path はリリース用にパッケージ化された ASAR ファイル内に対しても有効なので __dirname 直下を指定しても OK。ファイル名に制約はないが preload であることを明示するため、そのまま preload.js とした。

preload.js

このスクリプトへ Renderer プロセスからの IPC 処理を実装。TypeScript で書いて webpack + ts-loader によりビルドする。まずは実装から。Renderer から Main への送信だけでなく、逆方向の受信もあったほうがよいのでサンプルのうち multiple-window プロジェクトから引用する。

import { contextBridge, ipcRenderer } from 'electron'
import { IpcRendererEvent } from 'electron/main'
import { IPCKey } from './Constants'

contextBridge.exposeInMainWorld('myAPI', {
  sendMessage: async (targetWindowId: number, message: string): Promise<void> =>
    await ipcRenderer.invoke(IPCKey.SendMessage, targetWindowId, message),

  createNewWindow: async (): Promise<void> =>
    await ipcRenderer.invoke(IPCKey.CreateNewWindow),

  getWindowIds: async (): Promise<number[]> =>
    await ipcRenderer.invoke(IPCKey.GetWindowIds),

  onUpdateMessage: (listener: (message: string) => void) =>
    ipcRenderer.on(
      IPCKey.UpdateMessage,
      (ev: IpcRendererEvent, message: string) => listener(message)
    ),

  onUpdateWindowIds: (listener: (windowIds: number[]) => void) =>
    ipcRenderer.on(
      IPCKey.UpdateWindowIds,
      (ev: IpcRendererEvent, windowIds: number[]) => listener(windowIds)
    )
})

contextBridgeexposeInMainWorld により JavaScript 実行コンテキスト間の仲介処理 (Bridge) を定義する。第 1 引数 apiKey は Renderer プロセスの window 配下として公開 (Expose) される名前。DOM の window 配下 (window.location など) と衝突しないように注意すること。ベタだが myAPI のような命名なら今後も標準規格になることはないだろう。

第 2 引数 apiObject として API やプロパティーを定義してゆく。ここで注意。Context Isolation で指摘されているように IPC 処理を汎用化してはいけない。

// ❌ Bad code
contextBridge.exposeInMainWorld('myAPI', {
  send: ipcRenderer.send
})

これは ipcRenderer.send を直に公開しているようなもので、非常に危険だ。そのため用途ごとに API を定義し、入力 (引数) の検証を可能とするため関数で包むことを推奨している。

// ✅ Good code
contextBridge.exposeInMainWorld('myAPI', {
  loadPreferences: () => ipcRenderer.invoke('load-prefs')
})

以上を踏まえて私もそのようにした。

受信について。Main プロセスからの送信を Renderer プロセスで受信する場合は以下のようにリスナーを登録するための API を定義すればよい。

contextBridge.exposeInMainWorld('myAPI', {
  onUpdateMessage: (listener: (message: string) => void) =>
    ipcRenderer.on(
      IPCKey.UpdateMessage,
      (ev: IpcRendererEvent, message: string) => listener(message)
    )
})

厳密にはリスナーの重複管理とか、そのオーナーである BrowserWindow が破棄された際の解除も実装したほうがよいのかもしれない。こうしたライフサイクル関連についての言及が公式ドキュメントから見つからなかったので楽観的な実装としている。

本記事を読まれた方でこのあたりの情報をお持ちの方は @akabekobeko にお知らせくださると助かります。情報があれば本記事へ追記する予定です。

preload.js をビルドする

preload.js のビルドについて。このファイルは Main プロセスと同様に Electron を参照する。そのため Main 系としてビルドすることにした。webpack としては Main と Renderer に続き、エントリー ポイントが追加されたことになる。webpack.config.ts 実装は以下。

import { Configuration } from 'webpack'

export default (env: any, argv: Configuration) => {
  const MAIN = !!(env && env.main)
  const PRELOAD = !!(env && env.preload)
  const PROD = !!(argv.mode && argv.mode === 'production')
  if (PROD) {
    process.env.NODE_ENV = 'production'
  }

  return {
    target: MAIN || PRELOAD ? 'electron-main' : 'electron-renderer',
    entry: MAIN
      ? './src/main/AppMain.ts'
      : PRELOAD
      ? './src/common/Preload.ts'
      : './src/renderer/AppRenderer.tsx',
    output: {
      path: PROD ? `${__dirname}/dist/src/assets` : `${__dirname}/src/assets`,
      filename: MAIN ? 'main.js' : PRELOAD ? 'preload.js' : 'renderer.js'
    },
    devtool: PROD ? undefined : 'inline-source-map',
    node: {
      __dirname: false,
      __filename: false
    },
    resolve: {
      extensions: ['*', '.js', '.jsx', '.ts', '.tsx']
    },
    module: {
      rules: [
        {
          test: /\.(ts|js)x?$/,
          exclude: /node_modules/,
          use: [
            {
              loader: 'ts-loader',
              options: {
                transpileOnly: true
              }
            },
            {
              loader: 'ifdef-loader',
              options: {
                env: PROD ? 'PRODUCTION' : 'DEBUG'
              }
            }
          ]
        }
      ]
    },
    externals: MAIN || PRELOAD ? [] : ['electron']
  }
}

単一の設定ファイルで Main、Renderer、Preload を env の値で分岐。これは package.json の npm-scripts から以下のように指定している。

{
  "scripts": {
    "build:js-main": "webpack --env main --mode development",
    "build:js-preload": "webpack --env preload --mode development",
    "build:js-renderer": "webpack --mode development",
    "watch:js-main": "webpack --env main --mode development --watch",
    "watch:js-preload": "webpack --env preload --mode development --watch",
    "watch:js-renderer": "webpack --mode development --watch",
    "release:js-main": "webpack --env main --mode production",
    "release:js-preload": "webpack --env preload --mode production",
    "release:js-renderer": "webpack --mode production",
  }
}

webpack 4 までは --env.main としていたが webpack 5 からは --env main のように引数の値として指定する形式に変更された。5 で従来式を実行するとエラーになるので注意すること。これらの設定により src/assets/ 直下へ main.jspreload.jsrenderer.js が揃う。

Renderer プロセスから参照する

preload.js の定義により window.myAPI が参照可能となった。しかし TypeScript で実装しているなら VS Code などから入力補完を効かせたい。そのためには型を定義する。

/**
 * Declare a type that depends on the renderer process of Electron.
 */
declare global {
  interface Window {
    myAPI: MyAPI
  }
}

/**
 * Provides an application-specific API.
 */
export type MyAPI = {
  /**
   * Create a new window.
   */
  createNewWindow: () => Promise<void>

  /**
   * Gets all existing window identifiers.
   * @returns Collection of window identifiers.
   */
  getWindowIds: () => Promise<number[]>

  /**
   * Sends a message to the specified window.
   * @param targetWindowId The identifier of target window.
   * @param message Message to be sent
   */
  sendMessage: (targetWindowId: number, message: string) => Promise<void>

  /**
   * Occurs when a message is sent to its own window.
   * @param listener Event listener.
   */
  onUpdateMessage: (listener: (message: string) => void) => void

  /**
   * Occurs when the list of window identifiers is updated by new creation or deletion.
   * @param listener Event listener.
   */
  onUpdateWindowIds: (listener: (windowIds: number[]) => void) => void
}

こんな感じの定義をしておくと window. から順に window.myAPI.sendMessage まで入力補完される。TSDoc でコメントを書けばそれも表示されるので便利だ。これらは以下のように呼び出せる。

const windowIds = await window.myAPI.getWindowIds()

React を利用しているなら Hooks か Redux の Action あたりで処理するのがよいだろう。私は後者を採用している。

import { Dispatch } from 'redux'
import { ActionType } from '../Types'

export const finishGetWindowIds = (windowIds: number[]) => ({
  type: ActionType.GetWindowIds as ActionType.GetWindowIds,
  payload: {
    windowIds
  }
})

export const getWindowIds = () => async (dispatch: Dispatch) => {
  const windowIds = await window.myAPI.getWindowIds()
  dispatch(finishGetWindowIds(windowIds))
}

まとめ

これまで仕方なく Renderer へ露出させていた require を隠蔽できて嬉しい。IPC というプロセス間を仲介する部分が独立したファイルとして括りだされた点も、運用面で好ましく感じる。

昔から Electron の nodeIntegration は危険視されていた。この問題について「なんとかできないものか?それもスマートに」と悩んでいたのだけど、公式からよい方法が提供されたことへ感謝したい。

Copyright © 2009 - 2023 akabeko.me All Rights Reserved.