設定値やキャッシュ、接続中の相手、カウンターのように、Rust 側でアプリの実行中ずっと持っておきたい値は、グローバル変数ではなく Tauri に預けます。manage() で登録した値は、コマンド の引数に State<T> と書くだけで受け取れ、setup やイベント処理からも取り出せます。書き換える値は Mutex で包みますが、標準ライブラリと tokio のどちらを使うか、ロックをどこまで持つかを誤ると、画面が固まったりコンパイルが通らなかったりします。その判断の仕方を中心に説明します。
前提条件
プラグインも capability の権限も要りません。invoke_handler に登録した自作のコマンドは、アプリ用の権限定義を作っていない限り、そのままフロントエンドから呼べます。std::sync::Mutex だけで済むなら追加のクレートも不要です。この記事では tokio の Mutex と sleep() も使うので、tokio を追加します(tokio の Mutex だけなら、同じ型が tauri::async_runtime::Mutex として使えます)。
cd src-tauri
cargo add tokio --features sync,time
包み方は、値の使い方で決めます。
| 値の使い方 | 登録する形 |
|---|---|
| 起動時に決まり、あとは読むだけ(パス・設定値) | そのまま manage(値) |
| コマンドから書き換える | std::sync::Mutex で包む |
| 読むことが多く、書くことはまれ | std::sync::RwLock で包む |
ロックしたまま .await したい(接続を順番に使う) | tokio::sync::Mutex で包む |
Arc で包む必要はありません。登録した値は Tauri がアプリの終了まで持ち続けます。
1. バックエンドから実装する (Rust)
登録して、コマンドで受け取る
Builder::manage() で登録し、コマンドの引数に State<'_, 型> を書きます。どの値が渡るかは型だけで決まるので、同じ Mutex<u32> を 2 つ持ちたいときは、それぞれ別の構造体で包みます。
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::{Manager, State};
/// 起動時に決まり、あとは読むだけの値
struct AppPaths {
data_dir: PathBuf,
}
/// コマンドから書き換える値は Mutex で包む
#[derive(Default)]
struct Counter {
count: Mutex<u32>,
}
#[tauri::command]
fn get_data_dir(paths: State<'_, AppPaths>) -> String {
paths.data_dir.display().to_string()
}
#[tauri::command]
fn increment(counter: State<'_, Counter>) -> Result<u32, String> {
let mut count = counter.count.lock().map_err(|e| e.to_string())?;
*count += 1;
Ok(*count) // 関数を抜けるとガード(count)が破棄され、ロックが外れる
}
lock() はロックが取れるまで待ちます。async でないコマンドはメインスレッドで動くので、ここで長く待つと画面ごと固まります。ロックの中では値の読み書きだけにし、重い処理は外で行います。
async コマンドでは .await の前にロックを外す
std::sync::Mutex は async コマンドでも使えます。条件は、lock() が返すガードを持ったまま .await しないことで、ブロック { } で囲んで先に捨てます。なお State を受け取る async コマンドは、戻り値を Result にする必要があります(非同期(async)コマンドを定義する)。
use std::collections::HashMap;
/// 取得に時間のかかる値のキャッシュ
#[derive(Default)]
struct Cache {
entries: Mutex<HashMap<String, String>>,
}
#[tauri::command]
async fn load_item(key: String, cache: State<'_, Cache>) -> Result<String, String> {
// 1. ロックして確かめ、ブロックを抜けたところで外す
let hit = {
let entries = cache.entries.lock().map_err(|e| e.to_string())?;
entries.get(&key).cloned()
};
if let Some(value) = hit {
return Ok(value);
}
// 2. ロックを持たずに待つ(実際の通信やファイル読み込みの代わり)
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let value = format!("{key} の値");
// 3. 書き込むときだけ、もう一度ロックする
cache.entries.lock().map_err(|e| e.to_string())?.insert(key, value.clone());
Ok(value)
}
この書き方だと、待っている間に同じキーの要求が来れば取得が 2 回走ります。1 件ずつ順番に処理したい、接続を 1 本だけ使い回したいなど、ロックしたまま待つ必要があるときは tokio::sync::Mutex にします。lock().await で順番を待ち、待つ間もスレッドをふさぎません。
/// 送信を 1 件ずつ順番に行うための状態(実際には接続などを入れる)
#[derive(Default)]
struct Outbox {
sent: tokio::sync::Mutex<Vec<String>>,
}
#[tauri::command]
async fn send_in_order(message: String, outbox: State<'_, Outbox>) -> Result<usize, String> {
let mut sent = outbox.sent.lock().await; // 前の送信が終わるまでここで待つ
tokio::time::sleep(std::time::Duration::from_millis(300)).await; // 送信の代わり
sent.push(message);
Ok(sent.len())
}
どちらでもよい場面では、速くて扱いやすい std::sync::Mutex を選びます。
setup で登録する・コマンド以外から読む
app.path() のように、アプリが動き出してから決まる値は setup の中で app.manage() します(アプリ起動・終了時の処理を書く)。use tauri::Manager; があれば、App や AppHandle から state::<型>() で取り出せます。別スレッドには State ではなく AppHandle を渡し、スレッドの中で取り出します(AppHandle を使ってアプリ全体を操作する)。
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(Counter::default())
.manage(Cache::default())
.manage(Outbox::default())
.setup(|app| {
// アプリが動き出してから決まる値は setup で登録する
let data_dir = app.path().app_data_dir()?;
app.manage(AppPaths { data_dir });
// setup からも読み書きできる(例: 保存しておいた値から始める)
*app.state::<Counter>().count.lock().unwrap() = 100;
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_data_dir,
increment,
load_item,
send_in_order
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
2. フロントエンドから呼び出す (TypeScript)
呼び出し方は普通のコマンドと同じで、State の引数は JS から渡しません。
import { invoke } from '@tauri-apps/api/core';
// 呼ぶたびに 1 増えた値が返る
console.log(await invoke<number>('increment'));
// 1 回目は約 0.5 秒かかり、2 回目はキャッシュからすぐ返る
console.log(await invoke<string>('load_item', { key: 'profile' }));
console.log(await invoke<string>('load_item', { key: 'profile' }));
// 3 件を同時に送っても、Rust 側では 1 件ずつ順番に処理される
const counts = await Promise.all(
['a', 'b', 'c'].map((message) => invoke<number>('send_in_order', { message })),
);
console.log(counts);
動作確認
npm run tauri dev で起動して上のコードを実行すると、DevTools のコンソールに次のように出ます。setup で 100 を入れているので、カウンターは 101 から始まります。
101
profile の値
profile の値
[1, 2, 3]
2 行目は約 0.5 秒後、3 行目はすぐに出ます。最後の行は約 0.9 秒後で、1〜3 が重複せずに 1 つずつ入ります。
よくあるエラーと対処法
- 「state not managed for field
counteron commandincrement. You must call.manage()before using this command」: 登録した型と受け取る型が違うか、登録していません。manage(Mutex::new(0u32))で登録してState<'_, Counter>で受け取るような場合で、コンパイルは通り、呼んだときにinvokeが失敗します。 - 「state() called before manage() for ...」で落ちる:
app.state::<型>()を登録前に呼んでいます。登録されているか分からない値はtry_state()でOptionとして受け取ります。 - 「future cannot be sent between threads safely」:
std::sync::Mutexのガードを持ったまま.awaitしています。ブロックで先に外すか、tokio のMutexにします。 - 「async commands that contain references as inputs must return a
Result」:Stateを受け取るasyncコマンドの戻り値がResultになっていません。 - 呼ぶと固まって戻らない: 同じ関数の中で同じ
Mutexを 2 回lock()しています。std::sync::Mutexは同じスレッドからでも二重に取れず、永久に待ちます。 - 「state for type '...' is already being managed」で起動しない: 同じ型を
Builder::manage()で 2 回登録しています。app.manage()の場合は落ちずにfalseを返し、最初の値が残ります。
注意点
- 再起動すると消える: State はメモリ上にあるだけです。残したい値は終了時にファイルへ書き出すか(rust-012)、Store プラグインに保存します。
- 全ウィンドウで共有: どのウィンドウから呼んでも同じ値です。ウィンドウごとに分けるなら、ラベルをキーにした
HashMapにします(ラベルの扱いは WindowHandle で特定のウィンドウを操作する)。 - 取り除けない: 登録した値を外す
unmanage()は非推奨です。途中で手放す値(切断した接続など)はMutex<Option<T>>にしてtake()します。 - パニックの後:
std::sync::Mutexはロック中にパニックが起きると、以後のlock()がエラーを返します。unwrap()のままだと、そのロックを使う処理がすべてパニックするので、例のようにmap_err()でエラーとして返すと JS 側で原因が分かります。
