🙌

electronプロジェクトのテンプレート(ボイラープレート)を作ってみる(SPA by vite, MPA by Next.js)

2023/06/28に公開

個人的にしっくりくる?Electronプロジェクトのボイラープレート的なものを模索してみた記録。

TL;DR;

作ったもの(SPA バージョンと MPA バージョン)をそれぞれ以下に置く。

  • SPA(Single Page Application) by vite + React
    • レンダラー部分について、
      vite のボイラープレート(react-ts)をベースに作成した、単一ページのアプリのアプリにしている。

https://github.com/junkor-1011/electron-vite-react-sample-2023/tree/2023-06-29_2

  • MPA(Multi Page Appliation) by nextjs
    • レンダラー部分について、
      Next.js のボイラープレートをベースに、Static Exportsによって静的サイトの形にしたものを使っている。
      また、複数ページ(複数の html ファイル)を持つアプリにしている

https://github.com/junkor-1011/electron-nextjs-sample-2023/tree/2023-06-29_2

それぞれ共通して、

# 開発に必要なnpmモジュールのインストール
pnpm install

# アプリのビルド・パッケージ化
pnpm dist     # Linux向け
pnpm dist:win # Windows向け
# MacOS向けは動作未確認

# 開発時におけるアプリの実行(レンダラープロセスに関してホットリロード、その他デバッグ機能の有効化など)
pnpm dev

のような感じで使えるようになっている。

いずれも Linux 上での開発を想定しており、Docker でビルド用コンテナを立ち上げることで Windows 向けのビルドも可能にしている。
(MacOS 向けビルドは個人的に必要性が無かったり、そもそも所有してないので動作確認できないとかで未対応。)

使用ツールのバージョンなどは各リポジトリのpackage.jsonなどで規定されているので、この記事では特に触れない。

解説など

最低限、何をどういうつもりで作ったのかを補足していく。

2 つのリポジトリのリンクを貼ったが、どちらも本質的にはあまり変わらない。
→ レンダラーは基本的に静的 Web サイトと同様に html, js, css ファイルで動作し、electron 的にはどうやって(どのフレームワークで)それらレンダラーのアセットが生成されたのか、という途中過程はどうでも良いため。静的サイトを構築できる技術なら何をレンダラーに使っても良い。
そのため、SPA 版の方をベースに書きつつ、差分があるところ(SPA か MPA か)についてだけ少し補足する。

主なポイントとしては、

  • ワークスペースによる機能分離
  • esbuildelectron-builder を使ったビルド・パッケージ化
  • 開発用の実行のサポート(※レンダラープロセスのみ)
  • カスタムプロトコルの登録によるファイルアクセス
  • 型の export/import によるメインプロセスとレンダラープロセスの協調

などが挙げられる。

また、現状できていない主な項目は

  • MacOS をターゲットにしたビルド
    • Mac を筆者が所有していないため、動作確認ができない
  • github actions などを使った CI/CD による各プラットフォーム向けの自動ビルドと配布

などが挙げられる。

ここで、テンプレート作成の要点として挙げた各項目について、この後それぞれざっくり説明する。

プロジェクト構成(ワークスペースによる機能分離)

pnpm workspaceを使い、

  • プロジェクト全体の管理とアプリのビルド(親ワークスペース)
  • メインプロセス(子ワークスペース: electronディレクトリ)
  • レンダラープロセス(子ワークスペース: rendererディレクトリ)

のように大きく 3 つに機能を分離し、それぞれをなるべく疎結合な感じにした。
(メインプロセスから react とかを import できたりするの気持ち悪いし、レンダラー側から electron を import できちゃうとかも嫌なので)

なお、構成や開発・ビルドに必要なコマンドを考える上で、nextjs のテンプレートの 1 つであるwith-electron-typescript:

https://github.com/vercel/next.js/tree/v13.4.7/examples/with-electron-typescript

を参考にしている。
(記述が古かったり、個人的にワークスペース化したかったなどの理由で割と全面的に作り変えたが)

アプリのビルド・パッケージ化

開発は Linux 上でやっているが、個人的に作りたかったのは Windows 向けだったりしたので、
クロスプラットフォームビルドができるようにelectron-builderを使ってビルドするようにしている。

electron-builder の設定

親ワークスペースの package.json のbuild部分で必要な設定を行っている。

https://github.com/junkor-1011/electron-vite-react-sample-2023/blob/2023-06-29_2/package.json

package.json(抜粋)
{
  "build": {
    "productName": "example-app",
    "asar": true,
    "files": [
      "main",
      "renderer/dist"
    ],
    "linux": {
      "executableName": "example-app"
    }
  }
}

filesで、パッケージに含めるディレクトリを指定している。
そのため、ここで指定したディレクトリは、パッケージを行う前に必ず出来ているようにする。
(今回では、npm-script,のbuildコマンドでメインプロセスとレンダラープロセス両方のビルドを行うようにしている。)

実用的にはまだ色々な設定がいるはずであり、
https://www.electron.build/configuration/configuration
などを参考にして適宜設定しカスタマイズしていく。

npmrc による npm module の hoisted の設定

今回は先述の通り pnpm で環境構築を行っているが、公式 Introductionによると、

In order to use with pnpm, you’ll need to adjust your .npmrc to use any one the following approaches in order for your dependencies to be bundled correctly (ref: #6389):

node-linker=hoisted

public-hoist-pattern=*

shamefully-hoist=true

のように書かれている。
これはすなわち、npm や yarn(v1)を使った場合と同じような node_modules の構成(全モジュールが node_modules に hoisted される)にしろと言っているが、せっかく workspace まで切って疎結合化しようとしているので、このアプローチは避けることにする。

electron-builder 的にはelectronのパッケージにはアクセスできないとパッケージのビルドに失敗するので、これだけ public な hoist を許可することにする。

https://github.com/junkor-1011/electron-vite-react-sample-2023/blob/2023-06-29_2/.npmrc

(public-hoist-patternについてはpnpm の公式ドキュメントなどを参照)

今回はelectronパッケージは子ワークスペース(main プロセス)の方でインストールするが、親ワークスペースからも見えるようになる。

その他の依存関係はメインプロセスではesbuildでバンドルし、レンダラープロセスでもviteなりnextjsなりでやはりバンドルするようにしておけば、親ワークスペースからアクセスできる必要は必ずしもない。

レンダラー側は適当なフレームワークでビルドするので省略するとして、メインプロセス側は以下のようなスクリプトを書いてバンドルしている。

https://github.com/junkor-1011/electron-vite-react-sample-2023/blob/2023-06-29_2/electron/scripts/build.ts

  • 最終的にmain/index.jsmain/preload.jsの 2 つのファイルが生成されている必要があるので、electron/index.tselectron/preload.tsをそれぞれ指定してビルドしている
  • bundle: trueを指定することで依存パッケージをバンドルし、ビルド後は import をしなくても良いようにしている
    • ただし、electronパッケージはパッケージ化されたアプリ内ではデフォルトで使えるため、これだけは一緒にバンドルすると困るためexternalで指定して外している

(参考)

electronパッケージはdevDependenciesに入れるべきという話:
https://github.com/electron-userland/electron-builder/issues/7191
があるが、自分が試している範囲では、electron-builderを実行する階層(今回は親ワークスペース)で dependencies に入っていれば問題なさそう

Windows 向けビルド

Windows 向けビルドにはWineHQのインストールが必要だったりするので、自分の環境を汚したくなかったりする場合はビルド用コンテナーを作ってそこでビルドさせるようにするのが良い。

今回は自前でイメージを作成した(→ Dockerfile)
が、
https://www.electron.build/multi-platform-build.html#to-build-app-for-windows-on-linux
によると electon-builder 公式のイメージも用意されている:
https://github.com/electron-userland/electron-builder/tree/v24.4.0/docker
https://hub.docker.com/r/electronuserland/builder

ので、こちらを少しカスタマイズする形でも良かったかもしれない。
(公式ドキュメントの記載が分かりづらく、Node v18 用のタグはwineでなく18-wineだった。wineの方はベースイメージも Node のバージョンも異様に古かったりする。)

(追記: 2023-06-29)
結局 electron-builder が提供しているイメージ(tag: 18-wine)をベースにして少しだけカスタマイズする形に書き換えた。
更新版の Dockerfile:
https://github.com/junkor-1011/electron-vite-react-sample-2023/blob/2023-06-29_2/Dockerfile
(corepackを有効にしてpnpmを使えるようにしつつ、root ユーザーでの実行を避けて一般ユーザーで作業を行うようにしている)
若干分かりづらいが、tag がwineのものではなく、node18 系用の18-wineのイメージをベースに使うのがポイントだったりする。

開発用の実行のサポート

パッケージ化しなくても、簡易的に動かせた方が開発が捗るし、開発用にデバッグモードを ON にするなどといった余地もできるため、開発モードを用意しておく。

開発モードではelectronコマンドで直接メインプロセスのindex.jsを叩けば良いようにする。
もちろん、実際にはそれに相当する npm-scripts を作る:

package.json(親ワークスペース)
{
  "scripts": {
    "dev:renderer": "pnpm --filter renderer dev",
    "dev:electron": "pnpm build:electron && electron . --inspect",
    "dev": "run-p dev:*"
  }
}

レンダラープロセス側(dev:renderer)については、vite でも nextjs でも開発用サーバーの立ち上げが可能(いずれもdevコマンドで起動するようになっている)なので、
これを実行させている。
開発時はメインプロセスが localhost の適当なポートからファイルアクセスするようにしておく。これでホットリロードなどが可能になる。

メインプロセス(dev:electron)も本当は ts-node や tsx などを使って watch モードでの動作ができると良かったのだが、preload.jsなどを指定する関係で難しく、こちらは仕方なく開発モードでもビルドすることにして妥協している。

また、開発モードか否かはメインプロセス側でapp.isPackagedという boolean 型の変数を見れば識別可能であり、↓ のような感じで処理を分けている:

https://github.com/junkor-1011/electron-vite-react-sample-2023/blob/2023-06-29_2/electron/index.ts

electron/index.ts(抜粋)
import { app } from 'electron';
// ...

declare const loadURL; // 本番用のrendererのファイル読み込み設定

// Prepare the renderer once the app is ready
app.on('ready', async () => {
  // session
  session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
    // ↓本番と開発時とで、Content Security Policyの設定を変えている
    const cspContents = app.isPackaged
      ? ["default-src 'self'"]
      : ["default-src 'self' 'unsafe-inline'"];
    callback({
      responseHeaders: {
        ...details.responseHeaders,
        'Content-Security-Policy': cspContents,
      },
    });
  });

  const mainWindow = new BrowserWindow({
    width: 1200,
    height: 900,
    webPreferences: {
      preload: join(__dirname, 'preload.js'),
    },
  });

  if (app.isPackaged) {
    // production
    await loadURL(mainWindow);
  } else {
    // development
    await mainWindow.loadURL(devServerUrl); // 開発時はhttp://localhost:XXXX(viteやnextjsの開発サーバーのURL)をロードする
  }
});

カスタムプロトコルの登録によるファイルアクセス

https://www.electronjs.org/ja/docs/latest/tutorial/security#csp-メタタグ

などによると、file:プロトコルだと CSP の設定をレスポンスヘッダーで設定出来なさそうな記述があり、
ちょっと不便そう(実際には他にも色々不便)なので、カスタムプロトコルを登録して対応する。

SPA の場合と MPA の場合でアプローチが異なる。

SPA の場合はelectron-serveというライブラリがあるので、それをそのまま使えば OK。
https://github.com/sindresorhus/electron-serve

MPA の場合は、別途記事を書いていたりするのでそれを参照のこと:
https://zenn.dev/junkor/articles/676c4822e71daf

メインプロセスとレンダラープロセスの協調

基本的に、セキュリティの都合でプロセス間通信によって最小限のやりとりを行うようにする。

今回の例では、ipcRenderer.sendipcRenderer.invokeの簡単なサンプルを入れている。

ipcRenderer:
https://www.electronjs.org/ja/docs/latest/api/ipc-renderer

これらはいずれもレンダラープロセスからメインプロセスを呼び出すものになっている。

メインプロセス側の処理は
https://github.com/junkor-1011/electron-vite-react-sample-2023/blob/2023-06-29_2/electron/lib/handler.ts
に記述し、electron/index.ts

electron/index.ts(抜粋)
// ...
import { exampleChannel1, exampleChannel2 } from './lib/channels'; // チャンネル名を別ファイルに記述している
import { invokeExampleHandler, sendExampleHandler } from './lib/handler';

// ...
ipcMain.on(exampleChannel1, sendExampleHandler);
ipcMain.handle(exampleChannel2, invokeExampleHandler);

のようにして登録している。

また、electron/preload.tsによってレンダラープロセス側で各ハンドラーを呼び出せるように設定できる:
https://github.com/junkor-1011/electron-vite-react-sample-2023/blob/2023-06-29_2/electron/preload.ts

なお、electron/preload.tsでの型定義は、他で呼び出されることはないため適当にやっている。
(ここを真面目に書いても二重管理になるだけなので)

これで、メインプロセスとレンダラープロセス両方でプロセス間通信の設定ができたことになるが、
レンダラープロセス側も TypeScript で記述していると、electron/preload.tsで登録した処理は型定義に入っていないため、コンパイルエラーになって処理が実行出来ない。
このため、renderer プロセス側の型定義を拡張し、preload.tsで登録したものを反映させる必要がある。

これにはレンダラー側のtsconfig.jsonを適切に設定した上で、*.d.tsファイルを用意する必要がある。

今回はそれぞれ以下のような感じ:

https://github.com/junkor-1011/electron-vite-react-sample-2023/blob/2023-06-29_2/renderer/tsconfig.json

renderer/tsconfig.json(抜粋)
{
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {
      "@main/*": ["../electron/*"] // メインプロセス側のインポートをやりやすくしておく
    },
    "include": [
      "renderer.d.ts" // レンダラープロセス側でglobalWindowの型を拡張するのに使う
    ]
  }
}

↑ でrenderer.d.tsを読み込み、この中でglobalWindowを拡張する。(electron/preload.tsで登録したものはwindowに生えるため)

https://github.com/junkor-1011/electron-vite-react-sample-2023/blob/2023-06-29_2/renderer/renderer.d.ts

↑ によってメインプロセスからインポートした型定義を参照して globalWindow の型を拡張しており、window.electronAPI.sendExampleおよびwindow.electronAPI.invokeExampleに型安全な形でアクセスできる。

例えば、
https://github.com/junkor-1011/electron-vite-react-sample-2023/blob/2023-06-29_2/renderer/src/App.tsx
では<button>の onClick コールバックの中でwindow.electronAPI.*を使っており、適当なエディタなどで開けば TypeError になることなく使えていることが確認できる。

感想など

Web 技術をもとにデスクトップアプリを作る技術としては tauri:
https://github.com/tauri-apps/tauri
なども良さそう(Electron よりもビルドサイズやパフォーマンスなど色々改善されていそう)ではあったが、今回は Electron を選択した。

CI/CD によらず手元でクロスプラットフォームビルドをしたかったり、使う言語やパッケージ管理を一元化したかった(Node + TypeScript)といったこともあり、まだ Electron を採用する選択肢は残っているように感じた。

ただ、結構長らくある技術の割には環境構築に手間取ったので、この記事でなんとか供養したい所存。

GitHubで編集を提案

Discussion