Read OSS

IPCブリッジ:JavaScriptのinvoke()がRustコマンドに届くまで

上級

前提知識

  • 第1回:アーキテクチャとCrateマップ
  • 第2回:アプリのライフサイクルとBuilderパターン
  • HTTPカスタムプロトコルとURIスキームの基本的な理解
  • Rustの手続きマクロ(proc macro)の概念
  • TypeScript/JavaScriptの基礎知識

IPCブリッジ:JavaScriptのinvoke()がRustコマンドに届くまで

Tauriアプリケーションの核心は、JavaScriptとRustをつなぐブリッジです。フロントエンドから invoke("greet", { name: "World" }) を呼び出すと、そのメッセージはカスタムURIプロトコルを経由してセキュリティ検証をパスします。その後、適切なコマンドハンドラーへルーティングされ、引数がデシリアライズされて最終的にレスポンスが返ってきます。この記事では、その一連の流れを一歩ずつ追いかけます。

JS側:invoke()と__TAURI_INTERNALS__

開発者が直接触れるAPIは packages/api/src/core.ts にあります。invoke() 関数は驚くほどシンプルです:

async function invoke<T>(
  cmd: string,
  args: InvokeArgs = {},
  options?: InvokeOptions
): Promise<T> {
  return window.__TAURI_INTERNALS__.invoke(cmd, args, options)
}

実際の処理はすべて window.__TAURI_INTERNALS__ に委ねられています。これは InvokeInitializationScript によってセットアップされます。このスクリプトはコンパイル時にレンダリングされるJavaScriptテンプレートで、invokeキー、OS名、IPC処理関数が埋め込まれます。

scripts/ipc-protocol.js をテンプレートとして生成される初期化スクリプトは、__TAURI_INTERNALS__.invoke を次のように構築します:

  1. SERIALIZE_TO_IPC_FN による引数値の変換処理を実行する
  2. transformCallback() でコールバックIDとエラーIDを生成する
  3. コールバックID、エラーID、invokeキーをヘッダーに乗せて、ipc://localhost カスタムプロトコルへHTTP POSTを送信する
  4. コールバックが発火したときに解決される Promise を返す
sequenceDiagram
    participant App as Frontend Code
    participant TI as __TAURI_INTERNALS__
    participant IPC as ipc:// Protocol Handler
    participant WV as Webview::on_message()

    App->>TI: invoke("greet", {name: "World"})
    TI->>TI: serialize args, create callback
    TI->>IPC: POST ipc://localhost/greet<br/>Headers: Tauri-Callback, Tauri-Error, Tauri-Invoke-Key
    IPC->>IPC: parse_invoke_request()
    IPC->>WV: on_message(InvokeRequest)
    WV->>WV: verify invoke key
    WV->>WV: ACL check
    WV->>WV: dispatch to command handler
    WV-->>IPC: InvokeResponse
    IPC-->>TI: HTTP Response with Tauri-Response header
    TI-->>App: Promise resolves/rejects

トランスポート層:カスタムプロトコルとヘッダー

Rust側では、IPCプロトコルハンドラーが crates/tauri/src/ipc/protocol.rs に登録されています。IPC通信のメタデータは3つのカスタムヘッダーで運ばれます:

const TAURI_CALLBACK_HEADER_NAME: &str = "Tauri-Callback";
const TAURI_ERROR_HEADER_NAME: &str = "Tauri-Error";
const TAURI_INVOKE_KEY_HEADER_NAME: &str = "Tauri-Invoke-Key";

ハンドラー関数 get() はURIスキームプロトコルとして登録されます。POSTリクエストが届くと、次の処理を行います:

  1. parse_invoke_request() を呼び出してURLパスからコマンド名、HTTPリクエストからヘッダー、そしてボディ(JSONまたは生バイト)を取り出す
  2. ラベルによってwebviewを検索する
  3. パースされた InvokeRequestwebview.on_message() に渡す
  4. コールバッククロージャを通じてレスポンスを受け取り、Tauri-Response ヘッダーに "ok" または "error" をセットしてHTTPレスポンスにマッピングする

CORSヘッダー(Access-Control-Allow-Origin: *)はすべてのレスポンスに付与され、OPTIONSプリフライトリクエストにはCORSヘッダーのみが返されます。webviewのオリジンが ipc:// スキームと異なるため、この処理が必要です。

flowchart TD
    REQ["HTTP POST to ipc://localhost/{cmd}"] --> PARSE["parse_invoke_request()"]
    PARSE --> LOOKUP["Find webview by label"]
    LOOKUP -->|"Found"| ONMSG["webview.on_message(request)"]
    LOOKUP -->|"Not found"| ERR404["404 Response"]
    ONMSG --> RESPOND["Invoke responder callback"]
    RESPOND --> OK["Tauri-Response: ok<br/>200 + body"]
    RESPOND --> FAIL["Tauri-Response: error<br/>400 + error body"]

invokeキーの検証とACLチェック

Webview 上の on_message() メソッドは、2段階のセキュリティゲートを実装しています。

フェーズ1:invokeキーの検証(1723〜1738行目)。すべてのIPCリクエストには、webviewの初期化時に注入されたinvokeキーが含まれています。ハンドラーはこのキーを AppManager に保存されたキーと照合します。一致しない場合、リクエストは無言で破棄されます。これにより、悪意あるスクリプトがIPCコールを偽造するのを防ぎます — 適切に初期化されたTauriのwebviewで動作するコードだけがキーを持っているからです。

フェーズ2:ACL解決(1771〜1826行目)。RuntimeAuthority が、特定のwebviewに対してそのコマンドが許可されているかどうかを解決します:

let (resolved_acl, has_app_acl_manifest) = {
    let runtime_authority = manager.runtime_authority.lock().unwrap();
    let acl = runtime_authority.resolve_access(
        &request.cmd,
        message.webview.window_ref().label(),
        message.webview.label(),
        &acl_origin,
    );
    (acl, runtime_authority.has_app_manifest())
};

resolve_access メソッドはコマンド名でBTreeルックアップを行い、リクエスト元のwebviewのラベルとウィンドウのラベルが、解決されたパーミッションのパターンのいずれかに一致するかを確認します。コマンドがACLで制限されており、どのパーミッションにも一致しない場合、デバッグビルドでは詳細なエラーメッセージ、リリースビルドでは汎用的な「not allowed」メッセージとともにリクエストが拒否されます。

ヒント: パーミッションが定義されていないコマンドに対しては、アプリが独自のACLマニフェストを定義していない限り、ACLチェックがスキップされます。つまり、ケイパビリティファイルを持たないシンプルなアプリはそのまま動作します — セキュリティシステムはデフォルトでブロックする設計ではなく、アプリ定義のコマンドに対しては必要に応じて制限を追加していく設計になっています。

コマンドのルーティング:コアコマンドとプラグインコマンド

セキュリティチェックを通過すると、コマンド名に基づいてルーティングが行われます。プラグインコマンドは次の命名規則に従います:

plugin:{plugin_name}|{command_name}

1788〜1794行目 での解析はこのように行われます:

let plugin_command = request.cmd.strip_prefix("plugin:").map(|raw_command| {
    let mut tokens = raw_command.split('|');
    let plugin = tokens.next().unwrap();
    let command = tokens.next().map(|c| c.to_string()).unwrap_or_default();
    (plugin, command)
});
flowchart TD
    CMD["Incoming Command"] --> CHECK{"Starts with 'plugin:'?"}
    CHECK -->|"Yes"| SPLIT["Split on '|'"]
    SPLIT --> PLUGIN["plugin_name = 'fs'<br/>command = 'read_file'"]
    PLUGIN --> DISPATCH_P["plugins.extend_api(invoke)"]
    CHECK -->|"No"| CORE["Regular command"]
    CORE --> DISPATCH_C["invoke_handler(invoke)"]

プラグインコマンドの場合、invokeはプラグインの extend_api() メソッドにディスパッチされます。通常のコマンドの場合は invoke_handlergenerate_handler! が生成するクロージャ — に渡されます。

#[command]マクロとCommandArgトレイト

#[command] プロシージャルマクロは、通常のRust関数をIPC互換のコマンドに変換します。次のような関数を例にしましょう:

#[tauri::command]
fn greet(name: String, state: State<'_, AppState>) -> String {
    format!("Hello, {}!", name)
}

マクロはラッパー関数を生成し、次の処理を行います:

  1. CommandArg トレイトを使って各パラメーターを取り出す
  2. 元の関数を呼び出す
  3. 結果を InvokeResponse にラップする

CommandArg トレイト が中心的な抽象化です:

pub trait CommandArg<'de, R: Runtime>: Sized {
    fn from_command(command: CommandItem<'de, R>) -> Result<Self, InvokeError>;
}

Deserialize を実装する任意の型に対してブランケット実装が用意されており(62行目)、JSONボディから値をデシリアライズします。一方、State<T>WindowWebviewAppHandle といった特殊な型には、JSONペイロードにアクセスすることなく呼び出しコンテキストから値を取り出すカスタム実装があります。コマンドのシグネチャにJSONデシリアライズされる引数とフレームワーク型を混在させられるのはこのためです。

generate_handler! マクロはコマンド関数のリストを受け取り、コマンド名でマッチしてそれぞれのラッパーを呼び出すクロージャを生成します。

Channel API:フロントエンドへのストリーミング

IPC通信はすべてリクエスト/レスポンス型というわけではありません。Channel APIを使うと、RustからリアルタイムにデータをJavaScriptへ流すことができます — 進捗イベントやファイルシステムウォッチャー、あるいは長期間続くデータフローに最適です。

Rust側では、Channel<TSend>on_message コールバックを持つ Arc<ChannelInner> をラップしています。注目すべき最適化が、しきい値ベースのトランスポートです(35〜39行目):

const MAX_JSON_DIRECT_EXECUTE_THRESHOLD: usize = 8192;
const MAX_RAW_DIRECT_EXECUTE_THRESHOLD: usize = 1024;

小さなペイロード(JSONは8KB未満、生データは1KB未満)は、webview内で直接JavaScriptを評価することで送信されます — 実質的に window.__TAURI_INTERNALS__.callback(id, data) を呼び出すイメージです。大きなペイロードは ChannelDataIpcQueue に格納され、JS側が特別な plugin:__TAURI_CHANNEL__|fetch コマンドで取得します。これにより、大きなバイナリデータをeval文字列にbase64エンコードするオーバーヘッドを避けられます。

JavaScript側では、Channel クラス がインデックスベースの仕組みでメッセージの順序を保証します:

sequenceDiagram
    participant Rust as Rust Channel
    participant WV as WebView
    participant JS as JS Channel

    Rust->>WV: send(index=0, message=A)
    Rust->>WV: send(index=1, message=B)
    Note over WV: index=1 arrives first (out of order)
    WV->>JS: callback({index: 1, message: B})
    JS->>JS: Queue B at index 1
    WV->>JS: callback({index: 0, message: A})
    JS->>JS: Process A (index matches)
    JS->>JS: Drain queue: process B
    Rust->>WV: send({end: true, index: 2})
    JS->>JS: All messages received, cleanup

各メッセージにはインデックスが付与されます。JSの Channel#nextMessageIndex を追跡し、順番通りにのみメッセージを処理します。順序が乱れたメッセージは #pendingMessages にバッファリングされ、期待するインデックスが届くまで待機します。Rust側がチャンネルをドロップすると {end: true, index: N} シグナルが送られ、JS側はそれを受けてクリーンアップを行います。

ヒント: SERIALIZE_TO_IPC_FN シンボル('__TAURI_TO_IPC_KEY__' としてエクスポート)を使うと、複雑なTypeScript型に対してカスタムシリアライズを定義できます。クラスが [SERIALIZE_TO_IPC_FN]() を実装していれば、IPC経由で値を送信する前にそのメソッドが呼び出されます — JSオブジェクトに自然にマッピングされないRust enumの表現などに活用できます。

次の記事では、このIPC通信全体を守るセキュリティモデルに焦点を当てます — ケイパビリティベースのACLシステム、スコープ制約、そしてWebベースの攻撃に対する多層防御について掘り下げます。