electronプロジェクトのテンプレート(ボイラープレート)を作ってみる(SPA by vite, MPA by Next.js)
個人的にしっくりくる?Electronプロジェクトのボイラープレート的なものを模索してみた記録。
TL;DR;
作ったもの(SPA バージョンと MPA バージョン)をそれぞれ以下に置く。
- SPA(Single Page Application) by vite + React
- レンダラー部分について、
vite のボイラープレート(react-ts
)をベースに作成した、単一ページのアプリのアプリにしている。
- レンダラー部分について、
- MPA(Multi Page Appliation) by nextjs
- レンダラー部分について、
Next.js のボイラープレートをベースに、Static Exportsによって静的サイトの形にしたものを使っている。
また、複数ページ(複数の html ファイル)を持つアプリにしている
- レンダラー部分について、
それぞれ共通して、
# 開発に必要な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 か)についてだけ少し補足する。
主なポイントとしては、
- ワークスペースによる機能分離
- esbuild と electron-builder を使ったビルド・パッケージ化
- 開発用の実行のサポート(※レンダラープロセスのみ)
- カスタムプロトコルの登録によるファイルアクセス
- SPA: electron-serve
- MPA: 前書いた記事
- 型の export/import によるメインプロセスとレンダラープロセスの協調
などが挙げられる。
また、現状できていない主な項目は
- MacOS をターゲットにしたビルド
- Mac を筆者が所有していないため、動作確認ができない
- github actions などを使った CI/CD による各プラットフォーム向けの自動ビルドと配布
などが挙げられる。
ここで、テンプレート作成の要点として挙げた各項目について、この後それぞれざっくり説明する。
プロジェクト構成(ワークスペースによる機能分離)
pnpm workspaceを使い、
- プロジェクト全体の管理とアプリのビルド(親ワークスペース)
- メインプロセス(子ワークスペース:
electron
ディレクトリ) - レンダラープロセス(子ワークスペース:
renderer
ディレクトリ)
のように大きく 3 つに機能を分離し、それぞれをなるべく疎結合な感じにした。
(メインプロセスから react とかを import できたりするの気持ち悪いし、レンダラー側から electron を import できちゃうとかも嫌なので)
なお、構成や開発・ビルドに必要なコマンドを考える上で、nextjs のテンプレートの 1 つであるwith-electron-typescript:
を参考にしている。
(記述が古かったり、個人的にワークスペース化したかったなどの理由で割と全面的に作り変えたが)
アプリのビルド・パッケージ化
開発は Linux 上でやっているが、個人的に作りたかったのは Windows 向けだったりしたので、
クロスプラットフォームビルドができるようにelectron-builderを使ってビルドするようにしている。
electron-builder の設定
親ワークスペースの package.json のbuild
部分で必要な設定を行っている。
{
"build": {
"productName": "example-app",
"asar": true,
"files": [
"main",
"renderer/dist"
],
"linux": {
"executableName": "example-app"
}
}
}
files
で、パッケージに含めるディレクトリを指定している。
そのため、ここで指定したディレクトリは、パッケージを行う前に必ず出来ているようにする。
(今回では、npm-script,のbuild
コマンドでメインプロセスとレンダラープロセス両方のビルドを行うようにしている。)
実用的にはまだ色々な設定がいるはずであり、
などを参考にして適宜設定しカスタマイズしていく。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 を許可することにする。
(public-hoist-pattern
についてはpnpm の公式ドキュメントなどを参照)
今回はelectron
パッケージは子ワークスペース(main プロセス)の方でインストールするが、親ワークスペースからも見えるようになる。
その他の依存関係はメインプロセスではesbuild
でバンドルし、レンダラープロセスでもvite
なりnextjs
なりでやはりバンドルするようにしておけば、親ワークスペースからアクセスできる必要は必ずしもない。
レンダラー側は適当なフレームワークでビルドするので省略するとして、メインプロセス側は以下のようなスクリプトを書いてバンドルしている。
- 最終的に
main/index.js
とmain/preload.js
の 2 つのファイルが生成されている必要があるので、electron/index.tsとelectron/preload.tsをそれぞれ指定してビルドしている -
bundle: true
を指定することで依存パッケージをバンドルし、ビルド後は import をしなくても良いようにしている- ただし、
electron
パッケージはパッケージ化されたアプリ内ではデフォルトで使えるため、これだけは一緒にバンドルすると困るためexternal
で指定して外している
- ただし、
(参考)
electron
パッケージはdevDependencies
に入れるべきという話:
があるが、自分が試している範囲では、electron-builder
を実行する階層(今回は親ワークスペース)で dependencies に入っていれば問題なさそう
Windows 向けビルド
Windows 向けビルドにはWineHQのインストールが必要だったりするので、自分の環境を汚したくなかったりする場合はビルド用コンテナーを作ってそこでビルドさせるようにするのが良い。
今回は自前でイメージを作成した(→ Dockerfile)
が、
によると electon-builder 公式のイメージも用意されている:
ので、こちらを少しカスタマイズする形でも良かったかもしれない。
(公式ドキュメントの記載が分かりづらく、Node v18 用のタグはwine
でなく18-wine
だった。wine
の方はベースイメージも Node のバージョンも異様に古かったりする。)
(追記: 2023-06-29)
結局 electron-builder が提供しているイメージ(tag: 18-wine
)をベースにして少しだけカスタマイズする形に書き換えた。
更新版の Dockerfile:
(corepackを有効にしてpnpm
を使えるようにしつつ、root ユーザーでの実行を避けて一般ユーザーで作業を行うようにしている)
若干分かりづらいが、tag がwine
のものではなく、node18 系用の18-wine
のイメージをベースに使うのがポイントだったりする。
開発用の実行のサポート
パッケージ化しなくても、簡易的に動かせた方が開発が捗るし、開発用にデバッグモードを ON にするなどといった余地もできるため、開発モードを用意しておく。
開発モードではelectron
コマンドで直接メインプロセスのindex.js
を叩けば良いようにする。
もちろん、実際にはそれに相当する npm-scripts を作る:
{
"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 型の変数を見れば識別可能であり、↓ のような感じで処理を分けている:
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)をロードする
}
});
カスタムプロトコルの登録によるファイルアクセス
などによると、file:
プロトコルだと CSP の設定をレスポンスヘッダーで設定出来なさそうな記述があり、
ちょっと不便そう(実際には他にも色々不便)なので、カスタムプロトコルを登録して対応する。
SPA の場合と MPA の場合でアプローチが異なる。
SPA の場合はelectron-serveというライブラリがあるので、それをそのまま使えば OK。
MPA の場合は、別途記事を書いていたりするのでそれを参照のこと:
メインプロセスとレンダラープロセスの協調
基本的に、セキュリティの都合でプロセス間通信によって最小限のやりとりを行うようにする。
今回の例では、ipcRenderer.send
とipcRenderer.invoke
の簡単なサンプルを入れている。
ipcRenderer:
これらはいずれもレンダラープロセスからメインプロセスを呼び出すものになっている。
メインプロセス側の処理は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
によってレンダラープロセス側で各ハンドラーを呼び出せるように設定できる:
なお、electron/preload.ts
での型定義は、他で呼び出されることはないため適当にやっている。
(ここを真面目に書いても二重管理になるだけなので)
これで、メインプロセスとレンダラープロセス両方でプロセス間通信の設定ができたことになるが、
レンダラープロセス側も TypeScript で記述していると、electron/preload.ts
で登録した処理は型定義に入っていないため、コンパイルエラーになって処理が実行出来ない。
このため、renderer プロセス側の型定義を拡張し、preload.ts
で登録したものを反映させる必要がある。
これにはレンダラー側のtsconfig.json
を適切に設定した上で、*.d.ts
ファイルを用意する必要がある。
今回はそれぞれ以下のような感じ:
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@main/*": ["../electron/*"] // メインプロセス側のインポートをやりやすくしておく
},
"include": [
"renderer.d.ts" // レンダラープロセス側でglobalWindowの型を拡張するのに使う
]
}
}
↑ でrenderer.d.ts
を読み込み、この中でglobalWindow
を拡張する。(electron/preload.ts
で登録したものはwindow
に生えるため)
↑ によってメインプロセスからインポートした型定義を参照して globalWindow の型を拡張しており、window.electronAPI.sendExample
およびwindow.electronAPI.invokeExample
に型安全な形でアクセスできる。
例えば、<button>
の onClick コールバックの中でwindow.electronAPI.*
を使っており、適当なエディタなどで開けば TypeError になることなく使えていることが確認できる。
感想など
Web 技術をもとにデスクトップアプリを作る技術としては tauri:
なども良さそう(Electron よりもビルドサイズやパフォーマンスなど色々改善されていそう)ではあったが、今回は Electron を選択した。CI/CD によらず手元でクロスプラットフォームビルドをしたかったり、使う言語やパッケージ管理を一元化したかった(Node + TypeScript)といったこともあり、まだ Electron を採用する選択肢は残っているように感じた。
ただ、結構長らくある技術の割には環境構築に手間取ったので、この記事でなんとか供養したい所存。
Discussion