← ブログに戻る

Tauri v2 を「ガワ」として使う — WSL2 mirrored networking で Node.js サーバをそのまま Windows デスクトップに映す

Claude Code の複数アカウントを切り替える自作ツール claude-shift には、もともと CLI と Chrome 拡張の 2 つの顔がありました。ある日ブラウザ用の Web UI を足し、その勢いで「これ、デスクトップアプリにもなりませんか」と欲が出ました。

結果として、CLI・Chrome 拡張・ブラウザに続く 4 つ目の顔 (デスクトップアプリ) が 1 日で増えたのですが、その過程で Tauri v2 の「たぶん想定されていない使い方」に落ち着きました。UI 資産をまったく持たないガワとしての Tauri です。おまけに Windows で起動した exe が、何の設定もなしに WSL2 の中の Node.js サーバに繋がるという、mirrored networking の気持ちよさも体験できました。

この記事はその設計判断と、ハマった罠の記録です。

Electron は 3 分で選択肢から消えた

claude-shift は Node.js v20 以上を前提とするツールです。ユーザーは全員、確実に Node を持っています。

Electron でデスクトップ化すると、Chromium と Node.js のランタイムを丸ごと同梱して 100MB 超のバイナリになります。ユーザーが確実に持っているものを、わざわざ同梱して配ることになる。これが引っかかって Electron は早々に外れました。

Tauri v2 は OS 標準の WebView (Windows は WebView2、Linux は WebKitGTK) を使うので、バイナリは Rust 部分だけ。生成された exe は約 8MB でした。

ただし Tauri の標準的な使い方とも少し違います。Tauri は普通、フロントエンドのビルド成果物 (dist/) をバイナリに焼き込みます。今回はそれすらしません。

設計: UI ファイルは 1 つ、配信経路は 4 つ

claude-shift の UI は extension/popup.html ただ 1 枚です。もともと Chrome 拡張のポップアップとして生まれたファイルで、これを全プラットフォームで使い回します。

デスクトップ化の前段として、CLI サーバが拡張の資産をそのまま静的配信する Web UI 化を済ませていました。配信対象はホワイトリスト方式です。

// cli/server.js — 拡張の資産をそのまま配信する
const STATIC_ASSETS = new Map([
  ["/",           { file: "popup.html", contentType: "text/html; charset=utf-8" }],
  ["/popup.html", { file: "popup.html", contentType: "text/html; charset=utf-8" }],
  // popup.js / helpers.js / styles.css も同様
]);

拡張とブラウザで接続先だけが違うので、popup.js の先頭で分岐します。

// extension/popup.js — http(s) で開かれたら same-origin、
// chrome-extension:// で開かれたら localhost の server へ
const SERVER = (typeof location !== "undefined" && /^https?:$/.test(location.protocol))
  ? ""
  : "http://127.0.0.1:19867";

ここまでできていると、Tauri 側の仕事はほぼゼロです。WebviewUrl::Externalhttp://127.0.0.1:19867/ を開くウィンドウを作るだけ。tauri.conf.jsonfrontendDist には空のダミーディレクトリを指定しています。ビルド設定上は必須なのに、実際には 1 バイトも使われないという扱いです。

tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::External(url.clone()))

つまり UI の実体は常に extension/popup.html の 1 枚。それを届ける経路だけが 4 つあります。

  • Chrome 拡張 — 拡張バンドルから popup.html をロードし、popup.js が localhost の server へ fetch
  • ブラウザ — server が同じファイルを静的配信、fetch は same-origin
  • デスクトップ — Tauri の WebView がブラウザと同じ URL を開く
  • CLI — UI を使わず同じ server の API を叩く

claude-shift の UI アーキテクチャ: extension/popup.html 1 枚を cli/server.js が配信し、Chrome 拡張・ブラウザ・デスクトップ・CLI の 4 経路に届く

UI のバグ修正が 1 ファイルで済み、拡張・ブラウザ・デスクトップに同時に反映されます。デスクトップだけ古い、という状態が構造上起きません。

ガワが持つ唯一のロジック: server のライフサイクル

UI を持たない代わりに、ガワは「server がいなければ起動する」責務だけ持ちます。ensure_server() の返り値は 3 パターンです。

  1. 既存の server が生きている → そのまま使う。殺さない (Ok(None))
  2. いないnode cli/server.js を spawn する (Ok(Some(child)))
  3. spawn もできない → エラーページを表示する (Err)

1 が重要で、私の Linux 環境では claude-shift の server が systemd user service として常駐しています。デスクトップアプリはそこに乗るだけで、終了時も他人の server には触りません。自分が spawn した子プロセスだけを RunEvent::Exit で片付けます。

Linux ではさらに保険として PDEATHSIG を張っています。ガワが SIGKILL で即死しても、子の node プロセスが孤児として残りません。

// 親が死んだら子 node に SIGTERM が届く (Linux のみ)
cmd.pre_exec(|| {
    libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM);
    Ok(())
});

server.js のパス解決は 2 段構えです。

fn server_js_path() -> PathBuf {
    if let Ok(repo) = std::env::var("CLAUDE_SHIFT_REPO") {
        return PathBuf::from(repo).join("cli/server.js");
    }
    // コンパイル時に埋め込まれる repo のパス (後述の罠)
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../cli/server.js")
}

ここでハマった罠を 2 つ。

罠 1: エラーページの data: URL で panic する。 server が見つからないときのエラーページを data:text/html,... で出そうとしたら、起動時に invalid window url: data URLs are not supported without the webview-data-url feature で落ちました。Cargo.toml の tauri に webview-data-url feature を足すと通ります。エラー処理を書いてから初めて踏むので、正常系のデモでは気づけません。

罠 2: 多重起動の race。 2 個同時に起動すると、両方が「server がいない」と判定して両方 spawn し、負けた方の子が EADDRINUSE で即死します。spawn 直後に try_wait() で即死を検知し、その場合は「別の誰かが先に立てた server を使う」に降格させました。その上で single-instance プラグインを入れ、2 個目のウィンドウ自体を出さないようにしています。

Windows で起動したら、WSL の中の server に繋がった

ここからが今回いちばん面白かった話です。

手元の Windows 11 機でビルドして exe を起動したら、ちゃんとウィンドウが出ました。ただし表示されたのは UI ではなく、こういう JSON でした。

{"error":"not found"}

エラーページではありません。どこかの server が HTTP で応答しているのです。しかし Windows 側では server を起動していません。

犯人は WSL2 でした。この機の WSL には claude-shift の server が systemd user service として常駐していて、WSL2 の mirrored networking モードでは 127.0.0.1 が Windows と WSL の間で透過します。Windows 側の Tauri が開いた 127.0.0.1:19867 は、WSL の中の Node.js サーバに届いていました。

{"error":"not found"} になったのは、WSL 側の server が静的配信の入る前の古いバージョンだったからです。当時の server は API 専用で、/ に GET すると not found を返す仕様でした。WSL 側で git pull して systemctl --user restart claude-shift、Tauri のウィンドウを Ctrl+R でリロードしたら、アカウントカードと使用率グラフが表示されました。

冷静に考えるとこれは、Windows のデスクトップアプリとして見えているものの中身が、WSL の中で走っている Linux の Node.js プロセスという状態です。Tauri のガワは Windows ネイティブ、server とデータ (SQLite) は Linux 側のまま。何かを設定した記憶は一切ありません。mirrored networking が勝手に橋を架けてくれました。

WSL2 mirrored networking の構成図: Windows 側の Tauri ガワが開く 127.0.0.1 が、WSL2 側で常駐する node cli/server.js に透過して届く

「UI 資産を持たないガワ」設計の副産物でもあります。ガワが URL しか知らないからこそ、その URL の先が Windows か WSL かをガワは区別する必要がない。ensure_server の「既存 server がいれば使う」が、OS の境界をまたいで機能した形です。

ビルドについて 1 点だけ。repo は Windows 側の C ドライブに置いてください。\\wsl$\... 経由で cargo build するとファイル IO で 5〜10 倍遅くなります。

8MB の exe が単体で動かない理由

さて、この exe を別のマシンに持っていくと動きません。8MB の中身は WebView の起動と server の探索・起動ロジックだけで、UI も server も Node.js も入っていないからです。

具体的に詰むのは先ほどの server_js_path() です。env!("CARGO_MANIFEST_DIR")コンパイル時にビルドマシンの絶対パスとして焼き込まれます。ビルドした本人のマシンでは「repo がそこにあるから」設定なしで動くのに、配布先にはそのパスが存在しません。

なので配布先で必要なのは 3 つです。

  1. Node.js v20 以上が PATH にいること
  2. repo (最低限 cli/extension/) を任意の場所に配置 (git clone でも zip 展開でも可)
  3. 環境変数 CLAUDE_SHIFT_REPO に repo のルートパスを設定

「単体で動く exe にしたい」なら、Tauri の sidecar で Node ランタイムごと同梱する道もあります。ただそれをやると、冒頭で Electron を外した理由に真正面から矛盾します。Node ユーザー向けのツールに Node を同梱して 80MB にするくらいなら、「exe + git clone + 環境変数 1 個」を配布形式と割り切る方が筋がいい、というのが今回の結論です。

実際、Linux の AppImage は WebKitGTK を抱えるので 77MB になる一方、deb は 2.3MB で済んでいます。「ランタイムを同梱するかどうか」がサイズのほぼすべてで、ロジック本体は誤差みたいなものです。

セットアップ手順の詳細は repo の docs/desktop-setup.md にまとめてあります。

まとめ: 「localhost で動いている何か」の昇格パターン

Tauri v2 の教科書的な使い方 (フロントエンドをビルドして焼き込む) からは外れますが、この「External URL を開くだけのガワ」は汎用パターンだと思っています。条件は 2 つだけです。

  • すでに localhost で動く Web UI がある
  • ユーザーがランタイム (今回は Node) を持っている前提を置ける

当てはまるなら、Rust 232 行の main.rs と空の frontendDist で、既存のツールがデスクトップアプリに昇格します。ウィンドウを閉じたらトレイに常駐する挙動も TrayIconBuilder で足せますし、GitHub Actions の 3 OS マトリクスに乗せれば msi / dmg / deb が CI から出てきます (macOS は手元に実機がないので、CI がビルド検証を兼ねています)。

そして WSL2 の mirrored networking 環境なら、Windows のガワと Linux の中身という構成が設定ゼロで成立します。デスクトップアプリの「中の人」が WSL の Node プロセスというのは、書いた本人が一番驚いていました。

コードは kenimo49/claude-shift にあります。desktop/ ディレクトリが今回のガワの全部です。