👻

electronでマルチページアプリを作る by Next.js

2023/06/26に公開

TL;DR;

electron で複数ページ間の遷移を実現しようとすると、パス名から参照する html ファイルが決まらないことがあるため結構厄介。
(/hogeの実体が、/hoge.htmlなのか/hoge/index.htmlなのか、など)

こういう場合、

https://www.electronjs.org/ja/docs/latest/api/protocol

を参照して、カスタムプロトコルを登録することで http プロトコルのように良い感じにパスを解決して Web アプリ開発と同じように作れる。

https://github.com/junkor-1011/electron-nextjs-sample-2023/blob/2023-07-13/electron/lib/custom-protocol.ts

↑ で、そのようなカスタムプロトコルの例をざっと書いてみた。
上記ファイルのregisterProtocolを electron メインプロセス内で import して、

registerProtocol({
  directory: "<エントリーポイントのindex.htmlが置かれるディレクトリパス>",
});

のようにすることで上記の問題を解決できる。

背景など

Electronを使えば静的 Web サイト作成のノウハウを転用してデスクトップアプリケーションを作ることができる。

ここで、作るアプリケーションのタイプとして大きく

  • SPA(Single Page Application)
    • html ファイルはエントリーポイントのindex.html1 つのみ
  • MPA(Multi Page Application)
    • html ファイル(ページ)は複数存在し、各ページ間を遷移する

の 2 通りの作成方法が考えられる。

一般的にどちらが適したケースが多いのかといった話はさておき、個人的に後者のタイプで作ってみようとして冒頭に書いたような問題で詰まった箇所がある。
すなわち、Next.js でレンダラープロセス部分を開発していたのだが、パッケージ用のビルドを行うとページの遷移ができなくなるといったバグが発生した。

原因は、ビルド後の本番用の設定ではfile:プロトコルでファイルにアクセスするようにしていると、一般的な Web サーバーのようには url のパス部分から実体の html ファイルに参照出来ないことだった。

あと、上の問題と関係無いが、

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

に記載されるように、file プロトコルを使っているとレスポンスヘッダーの設定ができないため、CSPなどセキュリティ周りの設定などが若干やりづらくなる問題もあった。

そのため、そのままの file プロトコルではなく、パス名の解決などいくらか機能を追加したカスタムプロトコルの設定が必要だった。

解決策の実装

前提として、

  • Linux(Fedora38)
  • Node.js v18.16.1
  • pnpm@8.6.7
  • electron@25.3.0

といった環境で検証している。

ここで、先述の背景で記載したことにある程度近いことをやっているライブラリが存在するため、それを参考に実装する:

https://github.com/sindresorhus/electron-serve

sindresorhusさん作のSPAで electron アプリを作る時用のユーティリティライブラリ
処理のメイン部分:

https://github.com/sindresorhus/electron-serve/blob/v1.1.0/index.js

↑ いかなるパスを指定しても常にエントリーポイントのindex.htmlを読ませるような実装になっている。
(README に記載されるように、react-routerなどを使って、実体はシングルページだがページが切られている感じ、すなわち SPA においてかなり有用そう)

今回はMPAをしたいのでファイルパスの解決は異なるが大部分の処理は共通して使えそうだったので、参考にしつつ deprecated なメソッドを置き換えるなどして書いてみたのが冒頭にも載せたこれ ↓

https://github.com/junkor-1011/electron-nextjs-sample-2023/blob/2023-07-13/electron/lib/custom-protocol.ts

custom-protocol.ts
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 で書いていたり異常系が雑だったりする違いはあるが、概ね参照元と同等のことをやっている。

あとは、

index.ts(抜粋)
// mainプロセス処理部分の記述

import { registerProtocol } from '<さっきの処理を書いたモジュールのパス>';

// ...

registerProtocol({
  directory: '<アプリのエントリーポイントのindex.htmlが置かれるパス>',
});

// ...

のような感じでメインプロセス内で実際にカスタムプロトコルを登録すると、Electron アプリは renderer プロセスのファイルアクセスでその登録されたプロトコルを使うようになる。

実際の例は

https://github.com/junkor-1011/electron-nextjs-sample-2023/blob/2023-07-13/electron/index.ts

のような感じで書いている。
(冒頭で書いたように、SCP のレスポンスヘッダー設定などもできている。)

動作例

個人的に作ったボイラープレート:

https://github.com/junkor-1011/electron-nextjs-sample-2023/tree/2023-07-13

の中に実際に組み込んでいる。

  • トップページ: /
  • サブページ: /next

の 2 ページを含み、各ページ間で遷移できるようになっているが、
開発モード(http://localhost:3000 を読み込み)でもビルド後(先程書いたカスタムプロトコルを使用)でも同じようにページ遷移するようになっている。

# 開発モードでの実行(nextjsの開発用サーバー: localhost:3000 にhttpプロトコルでアクセス)
pnpm dev

# パッケージのビルドと実行(カスタムプロトコルでアクセス)
pnpm pack-app
dist/linux-unpacked/example-app

トップページ()

↑ トップページ(/)

サブページ()

↑ サブページ(/next)

ビルド時でも nextjs の開発用サーバーを使っているときと同様にページのパス解決が出来ており、問題なくページ間遷移ができることが確認できる。

補足

この記事での解説に使った electron プロジェクトのボイラープレートについて、
https://zenn.dev/junkor/articles/212c28212db053
で説明しているため、必要に応じてそちらも参照のこと。

GitHubで編集を提案

Discussion