デスクトップアプリ(Electrobun)設計書
最終更新: 2026-10-01(Electrobun 2.0.2への更新を反映)
1. 概要
MorningStatusApp デスクトップアプリ(Windows/macOS)のアーキテクチャ設計書。Next.js standalone サーバーを Electrobun でラップし、単一のネイティブアプリとして配布する仕組みを扱う。
この領域のドキュメントはこれまで docs/notes/DEVELOPMENT_NOTES.md の断片的な知見や README の変更履歴に散在しており、設計の全体像をまとめた文書が存在しなかった。Windows/macOS でディレクトリ構成が異なる点(§3)のような前提知識が実装者間で共有されず、バグ(#1535)を誘発したことを受けて本書を新設した(#73)。
ビルド・インストールの実行手順(コマンド)は デスクトップアプリのビルド手順 に分離しており、本書はアーキテクチャ・実装仕様(Why・How が本質的に変わらない部分)に特化する。
2. ビルドフロー概要
DESKTOP_MODE=1 bun run next buildで Next.js standalone サーバー(.next/standalone/)を生成bunx electrobun build --env=stableで Electrobun アプリ(プラットフォーム別バイナリ)とインストーラを生成する。Hutch(Electrobun 2系のビルドツールチェーン)がbuild.scripts.preBuildフックでdesktop/scripts/stage-nextjs.tsを自動実行し、build.copy設定に従って 1 の standalone サーバー一式をパッケージ(.tar.zst)に同梱したうえで、インストーラ(Windows:MorningStatusApp-Setup.exe、macOS: DMG)まで生成する
具体的なコマンド・スクリプト(setup/build-macos.sh・setup/build-installer.ps1)は デスクトップアプリのビルド手順 を参照。
2.1 Next.js standalone の同梱方式(build.copy とステージング)
electrobun.config.ts の build.copy は desktop/ 配下の相対パスしか参照できず、.. を含むパスを拒否する(Hutch のバリデーションによる制約)。Next.js の standalone 出力(.next/standalone/)はリポジトリルートに生成され desktop/ の外にあるため、build.copy から直接参照できない。
このため、build.scripts.preBuild フックで desktop/scripts/stage-nextjs.ts を実行し、一度 desktop/.next-standalone-staging/(gitignore 対象)にステージングしてから build.copy: { ".next-standalone-staging": "nextjs/standalone" } で拾う二段構えにしている。
備考: Next.js の standalone 出力には
@prisma/client等へのシンボリックリンクが含まれる。Windows ではfs.cpSyncがこのシンボリックリンクをそのまま複製しようとするとEIO(“another process has locked a portion of the file”)で失敗するため、stage-nextjs.tsではdereference: trueを指定してリンク先の実体を複製している。
備考(検討したが採用しなかった代替案): Next.js の
distDirをdesktop/配下(例:desktop/.next-desktop)に変更すれば、standalone 出力を直接desktop/配下に生成でき、ステージング用のコピー処理を大幅に削減できると考え検証した。しかしdesktop/にpackage.jsonが存在するため、Next.js のファイルトレーサーがdesktop/をワークスペースの一部とみなし、standalone 出力がdesktop/.next-desktop自身を再帰的に含んでしまう(自己参照)問題が発生した。outputFileTracingExcludesでdesktop/**を除外しても、per-route のファイルトレースとは別経路(ワークスペースパッケージ検出によるコピー)で数MB規模の残骸が残り、完全には解消しなかったため見送った。
3. プラットフォーム別ディレクトリ構成の違い
3.1 インストール先と実行時展開先(Windows、Electrobun 2系/Hutch)
Electrobun 2系(Hutch)では、Windows インストーラ(MorningStatusApp-Setup.exe)がインストールする内容と、アプリが実際に実行時に使う内容が異なる二段構成になっている。
- インストール先(既定
%LOCALAPPDATA%\Programs\MorningStatusApp\。管理者権限なしの場合):bin/launcher.exe(ブートストラッパー)と、アプリ本体を圧縮したResources/*.tar.zstのみが置かれる - 実行時展開先(
%LOCALAPPDATA%\{app.identifier}\{channel}\app\。例:%LOCALAPPDATA%\dev.morning-status-app\stable\app\):launcher.exeが初回起動時に.tar.zstをここへ自己展開し、以降はこのディレクトリから実行する
desktop/src/bun/index.ts(Resources/app/bun/index.js としてビルドされる Worker 本体)が動作するのは 2. 実行時展開先 の中であり、パス解決ロジック(§3.2)が対象とするのもこちらである。
備考: v1(Inno Setup)ではインストール先=実行先で、フラットな
{app}/bin/・{app}/Resources/構成だった。v2 移行時、独自の Inno Setup スクリプトで v1 相当のフラット構成をそのままインストールしたところ、launcher.exeが.tar.zstの自己展開を前提に整合性チェックを行っており、“The installer package is incomplete” エラーで起動に失敗した。Hutch 純正のMorningStatusApp-Setup.exe(electrobun build --env=stableが生成)に切り替えることで解消した。
3.2 実行時ディレクトリ構成
両プラットフォームとも実行バイナリ配置ディレクトリと Resources が同階層にあるという点は共通しているが、ディレクトリ名(bin か MacOS か)が異なるため、パス解決ロジックはプラットフォームごとに個別の検証が必要になる。
| 要素 | Windows(実行時展開先) | macOS(.app バンドル仕様。Electrobun 2.0.1でApple Silicon実機検証済、2.0.2で再検証済) |
|---|---|---|
| 同梱 bun 実行ファイル | {app}/bin/bun.exe |
{app}.app/Contents/MacOS/bun |
| ランチャー本体 | {app}/bin/launcher.exe |
{app}.app/Contents/MacOS/launcher |
Electrobun ワーカー(index.js) |
{app}/Resources/app/bun/index.js |
{app}.app/Contents/Resources/app/bun/index.js |
| Next.js standalone サーバー | {app}/Resources/app/nextjs/standalone/ |
{app}.app/Contents/Resources/app/nextjs/standalone/ |
実際のパス解決は desktop/src/bun/resolve-paths.ts の resolveBunExecutablePath(resourcesDir, platform) に集約している。index.js の配置ディレクトリ(Resources/app/bun 相当)を起点に、darwin なら ../MacOS/bun、それ以外(Windows)なら ../bin/bun.exe を返す。
備考(Electrobun 2.0.1移行):
electrobun.config.tsのbuild.copyの同梱先はResources/直下ではなくResources/app/基準になる(実機検証で判明)。resolveBunExecutablePath()が使うresourcesDir(Resources/、bun 実行ファイル解決用)と、Next.js standalone サーバーのパス解決に使うappBundleDir(Resources/app/)は起点が1段異なるため、desktop/src/bun/index.tsでは両者を別変数として保持している。
備考: 旧実装(#1535)は、macOS でも Windows と同じ計算式(
{resourcesDir}/../bin/bun相当)で bun 実行ファイルのパスを求めていた。macOS 対応自体は #711 で追加されたが、インストール済.appでの実機起動検証が行われておらず、実在するContents/MacOS/bunを参照できずにENOENTで起動失敗していた。プラットフォーム別のパス解決ロジックをresolve-paths.tsに切り出し、darwinの場合のみ../MacOS/bunを返すように修正して解消した。
4. 起動フロー(インストール済アプリ)
launcher.exe(Windows)/MorningStatusApp(macOS)→ Electrobun ランチャーが起動し、bun/index.js(Worker)を起動する- Worker が
resolveBunExecutablePath()で同梱 bun 実行ファイルのパスを解決する(プラットフォーム別、§3参照) - 前回起動時の自プロセス(
main.js= このWorker自身)のPID(OS一時ディレクトリのmorning-status-app-desktop-main.pidに記録、MAIN_LOCK_FILE)が生存していればkillStaleServer()で強制終了し、ロックファイルを削除したうえで自身のPIDを書き込む。server.jsの有無に関わらず常に実行する(#1563) - 前回起動時のサーバー PID(OS 一時ディレクトリの
morning-status-app-desktop-server.pidに記録、SERVER_LOCK_FILE)が生存していればkillStaleServer()で強制終了し、ロックファイルを削除する(#1561) Resources/app/nextjs/standalone/server.js(§3.2)を検出し、同梱 bun で Next.js サーバーを子プロセス起動、そのプロセス PID をロックファイルに書き込む(writeLockedPid())waitForServer()がサーバーのポート応答をポーリングする。応答前にサーバープロセスが終了した場合は起動失敗として扱うBrowserWindowでhttp://localhost:{port}(既定3001)を表示する- WebView2 は初回描画時にスクロールバーを描画しないため、
dom-readyイベント後にウィンドウサイズを ±1px 変化させて再描画を強制する
備考: 以前は上記6の待機処理がポートへの応答有無だけを起動成功の判定基準にしており、ウィンドウを閉じても
process.on("exit")のクリーンアップを素通りしてサーバー子プロセスが残留することがあった。再ビルド・再インストール後に起動すると、新しいサーバーがポートの bind に失敗し、残留していた旧バージョンのサーバーにそのまま接続してしまい、再ビルドしても画面が更新されないバグがあった(#1561)。PID ロックファイルによる残留プロセスの検出・強制終了(上記4・5)と、起動確認時のプロセス終了検知(上記6)を追加して解消した。
5. プロセス終了時のクリーンアップ
- 正常終了:
process.on("exit")およびapp.on("before-quit", ...)(Electrobun のappexport)で、spawn した Next.js サーバー子プロセスをkill()する - 異常終了(強制終了・クラッシュ等)への対策: 上記フックは異常終了時には発火しないため、サーバー子プロセス(
server.js)は §4-4 のロックファイル方式で次回起動時に自己修復する(#1561)。main.js自身(Electrobun ランタイム本体)についても、launcher.exeの異常終了時には上記フックが発火しないため、同じロックファイル方式(§4-3、MAIN_LOCK_FILE)で次回起動時に自己修復する(#1563)
備考: 以前は
main.js自身を保護する仕組みが無く、launcher.exe(Electrobun ランタイム本体、main.jsを実行するbun.exeの親プロセス)が異常終了するとmain.jsが孤児プロセスとして残留していた。ウィンドウ(WebView2)はlauncher.exeではなくmain.js側が保持しているため、残留したmain.jsは操作可能な旧ウィンドウごと生存し続けていた。この状態でインストーラの上書きアップグレードを実行すると、ファイルにロックがかかり失敗する場合があった(#1563)。server.jsと同じ自己ロックファイル方式をmain.js自身にも適用し解消した。
備考(Electrobun 2.0.2): 2.0.2 では Windows 向けに、
launcher.exeが子プロセスの終了コードを32bitのまま保持して子プロセスの PID・生成時刻をログに出力する変更と、WebView2 コントローラをアプリ終了前にネイティブ UI スレッドでクローズする変更が入った(MorningStatusApp#1662)。どちらも正常終了時のブラウザプロファイル保存と異常終了時の原因調査(PID 再利用の判別)を改善するもので、異常終了時に子プロセスが残ること自体を防ぐものではないため、上記のロックファイル方式(§4-3・§4-4)は引き続き必要である。
6. WebView のキャッシュ制御
デスクトップモードの Next.js レスポンスは next.config.ts の headers() で Cache-Control: no-store を付与し、Next.js の ISR が内部的に設定する Cache-Control(s-maxage/stale-while-revalidate 等)を上書きする。適用条件は以下の2点で、Web 版(Vercel)の ISR キャッシュ設計には影響させない。
DESKTOP_MODE=1かつ macOS 限定: WKWebView(macOS)でのディスクキャッシュ不具合は実機調査で再現・検証済だが、WebView2(Windows)では同種の不具合が未確認。Windows は macOS よりインストール・アンインストールの検証回数が大幅に多いにもかかわらず再現報告がないため、根拠のないまま Windows にも同じ対策を適用するのは妥当でないと判断し、process.platformで macOS 限定にガードしている_next/static配下は対象外:source: '/((?!_next/static).*)'により除外する。同ディレクトリはコンテンツハッシュ付きファイル名で無期限キャッシュしてよいため、no-store の対象に含める必要がない
備考: 以前は Web 版と同じ ISR キャッシュヘッダーをそのまま返しており、WKWebView(macOS)が
stale-while-revalidateに従って HTML をディスクキャッシュしていた。このキャッシュは.appの再インストールでは消えないため(Electrobun のBrowserViewにキャッシュを明示的にクリアする API が無い)、サーバー自体が新バージョンで起動できていても画面には反映されないケースがあった(#1561)。デスクトップモードはローカル単一ユーザー向けで CDN キャッシュの恩恵がなく、常に最新の内容を表示すべきであるため、no-storeを強制することで解消した。当初は全ルート・全プラットフォームに適用していたが、_next/static配下の静的アセットまで無効化対象に巻き込んでいた点と、WebView2(Windows)では不具合が未確認のまま対策を適用していた点を別PRのレビュー(#1562)で指摘され、上記の適用条件に絞り込んだ。
7. 同梱 Bun バイナリのバージョン管理
electrobun パッケージは、システムの bun upgrade とは独立して、自前で固定バージョンの Bun バイナリを同梱する仕組みを持つ。
備考(Electrobun 2.0.1移行): v1 ではトップレベルの
build.bunVersionフィールドで明示指定できたが、v2(Hutch)ではzig/rust/go/odinの各ランタイムにはversion?フィールドでのトップチェーン上書きが用意されている一方、bun・cottontailにはこの上書き手段が存在しない(desktop/.hutch/devkit/api/config/ElectrobunConfig.tsの型定義で確認済)。同梱される Bun バイナリのバージョンは、選択した Electrobun devkit(desktop/package.jsonのelectrobunパッケージバージョン)が決める既定値に従うのみで、プロジェクト側から個別に固定することはできない。
8. バージョン管理(アプリ本体)
アプリのバージョンは目的の異なる3箇所で個別に管理されており、自動同期される仕組みはない。
| 管理箇所 | 用途 |
|---|---|
root package.json の version |
Next.js アプリが画面に表示するバージョン(フッター「データ最終更新」欄等) |
desktop/electrobun.config.ts の app.version |
Electrobun アプリ本体(インストーラ/DMG)のバージョン |
desktop/package.json の version |
Electrobun パッケージ管理上のバージョン。electrobun.config.ts の app.version と揃えて管理する |
画面表示用のバージョン(root package.json)と、Electrobun アプリ本体のバージョン(desktop/ 配下)は異なる数値体系で運用されている。
備考(Electrobun 2.0.1移行): v1 時代は Windows インストーラのバージョンを
build-installer.ps1が rootpackage.jsonから読み取り、Inno Setup に/DAppVersionとして渡していた。v2(Hutch 純正インストーラ)移行後は独自のバージョン受け渡しを行っておらず、インストーラのバージョンはdesktop/electrobun.config.tsのapp.versionに一本化されている。
9. リソース(アイコン)
アイコン素材は desktop/assets/ に配置する。
desktop/assets/
icons8-コーヒーカップ-100.png # ソース画像(Icons8)
icon.ico # Windows 用(100px PNG から生成)
icon.iconset/ # macOS 用(sharp で各サイズ生成済)
icon_16x16.png
icon_16x16@2x.png
icon_32x32.png
icon_32x32@2x.png
icon_128x128.png
icon_128x128@2x.png
icon_256x256.png
icon_256x256@2x.png
icon_512x512.png
electrobun.config.ts の build.win.icon(assets/icon.ico)/build.mac.icons(assets/icon.iconset)でビルド時に埋め込む。
アイコンを差し替える場合
より高解像度のソース画像(512px 以上推奨)を入手したら、以下のスクリプトで再生成する。
# Windows(Bun): icon.ico を再生成
bun -e "
const pngPath = 'desktop/assets/<新しい画像>.png';
const icoPath = 'desktop/assets/icon.ico';
const pngData = await Bun.file(pngPath).arrayBuffer();
const pngBytes = new Uint8Array(pngData);
const buf = new ArrayBuffer(6 + 16 + pngBytes.length);
const view = new DataView(buf);
view.setUint16(0, 0, true); view.setUint16(2, 1, true); view.setUint16(4, 1, true);
view.setUint8(6, 0); view.setUint8(7, 0); view.setUint8(8, 0); view.setUint8(9, 0);
view.setUint16(10, 1, true); view.setUint16(12, 32, true);
view.setUint32(14, pngBytes.length, true); view.setUint32(18, 22, true);
new Uint8Array(buf, 22).set(pngBytes);
await Bun.write(icoPath, buf);
"
# macOS: iconset を再生成(macOS 環境で実行)
mkdir -p desktop/assets/icon.iconset
sips -z 16 16 <ソース>.png --out desktop/assets/icon.iconset/icon_16x16.png
sips -z 32 32 <ソース>.png --out desktop/assets/icon.iconset/icon_16x16@2x.png
sips -z 32 32 <ソース>.png --out desktop/assets/icon.iconset/icon_32x32.png
sips -z 64 64 <ソース>.png --out desktop/assets/icon.iconset/icon_32x32@2x.png
sips -z 128 128 <ソース>.png --out desktop/assets/icon.iconset/icon_128x128.png
sips -z 256 256 <ソース>.png --out desktop/assets/icon.iconset/icon_128x128@2x.png
sips -z 256 256 <ソース>.png --out desktop/assets/icon.iconset/icon_256x256.png
sips -z 512 512 <ソース>.png --out desktop/assets/icon.iconset/icon_256x256@2x.png
sips -z 512 512 <ソース>.png --out desktop/assets/icon.iconset/icon_512x512.png
iconutil -c icns desktop/assets/icon.iconset # .icns も生成される
既知の問題(解消済)
備考: Windows ビルド時、
rceditの依存解決に失敗しlauncher.exe/bun.exeへのアイコン埋め込みが警告付きでスキップされる問題があった(Cannot find module '...\node_modules\rcedit\package.json')。Electrobun が Bun のグローバルキャッシュルートから実行される際、ローカルのnode_modules/rceditを解決できないことが原因(#846)。Electrobun 1.18.1・1.18.4-beta.3 の時点では未解消だったが、Hutch(Electrobun 2.0.1 のビルドツールチェーン)ベースのビルドパイプラインに刷新されたことで rcedit 呼び出し経路自体が変わり、launcher.exe・bun.exe・MorningStatusApp-Setup.exeいずれもアイコン埋め込みが成功することを実機(Windows)で確認した(#1548)。
10. TypeScript型チェック・テストとHutch devkitの依存
desktop/tsconfig.json は ./.hutch/devkit/tsconfig.json を extends する(Electrobun 2系/Hutchアーキテクチャの要件)。desktop/.hutch/ は .gitignore 対象で、bunx electrobun dev/build/prepare のいずれかを実行したときにのみローカルに生成される。bun install だけでは生成されない。
Electrobunを新規導入する場合、または desktop/src 配下にVitestテスト・型チェック対象のコードを追加する場合は、以下を必ずセットで用意すること:
- CI: テスト・型チェックを実行するジョブの前段で、
desktopディレクトリでのbun installとbunx electrobun prepare --env=stableを実行する(.github/workflows/deploy-vercel.ymlのtestジョブに実装済) - ローカルセットアップ: リポジトリクローン直後の環境で
desktop/src配下のテスト・型チェックを実行する前に、同様にbun install(desktopディレクトリ)とbunx electrobun prepare --env=stableを一度実行する
これを怠ると、desktop/.hutch/ が未生成の環境(新規clone直後・CI)でのみ [TSCONFIG_ERROR] Tsconfig not found が発生する。過去に一度でも上記コマンドを実行したことがあるローカル環境では再現しないため、発見が遅れやすい(MorningStatusApp#1548)。
備考: 同種の「
.gitignore対象の生成物へのtsconfig依存」は、ルートリポジトリのtsconfig.scripts.json(next-env.d.ts依存、MorningStatusApp#1629)でも発生している。個別事例を横断した一般化・設計原則は開発ノート「実装」カテゴリ#136を参照。
改訂履歴
| 版 | 更新日 | 変更内容 |
|---|---|---|
| 1.4 | 2026-10-01 | Electrobun 2.0.2(Hutch 0.27.1)への更新を反映。現行の仕組みを説明する箇所(§2・§3.1・§10)のバージョン表記を「2系」に一般化。§5に、2.0.2のWindows向け変更(ランチャーの終了コード保持・子プロセスPIDのログ出力、WebView2コントローラの終了時クローズ)がロックファイル方式を不要にするものではない旨を備考として追記。§3のmacOS実機検証の記録に2.0.2での再検証を追記(MorningStatusApp#1662・#1686) |
| 1.3 | 2026-09-16 | §10「TypeScript型チェック・テストとHutch devkitの依存」を新設。desktop/tsconfig.jsonの.hutch/devkit依存とCI・ローカルセットアップでの必須手順を、発生記録(開発ノート note #30)から実際の設計要件として明文化(MorningStatusApp#1629の調査に伴う整備) |
| 1.2 | 2026-08-27 | Electrobun 2.0.1(Hutch)への移行を反映。ビルドフロー(§2)に build.copy によるNext.js standalone同梱方式を追加、プラットフォーム別ディレクトリ構成(§3)にHutchの自己展開アーキテクチャ(インストール先と実行時展開先の分離)を追加、同梱Bunバイナリのバージョン管理(§7)のフィールド名変更を反映、バージョン管理(§8)からWindowsインストーラのバージョン受け渡し方式の変更を反映、リソース(アイコン)の既知の問題(§9)を解消済に更新。今後の対応方針(§10)の完了に伴い節を削除(#1548) |
| 1.1 | 2026-08-27 | main.js(Electrobunランタイム本体)自身の孤児プロセス化対策を追加。起動フロー(§4)に自己ロックファイル方式のステップを追加し、§5の既知の課題を解消として記載を更新。今後の対応方針(§10)から対応済となった項目を削除(#1563) |
| 1.0 | 2026-08-27 | 初版作成。ビルドフロー・プラットフォーム別ディレクトリ構成・起動フロー・プロセス終了時のクリーンアップ・WebViewのキャッシュ制御・バージョン管理・リソース(アイコン)を集約。実装済の対策(#1535: macOSパス解決バグ、#1561: 残留プロセス・WebViewキャッシュ)に加え、今後の対応方針(#1548: Electrobun 2.0.1移行、#1563: Windows main.js 残留プロセス対策、#846: アイコン埋め込みエラー)を記載(#73) |