ファイル操作や OS の情報の取得など、WebView の JavaScript だけではできない処理を Rust で書いて呼び出す入口が「コマンド」です。Rust の関数に #[tauri::command] を付けて generate_handler! で登録すると、JS から invoke('関数名') で呼べます。このレシピは Rust 側の定義と登録(関数の形、モジュールへの分け方、属性)を扱います。JS から呼ぶ側は Rust のコマンドを JS から呼び出す (invoke) を参照してください。
前提条件
プラグインは不要です。アプリ自身のコマンドは、既定では capability に何も書かなくても同梱したページから呼べます。権限の確認が入るのは、build.rs でアプリのコマンド一覧(tauri_build::AppManifest)を宣言した場合と、リモートの URL を表示したウィンドウから呼ぶ場合で、capability で許可しないと拒否されます。
1. コマンドを定義する (Rust)
Tauri v2 のテンプレートでは main.rs は lib.rs の run() を呼ぶだけで、コマンドの定義と登録は src-tauri/src/lib.rs に書きます。引数と戻り値には次のものが使えます。
| 書く場所 | 使えるもの | 値の出どころ |
|---|---|---|
| 引数 | serde::Deserialize を実装した型(String、数値、構造体、Option など) | JS の invoke の第 2 引数 |
| 引数 | tauri::AppHandle、tauri::WebviewWindow、tauri::State など | Tauri が自動で渡す(JS からは渡さない) |
| 戻り値 | serde::Serialize を実装した型、Result、なし | invoke の Promise の結果 |
// src-tauri/src/lib.rs
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct NewNote {
title: String,
body: String,
}
#[derive(Serialize)]
struct Note {
id: u32,
title: String,
}
/// AppHandle は Tauri が渡すので、JS からは引数なしで呼ぶ
#[tauri::command]
fn app_version(app: tauri::AppHandle) -> String {
app.package_info().version.to_string()
}
/// JS から受け取る引数(note)と、Tauri が渡す引数(window)は並べて書ける
#[tauri::command]
fn create_note(window: tauri::WebviewWindow, note: NewNote) -> Result<Note, String> {
if note.title.trim().is_empty() {
return Err("タイトルが空です".into()); // JS 側では reject になる
}
println!("[{}] {}({} 文字)", window.label(), note.title, note.body.chars().count());
Ok(Note { id: 1, title: note.title })
}
引数の渡し方は Rust コマンドに引数を渡す、戻り値の形は Rust コマンドからの戻り値を受け取る、Err の型の設計は Result 型を使ってエラーハンドリングする、State は State と Mutex でアプリの状態を管理する で扱います。#[tauri::command] を付けても普通の関数として残るので、Rust から直接呼んだりテストしたりできます。
2. モジュールに分ける (Rust)
コマンドが増えたら別ファイルに分けます。要点は lib.rs に直接書くコマンドには pub を付けず、別モジュールのコマンドには付ける ことです。#[tauri::command] は関数ごとに __cmd__関数名 という補助のマクロを作り、pub の関数ではそれをクレートの最上位にも公開します。lib.rs は最上位そのものなので、pub を付けると同じ名前が二重に定義されてエラーになります。逆にモジュール側で pub を忘れると、lib.rs から補助のマクロが見えずに登録できません。
// lib.rs に `mod commands;` と書き、この { } の中身を src-tauri/src/commands.rs に置くのと同じ
mod commands {
/// lib.rs の generate_handler! から参照するので pub を付ける
#[tauri::command]
pub fn list_titles(max_count: u32) -> Vec<String> {
(1..=max_count.min(3)).map(|i| format!("メモ {i}")).collect()
}
#[tauri::command]
pub fn remove_note(id: u32) -> Result<(), String> {
if id == 0 {
return Err("id は 1 以上を指定してください".into());
}
Ok(())
}
/// 開発ビルドだけで登録する(次の節の run() を参照)
#[tauri::command]
pub fn debug_info() -> String {
format!("{} / {}", std::env::consts::OS, std::env::consts::ARCH)
}
}
- ファイルに分けるときは、lib.rs に
mod commands;の 1 行を書き、src-tauri/src/commands.rsに#[tauri::command] pub fn …を直接並べます(mod commands { }で囲む必要はありません)。 - JS から呼ぶ名前にモジュール名は付かず、
commands::list_titlesも'list_titles'です。コマンド名はアプリ全体で重ならないようにします。別々のモジュールに同名のpubコマンドがあると、補助のマクロが衝突してエラーになります。
3. generate_handler! に登録する (Rust)
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
// invoke_handler は 1 回だけ。コマンドはすべてこの 1 つに並べる
.invoke_handler(tauri::generate_handler![
app_version,
create_note,
search_notes,
commands::list_titles, // JS からは 'list_titles'
commands::remove_note,
#[cfg(debug_assertions)]
commands::debug_info, // tauri dev のビルドだけで登録される
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
invoke_handlerを 2 回呼ぶと、後から渡したものだけが残ります。「前半に書いたコマンドだけ見つからない」ときはここを疑います。- 項目には属性を付けられます。
#[cfg(debug_assertions)]を付けたコマンドはリリースビルドでは登録されないので、デバッグ用のコマンドを配布物に残さずに済みます。 - 登録漏れはコンパイルでは分からず、呼んだときに初めて reject されます。
4. 属性で呼び方を変える (Rust)
#[tauri::command(...)] の括弧の中で、コマンドごとに次の指定ができます。
| 指定 | 効果 |
|---|---|
rename_all = "snake_case" | JS から渡すキーを引数名のまま(search_text)にする。既定は camelCase(searchText) |
async | async fn でない関数を、メインスレッドではなく非同期ランタイムのスレッドで実行する |
rename = "..." | JS から呼ぶ名前を関数名と別にする。Tauri 2.0 の時点には無かった指定なので、古い 2.x では使えない |
/// JS からも search_text / max_count のまま渡す
#[tauri::command(rename_all = "snake_case")]
fn search_notes(search_text: String, max_count: Option<u32>) -> Vec<String> {
["買い物メモ", "会議メモ", "日記"]
.iter()
.filter(|t| t.contains(search_text.as_str()))
.take(max_count.unwrap_or(10) as usize)
.map(|t| t.to_string())
.collect()
}
rename_all はコマンド単位の指定で、アプリ全体をまとめて切り替える設定はありません。値は "camelCase" と "snake_case" の 2 つだけで、変換されるのは引数名だけです(構造体のフィールド名は serde の属性で決まる。詳細は Rust コマンドに引数を渡す)。混在するとコマンドごとにキーの書き方を調べることになるので、既定のまま使うか全コマンドで揃えます。async の指定と async fn の違いは 非同期(async)コマンドを定義する で説明します。
動作確認
npm run tauri dev で起動し、次の関数をボタンなどから呼びます。
import { invoke } from '@tauri-apps/api/core';
type Note = { id: number; title: string };
export async function tryCommands() {
console.log(await invoke<string>('app_version'));
console.log(await invoke<Note>('create_note', { note: { title: '買い物', body: '牛乳と卵' } }));
console.log(await invoke<string[]>('list_titles', { maxCount: 2 }));
console.log(await invoke<string[]>('search_notes', { search_text: 'メモ' }));
try {
await invoke('list_titles', { max_count: 2 }); // わざとキー名を間違える
} catch (e) {
console.error(e);
}
}
開発者ツールのコンソールには次のように出ます(1 行目は tauri.conf.json の version)。
0.1.0
{id: 1, title: '買い物'}
['メモ 1', 'メモ 2']
['買い物メモ', '会議メモ']
invalid args `maxCount` for command `list_titles`: command list_titles missing required key maxCount
create_note の println! は開発者ツールではなく、tauri dev を実行したターミナルに [main] 買い物(4 文字) と出ます。
よくあるエラーと対処法
- 「Command list_titles not found」で reject される:
generate_handler!への登録漏れか、名前の打ち間違いです。JS 側で'commands::list_titles'のようにモジュール名を付けた場合も同じエラーになります。#[cfg(debug_assertions)]を付けたコマンドは、リリースビルドではこのエラーになります。 - 「macro import
__cmd__list_titlesis private」(E0603): モジュール内のコマンドにpubを付け忘れています。 - 「the name
__cmd__list_titlesis defined multiple times」: lib.rs に直接書いたコマンドにpubを付けた(E0255)か、別々のモジュールに同じ名前のpubコマンドがあります(E0428)。 - 「command list_titles missing required key maxCount」で終わるエラー: JS 側のキー名が違います。既定のキーは引数名の camelCase です。
Optionの引数なら省略しても通ります。 - 「expected "camelCase" or "snake_case"」:
rename_allにそれ以外の値("kebab-case"など)を書いたときのコンパイルエラーです。
OS ごとの違いと注意点
- 共通:
asyncでないコマンドはメインスレッドで動くので、時間のかかる処理の間はウィンドウの移動やほかのコマンドの応答が止まります(非同期(async)コマンドを定義する)。 - Windows: 同期コマンドの中でウィンドウを作ると固まる(デッドロックする)既知の問題があります。ウィンドウを作るコマンドは
async fnにします。 - Windows のリリースビルド: テンプレートの
main.rsはコンソールを出さない設定なので、println!の出力は見えません。残したい情報は Log プラグインでログファイルを出力する の方法でファイルに書きます。 - コマンドはどのウィンドウからでも呼べます。呼び出し元で動きを変えるなら
tauri::WebviewWindow引数のlabel()で判定します。
