Tauri コマンド関数を定義する

#[tauri::command] を付けた Rust 関数を generate_handler! に登録して JS から呼べるようにする。モジュールに分けるときの pub の付け方、rename_all、開発ビルドだけの登録も示す。

Rust バックエンド 対象: Tauri 2.x 更新日: 読了目安: 約9分 rust-001
目次
  1. 前提条件
  2. 1. コマンドを定義する (Rust)
  3. 2. モジュールに分ける (Rust)
  4. 3. generate_handler! に登録する (Rust)
  5. 4. 属性で呼び方を変える (Rust)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

ファイル操作や 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)
asyncasync 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_titles is private」(E0603): モジュール内のコマンドに pub を付け忘れています。
  • 「the name __cmd__list_titles is 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() で判定します。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する