概要
Tauriの Commands は、フロントエンド (JavaScript / TypeScript) からバックエンド (Rust) の関数を呼び出すための通信メカニズムである。
この機能により、ReactやVue等のフロントエンドフレームワークから、Rustで実装された高速で安全な処理を直接実行できる。
TauriのCommandsはIPC (Inter-Process Communication) を基盤としており、WebViewプロセスとRustプロセス間でのデータの送受信を行う。
通信はJSONベースのシリアライズを通じて行われ、型安全性を確保しながらデータのやり取りが可能である。
Commandsの主な特徴は以下の通りである。
- 型安全な通信
- Rustの型システムとTypeScriptの型定義により、コンパイル時に型チェックが行われる。
- JSONシリアライズ
- Serdeクレートを使用して、自動的にJSONへの変換が行われる。
- 非同期サポート
- 非同期関数を定義することで、ブロッキングを回避できる。
- エラーハンドリング
- Result型を使用して、構造化されたエラー処理が可能である。
前提条件
Commandsを使用するには、以下に示す前提条件を満たしている必要がある。
必要な依存関係
Cargo.tomlファイルに以下に示す依存関係を追加する。
[dependencies]
tauri = { version = "2", features = ["unstable"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
プロジェクト構成
Tauriプロジェクトの基本的な構成を以下に示す。
my-tauri-app/ ├── src/ # フロントエンド (React / TypeScript) │ ├── App.tsx │ └── main.tsx ├── src-tauri/ # バックエンド (Rust) │ ├── src/ │ │ ├── main.rs # エントリーポイント │ │ └── lib.rs # Commands定義 │ └── Cargo.toml └── package.json
TypeScriptの型定義
フロントエンドで型安全性を確保するために、以下に示すパッケージをインストールする。
npm install @tauri-apps/api
#[tauri::command]マクロ
#[tauri::command] マクロは、Rust関数をフロントエンドから呼び出し可能にするための属性マクロである。
基本的な使い方
マクロを関数に付与することにより、その関数がCommandとして登録される。
use tauri::command;
#[command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
関数シグネチャの要件
Commandとして定義する関数は、以下に示す要件を満たす必要がある。
- 引数の型
- 引数は、
serde::Deserializeを実装している必要がある。 - 基本的な型 (String、i32、bool等) や カスタム構造体を使用できる。
- 引数は、
- 戻り値の型
- 戻り値は、
serde::Serializeを実装している必要がある。 Result<T, E>型も使用可能である。(Eは、Serializeを実装している必要がある)
- 戻り値は、
- ライフタイム
- 引数に参照を使用する場合は、ライフタイムの考慮が必要である。
複数の引数を受け取る関数
複数の引数を受け取る関数の例を以下に示す。
#[command]
fn calculate(a: i32, b: i32, operation: String) -> Result<i32, String> {
match operation.as_str() {
"add" => Ok(a + b),
"subtract" => Ok(a - b),
"multiply" => Ok(a * b),
"divide" => {
if b == 0 {
Err("Division by zero".to_string())
}
else {
Ok(a / b)
}
}
_ => Err(format!("Unknown operation: {}", operation)),
}
}
invoke関数による呼び出し
フロントエンドからCommandを呼び出すには、invoke関数を使用する。
JavaScript / TypeScript側の実装
@tauri-apps/api パッケージから invoke 関数をインポートして使用する。
import { invoke } from '@tauri-apps/api/core'
// 基本的な呼び出し
const result = await invoke<string>('greet', { name: 'Tauri' })
console.log(result) // "Hello, Tauri!"
// エラーハンドリング付き
try {
const result = await invoke<number>('calculate', {
a: 10,
b: 5,
operation: 'add'
})
console.log(result) // 15
}
catch (error) {
console.error('Error:', error)
}
Reactでの使用パターン
ReactコンポーネントでCommandを呼び出す例を以下に示す。
import { useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
interface GreetingProps {
initialName?: string
}
function Greeting({ initialName = '' }: GreetingProps) {
const [name, setName] = useState(initialName)
const [greeting, setGreeting] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleGreet = async () => {
setLoading(true)
setError(null)
try {
const result = await invoke<string>('greet', { name })
setGreeting(result)
}
catch (err) {
setError(err as string)
}
finally {
setLoading(false)
}
}
return (
<div>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="名前を入力"
/>
<button onClick={handleGreet} disabled={loading}>
{loading ? '処理中...' : '挨拶'}
</button>
{greeting && <p>{greeting}</p>}
{error && <p style={{ color: 'red' }}>{error}</p>}
</div>
)
}
export default Greeting
型定義の自動生成
Tauri v2では、tauri-codegen クレートを使用して、TypeScriptの型定義を自動生成できる。
Cargo.tomlファイルに以下に示す設定を追加する。
[build-dependencies]
tauri-build = { version = "2", features = ["codegen"] }
build.rsファイルを作成する。
fn main() {
tauri_build::build()
}
これにより、ビルド時にTypeScriptの型定義ファイルが生成される。
引数の受け渡し (JSONシリアライズ)
Commandへの引数は、JSONシリアライズを通じて渡される。
基本的な引数
基本的な型は自動的にシリアライズされる。
| Rust型 | TypeScript型 | 説明 |
|---|---|---|
| String | string | 文字列 |
| i32, i64 | number | 整数 |
| f64 | number | 浮動小数点数 |
| bool | boolean | 真偽値 |
| Vec<T> | T[] | 配列 |
| Option<T> | null | NULL許容 |
| HashMap<K, V> | Record<K, V> | マップ |
複雑なオブジェクトの受け渡し
カスタム構造体を使用して、複雑なオブジェクトを渡すことができる。
- Rust側の定義
use serde::Deserialize; #[derive(Deserialize)] struct User { id: u32, name: String, email: String, active: bool, } #[command] fn create_user(user: User) -> Result<String, String> { if user.name.is_empty() { return Err("Name is required".to_string()) } Ok(format!("Created user: {} (ID: {})", user.name, user.id)) }
- TypeScript側の呼び出し
interface User { id: number name: string email: string active: boolean } const createUser = async (user: User) => { try { const result = await invoke<string>('create_user', { user }) console.log(result) } catch (error) { console.error('Failed to create user:', error) } } // 使用例 createUser({ id: 1, name: 'John Doe', email: 'john@example.com', active: true })
Serdeによるシリアライズ
Serdeクレートを使用して、シリアライズの動作をカスタマイズできる。
use serde::{Deserialize, Serialize};
// フィールド名のリネーム
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct UserProfile {
user_id: u32,
first_name: String,
last_name: String,
created_at: String,
}
// オプショナルフィールド
#[derive(Deserialize)]
struct UpdateRequest {
#[serde(default)]
name: Option<String>,
#[serde(default)]
email: Option<String>,
}
// カスタムデシリアライザ
#[derive(Deserialize)]
struct Config {
#[serde(deserialize_with = "parse_version")]
version: u32,
}
戻り値の受け取り
Commandからの戻り値は、JSONシリアライズを通じてフロントエンドに返される。
基本的な戻り値
基本的な型を返す例を以下に示す。
#[command]
fn get_timestamp() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
}
#[command]
fn get_app_info() -> (String, String) {
("MyApp".to_string(), "1.0.0".to_string())
}
複雑なオブジェクトの返却
構造体を返すことにより、複雑なデータを渡すことができる。
use serde::Serialize;
#[derive(Serialize)]
struct FileInfo {
name: String,
size: u64,
is_directory: bool,
modified: String,
}
#[command]
fn get_file_info(path: String) -> Result<FileInfo, String> {
let metadata = std::fs::metadata(&path)
.map_err(|e| format!("Failed to read file: {}", e))?;
Ok(FileInfo {
name: path.split('/').last().unwrap_or(&path).to_string(),
size: metadata.len(),
is_directory: metadata.is_dir(),
modified: metadata.modified()
.map(|t| format!("{:?}", t))
.unwrap_or_else(|_| "Unknown".to_string()),
})
}
// TypeScript側での受信
interface FileInfo {
name: string
size: number
is_directory: boolean
modified: string
}
const getFileInfo = async (path: string): Promise<FileInfo | null> => {
try {
const info = await invoke<FileInfo>('get_file_info', { path })
return info
}
catch (error) {
console.error('Failed to get file info:', error)
return null
}
}
型安全な受信
TypeScriptのジェネリクスを使用して、型安全に受信する。
import { invoke } from '@tauri-apps/api/core'
// 型定義
interface ApiResponse<T> {
success: boolean
data: T | null
error: string | null
}
interface UserData {
id: number
name: string
email: string
}
// 型安全なinvokeラッパー
async function typedInvoke<T>(
command: string,
args?: Record<string, unknown>
): Promise<T> {
return invoke<T>(command, args)
}
// 使用例
const fetchUser = async (userId: number) => {
try {
const response = await typedInvoke<ApiResponse<UserData>>(
'fetch_user',
{ userId }
)
if (response.success && response.data) {
console.log('User:', response.data.name)
}
else {
console.error('Error:', response.error)
}
}
catch (error) {
console.error('Command failed:', error)
}
}
generate_handler!マクロによるコマンド登録
定義したCommandを使用するには、generate_handler! マクロで登録する必要がある。
単一コマンドの登録
基本的な登録方法を以下に示す。
// src-tauri/src/lib.rs
use tauri::command;
#[command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
// エクスポート
#[tauri::command]
fn get_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
// main.rsでの登録
// src-tauri/src/main.rs
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet, get_version])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
複数コマンドの登録
複数のCommandを一括で登録する。
// src-tauri/src/lib.rs
mod commands;
pub use commands::*;
// src-tauri/src/commands/mod.rs
pub mod user;
pub mod file;
pub mod system;
pub use user::*;
pub use file::*;
pub use system::*;
// src-tauri/src/main.rs
use my_app::*;
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
// User commands
user::create_user,
user::get_user,
user::update_user,
user::delete_user,
// File commands
file::read_file,
file::write_file,
file::delete_file,
// System commands
system::get_system_info,
system::get_env_var,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
main.rsでの設定
Tauri Builderの設定例を以下に示す。
use tauri::Manager;
fn main() {
tauri::Builder::default()
// Commandsの登録
.invoke_handler(tauri::generate_handler![
greet,
get_version,
calculate,
create_user,
get_file_info,
])
// ウィンドウ設定
.setup(|app| {
#[cfg(debug_assertions)]
{
let window = app.get_webview_window("main").unwrap();
window.open_devtools();
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
サンプルコード
Rust側の実装
// src-tauri/src/lib.rs
use serde::{Deserialize, Serialize};
use tauri::command;
// データ構造
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Todo {
id: u32,
title: String,
completed: bool,
}
// グローバル状態 (実際のアプリではデータベースを使用)
static mut TODOS: Vec<Todo> = Vec::new();
// Todo一覧取得
#[command]
fn get_todos() -> Vec<Todo> {
unsafe { TODOS.clone() }
}
// Todo追加
#[command]
fn add_todo(title: String) -> Result<Todo, String> {
if title.trim().is_empty() {
return Err("Title cannot be empty".to_string())
}
unsafe {
let id = TODOS.len() as u32 + 1;
let todo = Todo {
id,
title,
completed: false,
};
TODOS.push(todo.clone());
Ok(todo)
}
}
// Todo更新
#[command]
fn update_todo(id: u32, completed: bool) -> Result<Todo, String> {
unsafe {
if let Some(todo) = TODOS.iter_mut().find(|t| t.id == id) {
todo.completed = completed;
Ok(todo.clone())
}
else {
Err(format!("Todo with id {} not found", id))
}
}
}
// Todo削除
#[command]
fn delete_todo(id: u32) -> Result<(), String> {
unsafe {
let initial_len = TODOS.len();
TODOS.retain(|t| t.id != id);
if TODOS.len() == initial_len {
Err(format!("Todo with id {} not found", id))
}
else {
Ok(())
}
}
}
TypeScript / React側の実装
// src/App.tsx
import { useState, useEffect } from 'react'
import { invoke } from '@tauri-apps/api/core'
interface Todo {
id: number
title: string
completed: boolean
}
function App() {
const [todos, setTodos] = useState<Todo[]>([])
const [newTodo, setNewTodo] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Todo一覧取得
const fetchTodos = async () => {
try {
const result = await invoke<Todo[]>('get_todos')
setTodos(result)
}
catch (err) {
setError(err as string)
}
}
useEffect(() => {
fetchTodos()
}, [])
// Todo追加
const handleAddTodo = async () => {
if (!newTodo.trim()) return
setLoading(true)
setError(null)
try {
await invoke<Todo>('add_todo', { title: newTodo })
setNewTodo('')
await fetchTodos()
}
catch (err) {
setError(err as string)
}
finally {
setLoading(false)
}
}
// Todo更新
const handleToggleTodo = async (id: number, completed: boolean) => {
try {
await invoke<Todo>('update_todo', { id, completed })
await fetchTodos()
}
catch (err) {
setError(err as string)
}
}
// Todo削除
const handleDeleteTodo = async (id: number) => {
try {
await invoke('delete_todo', { id })
await fetchTodos()
}
catch (err) {
setError(err as string)
}
}
return (
<div style={{ padding: '20px', maxWidth: '600px', margin: '0 auto' }}>
<h1>Todo App</h1>
{/* エラー表示 */}
{error && (
<div style={{ color: 'red', marginBottom: '10px' }}>
{error}
<button onClick={() => setError(null)}>×</button>
</div>
)}
{/* Todo追加フォーム */}
<div style={{ marginBottom: '20px' }}>
<input
type="text"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="新しいTodoを入力"
style={{ padding: '8px', marginRight: '10px', width: '300px' }}
onKeyDown={(e) => e.key === 'Enter' && handleAddTodo()}
/>
<button
onClick={handleAddTodo}
disabled={loading}
style={{ padding: '8px 16px' }}
>
{loading ? '追加中...' : '追加'}
</button>
</div>
{/* Todo一覧 */}
<ul style={{ listStyle: 'none', padding: 0 }}>
{todos.map((todo) => (
<li
key={todo.id}
style={{
display: 'flex',
alignItems: 'center',
padding: '10px',
borderBottom: '1px solid #ccc'
}}
>
<input
type="checkbox"
checked={todo.completed}
onChange={(e) => handleToggleTodo(todo.id, e.target.checked)}
style={{ marginRight: '10px' }}
/>
<span
style={{
flex: 1,
textDecoration: todo.completed ? 'line-through' : 'none'
}}
>
{todo.title}
</span>
<button
onClick={() => handleDeleteTodo(todo.id)}
style={{ color: 'red' }}
>
削除
</button>
</li>
))}
</ul>
{todos.length === 0 && (
<p style={{ textAlign: 'center', color: '#666' }}>
Todoがありません
</p>
)}
</div>
)
}
export default App
推奨される事柄
引数の命名規則
Rust側ではスネークケース (snake_case) を使用して、TypeScript側ではキャメルケース (camelCase) を使用する。
// Rust (snake_case)
#[command]
fn get_user_by_id(user_id: u32) -> Result<User, String> {
// ...
}
// TypeScript (camelCase)
await invoke<User>('get_user_by_id', { userId: 1 })
大きなデータの取り扱い
大きなデータを渡す場合は、パフォーマンスに注意する。
- チャンク化
- 大きなデータは小さなチャンクに分割して処理する。
- ストリーミング
- Channel APIを使用して、ストリーミング処理を行う。
- 圧縮
- 必要に応じてデータを圧縮する。
- 必要に応じてデータを圧縮する。
セキュリティ
CommandsはIPCを通じて通信するため、セキュリティに注意する。
- 入力検証
- 全ての入力を検証し、無効なデータを拒否する。
- 権限管理
- ファイルシステムアクセス等の危険な操作は、必要最小限の権限で実行する。
- エラーメッセージ
- 内部実装の詳細を漏らさないエラーメッセージを返す。
デバッグのヒント
開発時のデバッグに役立つテクニックを以下に示す。
#[command]
fn debug_command(input: String) -> String {
#[cfg(debug_assertions)]
println!("Debug: input = {}", input);
format!("Processed: {}", input)
}
関連情報
- Tauriの基礎 - 非同期Commands
- 非同期処理、Channel API、プログレス表示について
- Tauriの基礎 - Commandsのエラーハンドリング
- エラー処理、thiserror、anyhowについて
- Tauri公式ドキュメント - Commands
- 公式のCommandsリファレンス
- Serde公式ドキュメント
- シリアライズ / デシリアライズの詳細