electronでマルチページアプリを作る by Next.js
TL;DR;
electron で複数ページ間の遷移を実現しようとすると、パス名から参照する html ファイルが決まらないことがあるため結構厄介。
(/hoge
の実体が、/hoge.html
なのか/hoge/index.html
なのか、など)
こういう場合、
を参照して、カスタムプロトコルを登録することで http プロトコルのように良い感じにパスを解決して Web アプリ開発と同じように作れる。
↑ で、そのようなカスタムプロトコルの例をざっと書いてみた。
上記ファイルのregisterProtocol
を electron メインプロセス内で import して、
registerProtocol({
directory: "<エントリーポイントのindex.htmlが置かれるディレクトリパス>",
});
のようにすることで上記の問題を解決できる。
背景など
Electronを使えば静的 Web サイト作成のノウハウを転用してデスクトップアプリケーションを作ることができる。
ここで、作るアプリケーションのタイプとして大きく
- SPA(Single Page Application)
- html ファイルはエントリーポイントの
index.html
1 つのみ
- html ファイルはエントリーポイントの
- MPA(Multi Page Application)
- html ファイル(ページ)は複数存在し、各ページ間を遷移する
の 2 通りの作成方法が考えられる。
一般的にどちらが適したケースが多いのかといった話はさておき、個人的に後者のタイプで作ってみようとして冒頭に書いたような問題で詰まった箇所がある。
すなわち、Next.js でレンダラープロセス部分を開発していたのだが、パッケージ用のビルドを行うとページの遷移ができなくなるといったバグが発生した。
原因は、ビルド後の本番用の設定ではfile:
プロトコルでファイルにアクセスするようにしていると、一般的な Web サーバーのようには url のパス部分から実体の html ファイルに参照出来ないことだった。
あと、上の問題と関係無いが、
に記載されるように、file プロトコルを使っているとレスポンスヘッダーの設定ができないため、CSPなどセキュリティ周りの設定などが若干やりづらくなる問題もあった。
そのため、そのままの file プロトコルではなく、パス名の解決などいくらか機能を追加したカスタムプロトコルの設定が必要だった。
解決策の実装
前提として、
- Linux(Fedora38)
- Node.js v18.16.1
- pnpm@8.6.7
- electron@25.3.0
といった環境で検証している。
ここで、先述の背景で記載したことにある程度近いことをやっているライブラリが存在するため、それを参考に実装する:
↑sindresorhusさん作のSPAで electron アプリを作る時用のユーティリティライブラリ
処理のメイン部分:
↑ いかなるパスを指定しても常にエントリーポイントのindex.html
を読ませるような実装になっている。
(README に記載されるように、react-routerなどを使って、実体はシングルページだがページが切られている感じ、すなわち SPA においてかなり有用そう)
今回はMPAをしたいのでファイルパスの解決は異なるが大部分の処理は共通して使えそうだったので、参考にしつつ deprecated なメソッドを置き換えるなどして書いてみたのが冒頭にも載せたこれ ↓
import { statSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { app, net, protocol, session } from 'electron';
export const protocolInfo = {
scheme: 'mpa',
protocol: 'mpa:',
hostname: '-',
origin: 'mpa://-',
} as const;
const conv2FilePath = (path: string): string => {
return pathToFileURL(path).toString();
};
const getPath = (path_: string): string => {
try {
const result = statSync(path_);
if (result.isFile()) {
return path_;
}
if (result.isDirectory()) {
return getPath(path.join(path_, 'index.html'));
}
throw new Error();
} catch (_) {
if (path.extname(path_) === '') {
// if path do not have extention, add '.html'
return getPath(`${path_}.html`);
}
// net.fetch(path_) will throw 404
return path_;
}
};
export interface RegisterProtocolOptions {
directory: string;
}
export const registerProtocol = ({
directory,
}: RegisterProtocolOptions): void => {
/** entry point dir of renderer */
const baseDir = path.resolve(app.getAppPath(), directory);
/** path of entry point index.html at renderer */
const baseIndexPath = path.join(baseDir, 'index.html');
protocol.registerSchemesAsPrivileged([
{
scheme: protocolInfo.scheme,
privileges: {
standard: true,
secure: true,
allowServiceWorkers: true,
supportFetchAPI: true,
corsEnabled: false,
},
},
]);
app.on('ready', () => {
const session_ = session.defaultSession;
session_.protocol.handle(protocolInfo.scheme, (request) => {
const requestPathname = decodeURIComponent(new URL(request.url).pathname);
const convertedPathname = path.join(baseDir, requestPathname);
const resolvedPathname = getPath(convertedPathname);
const fileExtension = path.extname(resolvedPathname);
if (fileExtension === '.asar') {
return net.fetch(conv2FilePath(baseIndexPath));
} else {
return net.fetch(conv2FilePath(resolvedPathname));
}
});
});
};
雑に解説すると、
- カスタムプロトコル:
mpa
を登録している-
mpa://-/{path}
のpath
で受け取ったファイルパスを実際のファイルパス(file:
プロトコル)に変換し、electron のnet.fetch
でアクセスする実装になっている -
electron-serve
ではprotocol.registerFileProtocol
で登録しているが、deprecated になっているので、protocol.handle - hostname はデフォルトで
-
になる
-
-
mpa://-/{path}
によって受け取ったパスがファイルでない場合、以下のようにして html ファイルを探索してパスを自動で変換する:- ディレクトリの場合、
index.html
を付加したパスを作り、html ファイルが無いか調べる - 受け取ったパスに相当するファイルもディレクトリも存在せず、拡張子も存在しない場合、
.html
を末尾に付加してみて html ファイルを探索する
- ディレクトリの場合、
といったことをしている。
あとの部分は、TypeScript で書いていたり異常系が雑だったりする違いはあるが、概ね参照元と同等のことをやっている。
あとは、
// mainプロセス処理部分の記述
import { registerProtocol } from '<さっきの処理を書いたモジュールのパス>';
// ...
registerProtocol({
directory: '<アプリのエントリーポイントのindex.htmlが置かれるパス>',
});
// ...
のような感じでメインプロセス内で実際にカスタムプロトコルを登録すると、Electron アプリは renderer プロセスのファイルアクセスでその登録されたプロトコルを使うようになる。
実際の例は
のような感じで書いている。
(冒頭で書いたように、SCP のレスポンスヘッダー設定などもできている。)
動作例
個人的に作ったボイラープレート:
の中に実際に組み込んでいる。
- トップページ:
/
- サブページ:
/next
の 2 ページを含み、各ページ間で遷移できるようになっているが、
開発モード(http://localhost:3000 を読み込み)でもビルド後(先程書いたカスタムプロトコルを使用)でも同じようにページ遷移するようになっている。
# 開発モードでの実行(nextjsの開発用サーバー: localhost:3000 にhttpプロトコルでアクセス)
pnpm dev
# パッケージのビルドと実行(カスタムプロトコルでアクセス)
pnpm pack-app
dist/linux-unpacked/example-app
↑ トップページ(/
)
↑ サブページ(/next
)
ビルド時でも nextjs の開発用サーバーを使っているときと同様にページのパス解決が出来ており、問題なくページ間遷移ができることが確認できる。
補足
この記事での解説に使った electron プロジェクトのボイラープレートについて、
で説明しているため、必要に応じてそちらも参照のこと。
Discussion