Tauri IPC コマンドパターン
Tauri v2 IPC のコマンド登録、関数シグネチャ、State アクセス、エラーハンドリング、非同期パターン。
コマンド登録
すべての IPC コマンドは main.rs の invoke_handler マクロに登録する必要がある。これがフロントエンドが呼び出せるものの唯一の情報源である:
.invoke_handler(tauri::generate_handler![
// File operations
commands::files::messages_list,
commands::files::messages_read,
commands::files::messages_write,
commands::files::messages_delete,
commands::files::messages_create,
commands::files::draft_read,
commands::files::draft_write,
commands::files::draft_clear,
// Settings
commands::settings::settings_get,
commands::settings::settings_save,
// Workspace
commands::workspace::workspace_switch,
// Watchers
commands::watchers::pins_watch_file,
commands::watchers::pins_unwatch_file,
// Native dialog
native::dialog::open_directory,
])Warning
generate_handler! に記載されていないコマンドは、フロントエンドから呼び出してもサイレントに失敗する。すべての #[tauri::command] 関数が登録されているかどうかのコンパイル時チェックは存在しない。
コマンドの関数シグネチャ
基本的なコマンド
#[tauri::command] 属性は、フロントエンドから呼び出し可能な関数としてマークする:
#[tauri::command]
pub fn get_home_dir() -> String {
dirs::home_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default()
}フロントエンドからの呼び出し:
const homeDir = await invoke<string>("get_home_dir");パラメータ付きコマンド
パラメータはフロントエンドの引数オブジェクトからデシリアライズされる:
#[tauri::command]
pub fn messages_read(
state: State<'_, Arc<AppState>>,
filename: String,
) -> Option<String> {
let root = get_project_root_string(&state).ok()?;
let archives_dir = get_archives_dir(&root);
match safe_path(&archives_dir, &filename) {
Ok(path) => read_file_or_none(path.to_str()?),
Err(_) => None,
}
}フロントエンドからの呼び出し:
const content = await invoke<string | null>("messages_read", {
filename: "2024-01-15-meeting-notes.md",
});Note
Rust 関数のパラメータ名は、フロントエンドの引数オブジェクトのプロパティ名と一致する必要がある。Rust は snake_case を使用し、フロントエンドも(camelCase ではなく)snake_case をキーに使用する必要がある。
State<AppState> 付きコマンド
State エクストラクタを通じて共有アプリケーション状態にアクセスする。Arc ラッピングに注意:
#[tauri::command]
pub fn settings_get(
state: State<'_, Arc<AppState>>,
) -> Option<serde_json::Value> {
let root = state
.project_root
.lock()
.map_err(|e| format!("Failed to lock project root: {}", e))
.ok()?
.clone();
if root.is_empty() {
return None;
}
read_settings(&root, &**state)
}State は Tauri によって自動的に注入される -- フロントエンドからは渡さない:
// State is NOT passed from frontend -- it's injected by Tauri
const settings = await invoke("settings_get");&**state パターンは State と Arc を通じてデリファレンスし、&AppState 参照を取得する:
state: State<'_, Arc<AppState>>
*state -> Arc<AppState> (deref State)
**state -> AppState (deref Arc)
&**state -> &AppState (borrow)戻り値の型とエラーハンドリング
Option<T> を返す
値がないことがエラーではない場合に Option を使用する:
#[tauri::command]
pub fn draft_read(
state: State<'_, Arc<AppState>>,
) -> Option<String> {
let root = get_project_root_string(&state).ok()?;
let path = get_draft_path(&root);
read_file_or_none(&path) // Returns None if file doesn't exist
}フロントエンドでは、Option::None は null になる:
const content = await invoke<string | null>("draft_read");
if (content === null) {
// No draft exists
}Result<T, String> を返す
エラーの詳細を伝える必要がある場合に Result を使用する:
#[tauri::command]
pub fn messages_create(
state: State<'_, Arc<AppState>>,
name: String,
content: String,
) -> Result<String, String> {
let root = get_project_root_string(&state)?; // ? propagates String error
let archives_dir = get_archives_dir(&root);
fs::create_dir_all(&archives_dir)
.map_err(|e| format!("Failed to create archives dir: {}", e))?;
let filename = generate_filename(&name);
let path = safe_path(&archives_dir, &filename)?;
fs::write(&path, &content)
.map_err(|e| format!("Failed to write file: {}", e))?;
Ok(filename)
}フロントエンドでは、Err は Promise を reject する:
try {
const filename = await invoke<string>("messages_create", {
name: "Meeting Notes",
content: "# Meeting Notes\n\n...",
});
console.log("Created:", filename);
} catch (error) {
// error is the String from Err(...)
console.error("Failed:", error);
}Warning
Tauri v2 はエラー型が String(または Into<InvokeError> を実装する型)であることを要求する。カスタムエラー構造体を直接返すことはできない。.map_err(|e| format!("...: {}", e)) を使用してエラーを変換する。
bool を返す
エラー詳細なしの単純な成功/失敗の場合:
#[tauri::command]
pub fn settings_save(
state: State<'_, Arc<AppState>>,
settings: serde_json::Value,
) -> bool {
let root = match state.project_root.lock() {
Ok(r) => r.clone(),
Err(_) => return false,
};
if root.is_empty() {
return false;
}
if !settings.is_object() {
return false;
}
save_settings(&root, &settings, &**state)
}シリアライズ可能な構造体を返す
Serialize を derive して複雑なデータを返す:
#[derive(Serialize, Clone, Debug)]
pub struct DraftDeleteResult {
#[serde(rename = "newCount")]
pub new_count: u32,
#[serde(rename = "newActive")]
pub new_active: u32,
}
#[tauri::command]
pub fn drafts_delete(
state: State<'_, Arc<AppState>>,
draft_number: u32,
) -> Option<DraftDeleteResult> {
// ...
Some(DraftDeleteResult {
new_count,
new_active,
})
}Tip
#[serde(rename = "camelCase")] を使用して、Rust の snake_case フィールド名を JavaScript/TypeScript フロントエンドが期待する camelCase 規約に変換する。
非同期コマンド
ネイティブダイアログのように Tauri 非同期ランタイムを必要とする操作では、コマンドを async にする:
#[tauri::command]
pub async fn open_directory(app: AppHandle) -> Option<String> {
tauri::async_runtime::spawn_blocking(move || {
app.dialog()
.file()
.blocking_pick_folder()
.and_then(|fp| {
fp.as_path()
.map(|p| p.to_string_lossy().to_string())
})
})
.await
.ok()
.flatten()
}Note
非同期コマンドは .await ポイントをまたいでハンドルを所有する必要がある場合、State の代わりに AppHandle を受け取る。State と AppHandle の両方を受け取ることも可能で、最初の .await の前に State から必要なデータを抽出する。
コマンドモジュールの整理
ドメインごとにコマンドを commands/ ディレクトリに整理する:
src/ commands/
mod. rs # pub mod declarations
files. rs # CRUD operations for files
settings. rs # Settings read/ write
watchers. rs # File watcher management
terminal. rs # PTY/ terminal commands
workspace. rs # Workspace switching
window. rs # Window management commands
fonts. rs # Font listing// commands/mod.rs
pub mod files;
pub mod fonts;
pub mod settings;
pub mod terminal;
pub mod watchers;
pub mod window;
pub mod workspace;内部ヘルパーパターン
共有ロジックをコマンドでないヘルパー関数に抽出する:
// Not a command -- internal helper
fn get_project_root_string(
state: &AppState,
) -> Result<String, String> {
state
.project_root
.lock()
.map(|r| r.clone())
.map_err(|e| format!("Failed to lock project root: {}", e))
}
// Command that uses the helper
#[tauri::command]
pub fn draft_read(
state: State<'_, Arc<AppState>>,
) -> Option<String> {
let root = get_project_root_string(&state).ok()?;
let path = get_draft_path(&root);
read_file_or_none(&path)
}これにより、コマンドはパラメータ処理に集中した薄いものになり、実際のロジックは複数のコマンドから呼び出し可能な再利用可能な関数に配置される。
バンドル済みローディングページからの IPC 呼び出し
ラッパーアプリのバンドル済み frontend/(ローディングページ)は、しばしばバンドラを持たない -- スピナーを表示して実際の開発サーバーにナビゲートするまでのごく短い時間のために frontendDist から配信されるプレーンな HTML である。このページからバックエンドのイベントを listen したり、カスタムコマンドを invoke する必要がある場合(エラー状態のローディングページの retry_launch など)、Tauri JS SDK を import することはできない。唯一の選択肢は window.__TAURI__ である。
設定で 2 つのディテールを揃える必要がある。どちらも見落としやすい:
1. withGlobalTauri を有効にする
Tauri v2 では app.withGlobalTauri のデフォルトは false。これはつまり window.__TAURI__ が webview 内にそもそも存在しないということで、window.__TAURI__.event.listen は TypeError: Cannot read properties of undefined になる。ページは無言で失敗する -- 例外を明示的にログに出していない限り、フロントエンドにエラーは現れない。
// tauri.conf.json
{
"app": {
"withGlobalTauri": true
}
}Warning
これは典型的な「なぜイベントリスナーが動かないのか」の罠である。バンドル済みローディングページが window.__TAURI__ を少しでも使っていて、かつ withGlobalTauri: true を明示的に設定していないなら、何も動かず、フロントエンドにはエラーも出ない。失敗が可視化されるようにページ冒頭で防御的ログを仕込むとよい:
if (!window.__TAURI__) { console.error("window.__TAURI__ missing — set withGlobalTauri: true"); }2. core:default で既にカバーされている
典型的なローディングページ(1 つのイベントをリスンし、1〜2 個のカスタムコマンドを invoke する)では、capabilities/ の core:default で十分である。event:listen、event:unlisten、そしてカスタム #[tauri::command] 関数が使う invoke の配線がすべて付与される。core:allow-emit-to-any のような広すぎる権限を先回りで付与してはいけない -- 必要ないし、webview が触れる面積を無用に広げるだけだ。
// capabilities/default.json — unchanged from the default
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"windows": ["main"],
"permissions": ["core:default"]
}グローバルの使い方
この 2 点が揃っていれば、バンドル済みページから直接リスンと invoke ができる:
<script>
const { event, core } = window.__TAURI__;
// Listen for a backend event
const unlisten = await event.listen("launch-error", ({ payload }) => {
// payload is whatever Rust sent via Emitter::emit
});
// Invoke a custom command
await core.invoke("retry_launch");
</script>Note
window.__TAURI__.core.invoke は @tauri- が再エクスポートしている ESM 版の invoke と同じ関数である。ペイロードのシリアライズも戻り値のデシリアライズも同一 -- バンドル済みページは劣化した API を掴まされるわけではなく、import スタイルが違うだけだ。
フロントエンドからのプラグイン呼び出し: クリップボード
すべてのフロントエンドからバックエンドへの呼び出しがカスタム #[tauri::command] というわけではない。Tauri 公式プラグインは、JS SDK から直接 import できる既製の関数を提供する。クリップボードへの書き込みはその典型例だ。「エクスポートをクリップボードにコピー」ボタンは、@tauri-apps/plugin-clipboard-manager の writeText を使う。
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
async function handleCopy(content: string) {
if (!content) return;
try {
await writeText(content);
// show a transient "Copied!" state
} catch {
console.error("Failed to copy to clipboard");
}
}なぜ navigator.clipboard ではなくプラグインなのか
ブラウザの navigator.clipboard.writeText は WebView 内にも存在するが、そこでは信頼できない。navigator.clipboard はセキュアコンテキスト要件とブラウザ式の権限プロンプトによってゲートされており、そのどちらも Tauri が動作する macOS・Windows・Linux の WebView 実装間で一貫した挙動を示さない。プラットフォームやウィンドウのフォーカスのされ方によっては、呼び出しが無言で reject されたりハングしたりする。
プラグインは書き込みを WebView の JS クリップボードではなくネイティブの Rust クリップボード API 経由でルーティングする。これによりセキュアコンテキストと権限の癖を完全に回避するため、同じ呼び出しがすべてのプラットフォームで同じように動作する。
Capability とプラグイン init
セットアップは 2 つ必要で、どちらか欠けるとコンパイル時ではなく実行時に失敗する。
まず、ウィンドウの capability ファイルで clipboard-manager:default 権限を付与する:
// capabilities/default.json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"windows": ["main"],
"permissions": ["core:default", "clipboard-manager:default"]
}次に、main.rs でプラグインを登録する(tauri-plugin-clipboard-manager クレートと @tauri-apps/plugin-clipboard-manager npm パッケージの追加も必要):
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_clipboard_manager::init())
// ... other plugins, .invoke_handler(...), etc.
.run(tauri::generate_context!())
.expect("error while running tauri application");
}Warning
capability が欠けていると、writeText は実行時に権限エラーで reject される。プラグインが初期化されていないと、背後のコマンドが存在しないため呼び出しが失敗する。どちらもコンパイル時には現れない -- ボタンがクリックされたときに初めて失敗が表面化する。
重要なポイント
すべてのコマンドを
generate_handler!に登録する -- コンパイル時チェックは存在しないState<'_, Arc<AppState>>を使用する -- バックグラウンドスレッドと状態を共有するにはArcが必要失敗の詳細がある場合は
Result<T, String>を返す不在が通常の場合は
Option<T>を返す(ファイルが見つからない、状態が空など).map_err(|e| format!(...))を使用してすべてのエラー型をStringに変換するフロントエンドに適したフィールド名のために
Serializeをserde(rename)付きで derive するコマンドは薄く保つ -- 内部ヘルパー関数に委譲する
バンドル済みローディングページには
withGlobalTauri: trueが必要 -- そうでなければwindow.__TAURI__は無言で存在しない