ドキュメントビューアーアプリ
ローカルドキュメントサーバーを安全に所有する Tauri ラッパー
ホストは所有するローカルサーバーを起動し、遷移先の準備ができるまで同梱ローディング画面を表示する。現在の起動世代の結果が受理されてから遷移する。同梱ネイティブ sidecarを優先し、ユーザーの Node/pnpm 探索は実行時に JavaScript が必要なツールの代替手段とする。
アーキテクチャ概要
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 成功だけでは足りない。サーバーとホストの契約を定義する。
/など実際の遷移先を取得し、200 を要求する。docs/ ホストが生成し今回のサーバーが出力する起動ごとのマーカーを要求する。古いビルドなら
StaleContentにする。HTML パーサーで必要な
<script type="module" src>を取り出し、ルート URL を基準に解決する。要求前に scheme、host、実際の port の一致を確認し、別 origin への redirect を拒否する。JavaScript 本体を全部取得せず 200 を確認する。island マーカーがあるのに期待する module がなければ待機する。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 が信頼できない場合があるため、ハードコードされたパスを最初に確認する。/(which だけではなく)を使用するのは、アプリの PATH に / が含まれていない可能性があるためである。ユーザーが Volta で Node を pin しているケースが多い場合は $HOME/ もハードコードリストに含める -- Finder 起動は Volta のシム注入を見ない。