【Electron】contextBridgeでレンダラープロセスへAPIを公開する

こんにちは、しきゆらです。
今回は、最近遊んでいるElectronで詰まったところをメモしておきます。

Electronとは、というところはググればいろいろ出てくるのであまり書きません。
個人的には、Webの技術でデスクトップアプリを作れる便利ツールという認識です。

さて、まずcontextBridgeとは何ぞや、というところですが、私もちゃんと触り始めたのは最近なので詳しくはないので書きません。
(というか、書けません)
詳しくは公式ドキュメントあたりを見てくださいな。
https://www.electronjs.org/docs/api/context-bridge
https://www.electronjs.org/docs/tutorial/context-isolation

個人的理解では、レンダラープロセスからNode関連を使うために、扱える範囲を絞るための機能というイメージです。

では、contextBridge経由でAPIを公開するところで詰まったところと、解決方法をメモしていきます。

なお、実行環境等は以下の通り。

  • OS: Ubuntu 21.10 on WSL2
  • electron: 13.1.7
  • webpack: 5.45.1

状況と簡単なファイル構成

contextBridgeBrowserWindowオブジェクトを作る時の引数webPreference.preloadで対象ファイルを渡してあげることでレンダラープロセスへAPIを公開できます。
そこで、対象ファイルを作って読ませるわけですが、今回はここで大きくつまずきました。
この時のファイル構成は以下の通り。

 > tree -N --dirsfirst -I ".yarn|.git"  
 ├── dist // webpackでビルドしたもの
 │   ├── scripts
 │   │   ├── index.html
 │   │   ├── main.css
 │   │   └── renderer.js
 │   ├── main.js
 │   └── preload.js
 ├── src
 │   ├── renderer
 │   │   ├── style
 │   │   │   ├── partials
 │   │   │   │   └── // SCSSファイルの分割置き場
 │   │   │   └── main.scss
 │   │   ├── index.html
 │   │   └── renderer.tsx
 │   ├── main.ts
 │   └── preload.ts // 今回の主役
 ├── package.json
 ├── tsconfig.eslint.json
 ├── tsconfig.json
 ├── webpack.config.js
 └── yarn.lock

ざっくり、メインプロセスとなる「src/main.ts」とレンダラープロセスとなる「src/renderer/renderer.tsx」、レンダラープロセスへAPIを公開するための「src/preload.ts」という構成です。
なお、レンダラープロセスではReactを使ってページを作っている関係でTSX形式になっています。

それぞれのファイルの中身を抜粋したものを置いておきます。

// 画面作成部分のみ抜粋
app.on("create", () => {
    this.mainWindow = new BrowserWindow({
        width: 1000,
        height: 500,
        minWidth: 500,
        minHeight: 200,
        acceptFirstMouse: true,
        titleBarStyle: "hidden",
        webPreferences: {
            nodeIntegration: false, // この2行は最近のElectronではデフォルトのようなので設定不要? 
            contextIsolation: true, //   
            preload: path.resolve(__dirname, "preload.js"), // preloadでファイルを読ませる
        }
    });
    this.mainWindow.loadFile(path.resolve(__dirname, "scripts/index.html"));
    
    this.mainWindow.webContents.openDevTools();
    this.mainWindow.on("closed", () => {
        this.mainWindow = null;
    });
})
import { contextBridge } from "electron";

console.log("preload!!!"); // preloadファイルが読み込まれているかの確認

contextBridge.exposeInMainWorld("myAPI", {
    electron: true, // window.myAPIで値のやり取りができるかのテスト用
})
import React from "react";
import ReactDOM from "react-dom";

// StyleSheet
import "./style/main.scss";

ReactDOM.render(
  <div>Hello React</div>,
  document.getElementById("container")
);
console.log(window.myAPI) // contextBridge経由で公開されているAPIは今回はwindow.myAPIでアクセス可能

こんな感じで、それぞれのファイルをWebpackでビルドしてdistへ書き出しています。
Webpackの設定ファイルもおいておきます。

const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

const base = {
    mode: "development",
    node: {
        __dirname: false,
        __filename: false
    },
    module: {
        rules: [{
            test: /.ts?$/,
            include: [
                path.resolve(__dirname, "src")
            ],
            exclude: [
                path.resolve(__dirname, ".yarn")
            ],
            use: "babel-loader"
        }, {
            test: /.json$/,
            include: [
                path.resolve(__dirname, "src"),
            ],
            exclude: [
                path.resolve(__dirname, ".yarn")
            ],
        }],
    },
    resolve: {
        extensions: [".js", ".jsx", ".ts", ".tsx", ".json"]
    },
};

const main = {
    ...base,
    target: "electron-main",
    entry: path.join(__dirname, "src", "main"),
    output: {
        filename: "main.js",
        path: path.join(__dirname, "dist"),
    },
};

const renderer = {
    ...base,
    target: "electron-renderer",
    entry: path.join(__dirname, "src", "renderer", "renderer"),
    output: {
        filename: "renderer.js",
        path: path.resolve(__dirname, "dist", "scripts"),
        publicPath: path.resolve(__dirname, "dist", "scripts"),
    },
    resolve: {
        extensions: [".json", ".js", ".jsx", ".css", ".ts", ".tsx"]
    },
    module: {
        rules: [
            {
                test: /\.(js|ts)x?$/,
                use: ["babel-loader"],
                include: [
                    path.resolve(__dirname, "src"),
                    path.resolve(__dirname, ".yarn")
                ],
            },
            {
                test: /\.scss$/,
                use: [
                    {
                        loader: MiniCssExtractPlugin.loader,
                        options: {
                            publicPath: path.resolve(__dirname, "dist", "scripts", "css")
                        }
                    },
                    {
                        loader: "css-loader",
                        options: {
                            sourceMap: true,
                            importLoaders: 2,
                            },
                    },
                    {
                        loader: "sass-loader",
                        options: {
                            implementation: require('sass'),
                            sassOptions: {
                                outputStyle: 'expanded',
                            }
                        }
                    }
                ]
            }
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: "./src/renderer/index.html",
            filename: "index.html"
        }),
        new MiniCssExtractPlugin({
            filename: "[name].css",
          }),
        new CleanWebpackPlugin(),
    ],
    optimization: {
        minimizer: [
            "...",
            new CssMinimizerPlugin(),
        ]
    },
    devServer: {
        clientLogLevel: "trace",
        compress: true,
        contentBase: path.resolve(__dirname, "dist", "scripts"),
        publicPath: path.resolve(__dirname, "dist", "scripts"),
        liveReload: true,
        port: 9000,
        watchContentBase: true,
        writeToDisk: true
    }
};

const preload = {
    ...base,
    target: "electron-preload",
    entry: path.resolve(__dirname, "src", "preload"),
    output: {
        filename: "preload.js",
        path: path.resolve(__dirname, "dist")
    }
}

module.exports = [
    main, renderer, preload
];

Webpackのビルドターゲットの指定は、それぞれメインプロセス用、レンダラープロセス用、プリロード用が用意されていたのでそのまま割り当てています。
調べている中で、セキュリティ的に微妙なので別なものを使え、という話も見ましたが、その辺は追えていません。

さて、この設定でビルドしたのち、Electronを動かすとpreload.jsは読み込んでくれませんでした。
具体的には、preload.ts3行目のログが表示されず、window.myAPIがundefinedになっていました。
ファイル名やビルドの配置など、色々変えてみても読み込まれず。

問題の回避方法

もろもろ調べても、コード的には大差ない感じで八方ふさがり状態でした。
そんな中、公式ドキュメントでwebPreferencesに渡せるオプションのリストを見ていてsandboxというものを発見しました。
https://www.electronjs.org/docs/latest/api/browser-window#new-browserwindowoptions
https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts

説明を読んだ感じだと、
「レンダープロセスからメインプロセスへ通信するために必要なNodeの機能(requireなど)を使えるようにする必要があるが、セキュリティの問題で閉じている。
preloadではrequireが使えないとモジュール読み込みなどができないためNodeの機能やElectronのモジュールを読み込めるようにする」
的なことが書いてあった。

ということで、以下のようにメインプロセスに追記してみた。

// 画面作成部分のみ
app.on("create", () => {
    this.mainWindow = new BrowserWindow({
        width: 1000,
        height: 500,
        minWidth: 500,
        minHeight: 200,
        acceptFirstMouse: true,
        titleBarStyle: "hidden",
        webPreferences: {
            nodeIntegration: false, // この2行は最近のElectronではデフォルトのようなので設定不要? 
            contextIsolation: true, //   
            sandbox: true // <- 追記
            preload: path.resolve(__dirname, "preload.js"),
        }
    });
    this.mainWindow.loadFile(path.resolve(__dirname, "scripts/index.html"));
    
    this.mainWindow.webContents.openDevTools();
    this.mainWindow.on("closed", () => {
        this.mainWindow = null;
    });
})

webPreferences.sandboxtrueにしてあげただけ。

これでビルドし、Electronを実行すると無事preload.jsが読み込まれ、myAPIも参照できるようになりました。

本来は、きちんと理解したうえで解決できればいいんですが
あまり内容を追えず手を動かしつつドキュメント見つつでたまたま解決してしまった形です。
この設定が根本的な解決策なのか、手元の環境でのみ起こる問題なのかなどは確認できていない状況であることを添えておきます。

まとめ

今回は、ElectronでレンダラープロセスへAPIを公開するためのcontextBridgeを設定するところでつまったので、それを回避する方法を探して何とかしました。
セキュリティ周りの話は全く理解できていないため、この設定によって裏側でどういう変化が起きているのか等をきちんと追えていません。

が、今回の目標であるレンダラープロセスへ任意にAPIを定義して公開できる環境は整えることができました。
ようやく次の一歩に進めるようになりました。
ひとまずよかった、ということにしておきます。

今回は。ここまで。
おわり