State と Mutex でアプリの状態を管理する

manage() で登録した値をコマンドの State 引数で受け取り、Mutex で書き換える。std と tokio の Mutex の使い分け、await をまたいでロックを持たない書き方、型の不一致も示す。

Rust バックエンド 対象: Tauri 2.x 更新日: 読了目安: 約6分 rust-006
目次
  1. 前提条件
  2. 1. バックエンドから実装する (Rust)
  3. 登録して、コマンドで受け取る
  4. async コマンドでは .await の前にロックを外す
  5. setup で登録する・コマンド以外から読む
  6. 2. フロントエンドから呼び出す (TypeScript)
  7. 動作確認
  8. よくあるエラーと対処法
  9. 注意点
  10. 関連レシピ

設定値やキャッシュ、接続中の相手、カウンターのように、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 counter on command increment. 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 側で原因が分かります。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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