zudo-tauri-wisdom
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

ドキュメントビューアーアプリ

ローカルドキュメントサーバーを安全に所有する Tauri ラッパー

ホストは所有するローカルサーバーを起動し、遷移先の準備ができるまで同梱ローディング画面を表示する。現在の起動世代の結果が受理されてから遷移する。同梱ネイティブ sidecarを優先し、ユーザーの Node/pnpm 探索は実行時に JavaScript が必要なツールの代替手段とする。

アーキテクチャ概要

sequenceDiagram participant UI participant Coordinator participant Worker participant Sidecar UI->>Coordinator: Launch / Retry Coordinator->>Coordinator: Advance generation; take old resources Coordinator->>Worker: Stop old group; prepare new resources Worker->>Sidecar: Spawn owned process group on loopback Worker->>Worker: Probe route, marker, module entries Worker->>Coordinator: Publish generation + owned resources alt Current generation and not shutting down Coordinator->>UI: Navigate to effective URL else Superseded or shutdown Coordinator->>Worker: Dispose unpublished resources end

2つのモード

開発時は beforeDevCommand が開発サーバーを所有する場合がある。ランタイムの終了処理でその外部所有プロセスを止めない。配布アプリはビルド時に依存関係を準備し、自分の sidecar を所有する。先に書き込み可能なワークスペースを解決する。同梱 Resources は入力であり生成先ではない。

サイドカーの起動

ライフサイクルコーディネーターを子プロセス、グループ、ウォッチャー、実際のポートの唯一の所有者にする。Unix では process_group(0) を使い、登録前に子の PGID を記録する。再起動を直列化し、Retry や終了後に返った古い結果は拒否してリソースを破棄する。標準出力・エラーはアプリが書けるログへ送る。

起動時のポートクリーンアップ

ポート所有者を終了する手順は不要。strict モードでは希望ポートの占有を報告し、明示的な fallback 方針だけが空きループバックポートを選ぶ。そのポートを spawn、確認、遷移、capability に一貫して渡す。事前 bind は競合するので、確認できた bind 競合だけを再試行する。lsof で見つけたサービスは終了しない。

準備完了ポーリング

バックグラウンドワーカー内の HTTP クライアントを使う。次の例は proxy と redirect を無効にし、IPv4 loopback に限定し、大きすぎる応答を拒否する。実装では client を再利用できる。reqwest はアプリの明示的な依存であり、Tauri が提供するものではない。

// Cargo.toml: reqwest = { version = "0.12", default-features = false, features = ["blocking"] }
use std::{io::{self, Read}, time::Duration};

fn probe_route(port: u16, path: &str) -> Result<(u16, String), Box<dyn std::error::Error>> {
    if !path.starts_with('/') || path.starts_with("//") || path.contains(['\r', '\n']) {
        return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid route").into());
    }
    let client = reqwest::blocking::Client::builder()
        .no_proxy()
        .redirect(reqwest::redirect::Policy::none())
        .connect_timeout(Duration::from_millis(200))
        .timeout(Duration::from_secs(1))
        .build()?;
    let response = client.get(format!("http://127.0.0.1:{port}{path}")).send()?;
    let status = response.status().as_u16();
    const LIMIT: u64 = 2 * 1024 * 1024;
    let mut bytes = Vec::new();
    response.take(LIMIT + 1).read_to_end(&mut bytes)?;
    if bytes.len() as u64 > LIMIT {
        return Err(io::Error::new(io::ErrorKind::InvalidData, "response too large").into());
    }
    Ok((status, String::from_utf8(bytes)?))
}

HTTP 成功だけでは足りない。サーバーとホストの契約を定義する。

  1. /docs/ など実際の遷移先を取得し、200 を要求する。

  2. ホストが生成し今回のサーバーが出力する起動ごとのマーカーを要求する。古いビルドなら StaleContent にする。

  3. HTML パーサーで必要な <script type="module" src> を取り出し、ルート URL を基準に解決する。要求前に scheme、host、実際の port の一致を確認し、別 origin への redirect を拒否する。JavaScript 本体を全部取得せず 200 を確認する。island マーカーがあるのに期待する module がなければ待機する。

  4. HttpUnavailable、StaleContent、Ready を区別し、タイムアウト時に最後の状態を報告する。全体期限、リクエスト・アセット数の制限、各 asset 間のキャンセル確認を設ける。50 ms 間隔を出発点とし、tick ごとの curl プロセスや成功後の固定 sleep は使わない。

これは asset 契約の確認であり JavaScript 実行成功の証明ではない。hydration はブラウザで検証する。各 tick で子の死亡と世代も確認する。ReadyResult の Superseded は遷移も新しい UI の上書きもしない。

ローディング画面

同梱 frontendDist を背後に持つ WebviewUrl::default() で main を作る。timeout や子の終了でも画面を残し、ログパスと Retry を表示する。コーディネーターが受理した Ready だけが実際のルートへ遷移できる。待機関数から戻ったというだけで遷移しない。

DocumentStart の状態

appearance などページの script より前に必要な状態には initialization_script を使い、ユーザーの文字列を直接埋め込まず JSON としてシリアライズする。

let initial_state = serde_json::to_string(&serde_json::json!({ "appearance": "dark" }))?;
let init = format!("window.__APP_INITIAL_STATE__ = {initial_state};");
let window = tauri::WebviewWindowBuilder::new(
    app, "main", tauri::WebviewUrl::default(),
)
.initialization_script(init)
.build()?;

ページの最初の script がこの値を消費できる。DocumentStart では document.body がない場合があるので依存しない。将来の navigation にも適用されるため秘密情報を入れず、遷移先を制限する。Tauri の実装を参照。

ループバック capability と CSP

127.0.0.1 だけに bind する。特権を持つ Settings と loading はローカルに置き、別々の狭い capability にする。docs が event 監視だけを必要とする場合は次のようにする。

{
  "identifier": "docs-main",
  "windows": ["main"],
  "local": false,
  "remote": { "urls": ["http://127.0.0.1:32342/*"] },
  "permissions": ["core:event:allow-listen", "core:event:allow-unlisten"]
}

これは固定ポートの例であり、その実際のポートだけに有効。fallback 時は選んだ origin の runtime capability を遷移前に追加するか、remote IPC を無効のままにする。全ポート・全ウィンドウ許可へ置き換えない。複数の capability は合成されるので全体を調べる。独自 command には permission manifest と呼び出し元確認が必要で、plugin permission だけですべての登録 command が制限されるわけではない。

同梱ページ用の出発点(設定へ統合し、名前を挙げた capability を用意する):

{
  "app": {
    "windows": [],
    "security": {
      "csp": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; object-src 'none'; base-uri 'none'",
      "capabilities": ["loading", "docs-main", "settings"]
    }
  }
}

この方針では loading の JS/CSS は別の同梱ファイルに置く。必要な source だけを調整する。Tauri の設定 CSP が HTTP sidecar の応答に自動適用されるわけではない。remote document にはサーバーが CSP header を送り、必要な script/style/asset と、有効なら IPC endpoint だけを許可する。無関係な origin への遷移を拒否し、外部リンクは特権 webview の外で開く。capabilityと CSPを参照。

ズームメニュー項目

ドキュメントビューアーにはズームコントロールが有用である。アプリは現在のズームレベルを AppState に保存し、set_zoom で WebView に適用する。

struct AppState {
    sidecar: Arc<Mutex<Option<Sidecar>>>,
    pnpm_path: Option<PathBuf>,
    zoom: Mutex<f64>,
}

fn apply_zoom(app_handle: &AppHandle, level: f64) {
    let state = app_handle.state::<AppState>();
    *state.zoom.lock().unwrap() = level;
    if let Some(w) = app_handle.get_webview_window("main") {
        let _ = w.set_zoom(level);
    }
}

ズームコントロールのメニュー項目は以下の通りである。

let view_menu = SubmenuBuilder::new(app, "View")
    .item(
        &MenuItemBuilder::with_id("actual_size", "Actual Size")
            .accelerator("CmdOrCtrl+0")
            .build(app)?,
    )
    .item(
        &MenuItemBuilder::with_id("zoom_in", "Zoom In")
            .accelerator("CmdOrCtrl+=")
            .build(app)?,
    )
    .item(
        &MenuItemBuilder::with_id("zoom_out", "Zoom Out")
            .accelerator("CmdOrCtrl+-")
            .build(app)?,
    )
    .build()?;

ハンドラは以下の通りである。

app.on_menu_event(|app_handle, event| match event.id().as_ref() {
    "actual_size" => apply_zoom(app_handle, 1.0),
    "zoom_in" => {
        let state = app_handle.state::<AppState>();
        let z = (*state.zoom.lock().unwrap() + 0.1).min(3.0);
        apply_zoom(app_handle, z);
    }
    "zoom_out" => {
        let state = app_handle.state::<AppState>();
        let z = (*state.zoom.lock().unwrap() - 0.1).max(0.1);
        apply_zoom(app_handle, z);
    }
    _ => {}
});

document.body.style.zoom は document 単位なので遷移でリセットされる。WebviewWindow::set_zoom はネイティブ API を使う。対象 backend で保持を確認し、必要なら page-load 完了時に保存値を再適用する。値を制限し、full navigation と新規 window の両方を確認する。実装を参照。

終了時のプロセスクリーンアップ

終了経路と補助ウィンドウに従う。Settings の close でアプリを終了しない。所有スロットを一度だけ take し、probe と watcher をキャンセルし、グループ全体を止めて後続の登録を防ぐ。メインウィンドウの close、アプリ quit、restart を対象 OS で別々に検証する。

sidecar でのコールドインストールブートストラップ

配布ネイティブ sidecar は管理された setup/build で pnpm install --frozen-lockfile を行い、対象 binary と runtime asset を staging して、packaging 前に検証する。初回起動でネットワーク install を行わない。Node/pnpm もネットワークもない環境で配布物を試す。ネイティブ sidecarの wrapper と plugin の注意点を参照。

実行時 Node の代替経路

本当に Node が必要なら、対応バージョンを文書化し、spawn 前に Node と pnpm を確認する。shim を見つけただけでは runtime が動く証明にならない。version probe の失敗を明示する。開発専用の cold install は opt-in にし、配布アプリが黙って依存を取得する設計にしない。

pnpm の検索

本番モードでは、アプリはユーザーのシステム上で pnpm バイナリを見つける必要がある。GUI アプリはユーザーのシェル PATH を継承しないため、単に pnpm を呼び出すだけでは動作しない。

戦略は、まずハードコードされた既知のパス(バージョンマネージャのシムを含む)を確認し、次に which にフォールバックすることである。

fn find_pnpm() -> Option<PathBuf> {
    // Check well-known installation paths first
    let home = std::env::var("HOME").ok()?;
    let candidates = [
        "/opt/homebrew/bin/pnpm".to_string(),  // Apple Silicon Homebrew
        "/usr/local/bin/pnpm".to_string(),     // Intel Homebrew
        format!("{home}/.volta/bin/pnpm"),     // Volta shim
    ];
    for p in &candidates {
        let path = PathBuf::from(p);
        if path.exists() {
            return Some(path);
        }
    }

    // Fallback: ask the system
    if let Ok(output) = Command::new("/usr/bin/which").arg("pnpm").output() {
        let path_str = String::from_utf8_lossy(&output.stdout)
            .trim()
            .to_string();
        if !path_str.is_empty() {
            let path = PathBuf::from(&path_str);
            if path.exists() {
                return Some(path);
            }
        }
    }

    None
}

Note

GUI アプリのコンテキストでは which が信頼できない場合があるため、ハードコードされたパスを最初に確認する。/usr/bin/which(which だけではなく)を使用するのは、アプリの PATH に /usr/bin が含まれていない可能性があるためである。ユーザーが Volta で Node を pin しているケースが多い場合は $HOME/.volta/bin/pnpm もハードコードリストに含める -- Finder 起動は Volta のシム注入を見ない。

Revision History

作成更新