以下は開発セッションで得た知見をAI(Claude)がまとめたものです。Tauri v2でAstro製ドキュメントサイトをmacOSネイティブアプリとしてラップするプロジェクトにおいて、実際に遭遇した問題と解決策を記録しています。
この記事で紹介しているコードやスキルは claude-resources リポジトリで公開している。記事中のリンクは執筆時点のコミットに固定してある。最新版とは異なる場合がある。
概要
Tauri v2でAstro製ドキュメントサイト(Claude Code設定ビューア)をmacOSアプリとしてラップした。最初はpnpm dev:stableをシェル経由で起動する方式だったが、Finderからの起動でPATHが通らない問題があり、Node.jsバイナリをTauriのexternalBinとしてバンドルする自己完結型に移行した。
その過程で色々な問題に遭遇して解決策を見つけたので、Tips集としてまとめたもの。各Tipsは独立して読める構成にしてある。
このアプリで閲覧しているのは、Claude Codeのリソース(CLAUDE.md、コマンド、スキル、エージェント)をドキュメントサイトとして表示するビューア。以下がそのスクリーンショット。

起動時はAstroビルドが走るため、ローディング画面が表示される。

シェル経由のサイドカー起動を避ける
最初のアプローチは/bin/zsh -c "source ~/.zshrc; pnpm dev:stable"でサイドカーを起動する方式だった。
// Before: シェル経由(Finderから起動すると壊れる)
Command::new("/bin/zsh")
.args(["-c", "source ~/.zshrc 2>/dev/null; pnpm dev:stable"])
ターミナルからcargo tauri devで開発している間は動く。ただしFinderから.appをダブルクリックして起動すると、GUIアプリはシェルのPATHを継承しないため、pnpmが見つからない。/bin/zsh -l -cでログインシェルにしても、nodenvやpnpmのパスが通らないケースがある。
シェルの設定に依存する起動方式は、開発環境では動くが配布や他のマシンでの利用を考えると脆い。
Node.jsバイナリをexternalBinとしてバンドルする
pnm/nodenv/.zshrc依存を排除するために、Node.js公式バイナリをTauriのexternalBinとしてバンドルする方式に移行した。
app/scripts/download-node.shでNode.js公式サイトからバイナリをダウンロードする。SHA256チェックサム検証つき。
// tauri.conf.json
{
"bundle": {
"externalBin": ["binaries/node"]
}
}
バイナリはapp/binaries/node-aarch64-apple-darwinに配置する(gitignored、約112MB)。
これでpnm、nodenv、.zshrc、シェル設定に一切依存しない自己完結型のアプリになる。
dev vs productionでのバイナリ名の違い
Tauriはdev時とproduction bundle時でexternalBinの命名規則が異なる。
- dev時:
node-aarch64-apple-darwin(ターゲットトリプルつき) - production bundle時:
node(トリプルなし)
両方に対応するコードはこうなる。
fn node_binary_path() -> std::path::PathBuf {
let exe = std::env::current_exe().expect("Failed to get current exe path");
let dir = exe.parent().expect("Failed to get exe directory");
// Dev mode: Tauri keeps the target triple
let target_triple = format!("{}-apple-darwin", std::env::consts::ARCH);
let dev_path = dir.join(format!("node-{}", target_triple));
if dev_path.exists() {
return dev_path;
}
// Production bundle: Tauri strips the triple
dir.join("node")
}
dev時のパスを先にチェックして、存在すればそれを使う。なければproduction用のパスにフォールバックする。
サイドカー起動からpnpmを排除する
Node.jsバイナリを直接バンドルしたら、起動コマンドからもpnmを排除する必要がある。
Rustサイドの起動コマンドはこう変わる。
// Before: シェル経由でpnmを呼ぶ
Command::new("/bin/zsh")
.args(["-c", "source ~/.zshrc 2>/dev/null; pnpm dev:stable"])
// After: バンドルしたNode.jsバイナリで直接実行
Command::new(&node_binary_path())
.args(["scripts/dev-stable.js"])
.current_dir(&doc_dir)
dev-stable.js内部でも、pnm経由のコマンド呼び出しを直接パスに置き換える。
// Before: pnm経由でAstro CLIを呼ぶ
spawn("pnpm", ["exec", "astro", "build", "--outDir", "dist_tmp"]);
// After: node_modulesのバイナリを直接指定
const astroBin = join(ROOT, "node_modules", "astro", "astro.js");
spawn(process.execPath, [astroBin, "build", "--outDir", "dist_tmp"]);
process.execPathを使うと、起動に使われたNode.jsバイナリのパスが取得できる。これでpnm依存が完全になくなる。
ポート占有の二重キル
アプリがクラッシュしたり強制終了されたりすると、前回のサーバープロセスがポートを掴んだまま残る。新しいインスタンスがポートをバインドできない。
これに対してRustサイドとJSサイドの両方でポートキルを実装する。Belt and suspenders方式。
Rustサイドではspawn前にSIGTERMを送る。
fn kill_port() {
if let Ok(output) = Command::new("/usr/bin/lsof")
.args(["-ti", &format!(":{PORT}")])
.output()
{
// parse PIDs, send SIGTERM, wait 500ms
}
}
JSサイドでもサーバー起動前に同じことをやる。
なぜ両方でやるかというと、Rustのkill_portが実行されるタイミングとJSプロセスが起動するタイミングにラグがあるため。Rustがkillした後、JSが起動するまでの間に別のプロセスがポートを掴む可能性はほぼないが、どちらか片方が失敗した場合のフォールバックとして機能する。
SSEライブリロード
ファイル変更→自動リビルド→ブラウザ自動更新のワークフローをSSE(Server-Sent Events)で実装した。
SSEはWebSocketより構成がシンプル。サーバーからクライアントへの一方通行の通信で、ライブリロードにはこれで十分。
サーバーサイドでは/___eventsというSSEエンドポイントを用意する。クライアントはこのエンドポイントに接続して、イベントを待つ。
送信するイベントは2種類。
building— ビルド開始時に送信。クライアントはスピナーオーバーレイを表示する(80x80px、右下固定)rebuild— ビルド完了時に送信。クライアントはlocation.reload()でページを更新する
CLAUDE.mdを編集すると、右下にスピナーが表示され、リビルド完了後にページが自動更新される。

クライアントサイドのスクリプトは、HTMLレスポンスの</body>の前に自動注入する。サーバーがHTMLを配信する際に、レスポンスを書き換えてスクリプトを埋め込む方式。
ビルドが失敗した場合もSSEでbroadcastしてスピナーを消す。ビルド失敗時にスピナーが表示されたままだとブラウザが固まっているように見えるため。
wait_for_buildでWindow作成を待つ
Rustサイドでサイドカーを起動した後、ビルドが完了するまでWindow作成を待つ必要がある。curlポーリングでシンプルに実装できる。
wait_for_build(Duration::from_secs(120));
WebviewWindowBuilder::new(app, "main", WebviewUrl::External(url))
.title("Claude Resources")
.build()?;
wait_for_build()は/usr/bin/curlでlocalhostにリクエストを送り、200が返るまでループする。IPC不要のシンプルなアプローチ。
タイムアウトを120秒に設定しているのは、初回ビルドが遅い場合があるため。通常は数秒で完了するが、Content Collectionが大きいと時間がかかる。
FAQ
ポート占有キルはアプリがクラッシュした場合でも機能する?
機能する。Tauriアプリ(Rustプロセス)がクラッシュしても、Node.jsサイドカーは自分自身のプロセスグループ(process_group(0)で作成)で動いている。OSは親プロセスがクラッシュしても子プロセスグループを自動的にkillしない。そのためNode.jsプロセスはポート4892を掴んだまま孤児プロセスとして残る。
次回のアプリ起動時、Rustのkill_port()がlsof -ti :4892を実行すると、この孤児プロセスが見つかり、SIGTERMでkillされる。正常終了時はkill_sidecar()がプロセスグループにSIGTERMを送るので問題ない。kill_port()は主にクラッシュ時の保険として機能している。
SSEとは何?ポーリングやWebSocketとの違いは?
SSE(Server-Sent Events)はブラウザネイティブの仕様(HTML5の一部)。サーバーからブラウザへの一方通行の通信チャネル。
| ポーリング | SSE | WebSocket | |
|---|---|---|---|
| 方向 | クライアント→サーバー(繰り返し) | サーバー→クライアント | 双方向 |
| 接続 | 毎回新しいHTTPリクエスト | 単一の長寿命HTTP接続 | ws://にアップグレード |
| ブラウザAPI | fetch/setInterval | EventSource | WebSocket |
| 再接続 | 手動 | 自動(組み込み) | 手動 |
| 複雑さ | 最もシンプル | シンプル | 最も複雑 |
SSEはポーリングなしでサーバーからの通知を待てる?
待てる。フロントエンドはポーリングループなしで、サーバーからの通知をただ待つだけ。仕組みはこうなっている。
ブラウザは通常のGETリクエストを送る。
GET /___events HTTP/1.1
サーバーはレスポンスを返すが、接続を閉じない。
HTTP/1.1 200 OK
Content-Type: text/event-stream ← このヘッダーがキー
Cache-Control: no-cache
Connection: keep-alive
data: connected ← 最初のメッセージ、即座に送信
← ...沈黙、接続は開いたまま...
← ...数分経過、何も起こらない...
event: building ← サーバーが好きなタイミングで書き込む
data: start
event: rebuild ← これも、数秒/数分後に
data: 1
ポイントはres.end()が呼ばれないこと。resオブジェクトは開いたまま保持される。サーバーはその参照を保持し、言いたいことがあるときにres.write()を呼ぶ。
// サーバーは接続中の全クライアントを保持
const sseClients = new Set();
// GET /___events — レスポンスを返すが閉じない
res.writeHead(200, { "Content-Type": "text/event-stream" });
sseClients.add(res);
req.on("close", () => sseClients.delete(res));
// 後で、リビルドが完了したら — 全ての開いている接続に書き込む
for (const res of sseClients) {
res.write("event: rebuild\ndata: 1\n\n");
}
つまり通常のHTTPレスポンスと同じ — 同じヘッダー、同じTCP接続 — サーバーが接続を開いたまま、好きなときにデータを書き込むだけ。text/event-streamのContent-Typeがブラウザに対して、このストリームを通常のレスポンスボディではなくイベントとしてパースするよう指示する。
まとめ
Tauri v2 + バンドルNode.jsの組み合わせで、pnm/nodenv/zshrcに一切依存しない自己完結型のmacOSアプリが作れた。SSEライブリロードにより、設定ファイル編集→自動リビルド→ブラウザ自動更新のワークフローも実現できている。
各Tipsの要点を並べるとこうなる。
- シェル経由(
zsh -c)のサイドカー起動は避ける — Finderからの起動でPATHが通らない - Node.jsバイナリを直接バンドルすれば環境依存ゼロ
- Tauriのdev/production間でバイナリ名が変わるので、両方に対応するコードが必要
- ポートキルはRust・JS両方で実施する(belt and suspenders)
- SSEはWebSocketより簡素でライブリロードに向いている — ポーリング不要でサーバーから通知を待てる
- ビルド失敗時もSSE broadcastしてスピナーを消す
今回作成したTauriアプリや関連するスキル・スクリプトは claude-resources リポジトリで公開している。主な関連ファイルは以下。
app/src/main.rs— Rustサイドのサイドカー管理、メニュー、ウィンドウ制御doc/scripts/dev-stable.js— ビルド→サーブ + SSEライブリロードdoc/scripts/watch-dirs.js— ウォッチ対象ディレクトリの設定skills/tauri-wisdom/SKILL.md— 今回得た知見をまとめたClaude Codeスキル