3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Tauri 2.0 に入門する - コマンド編 -

Last updated at Posted at 2025-12-08

こんにちは!

少し前に投稿した記事で、Tauri2.0の開発環境の準備からnpm create tauri-app@latest でのプロジェクト作成、そしてサンプルのgreet コマンドを動かすところまでを駆け足で紹介しました。

「ひとまず Hello World までは動いたけど、ここからどうやって“自分のアプリの機能” を作っていけばいいの?」
と感じた方も多いのではないでしょうか。

そこで今回は、Tauri アプリの中身を支える Rustコードをフロントエンド(この記事では Vue + TypeScript を想定)から呼び出すための仕組み―― コマンド (command) を掘り下げてみます。

今回も「実験ノートをのぞき見る」ようなテンションで、公式ドキュメント「フロントエンドから Rust を呼び出す(コマンド)」の内容をベースにしつつ、実際のコードに落とし込んでいきます。

はじめに

まずは、この記事の立ち位置とゴールを軽く整理しておきます。

前回のおさらい

前回までに、だいたい次のところまで進みました。

  • Windows 上に Tauri 2.0 の開発環境を構築した
  • npm create tauri-app@latest で Vue + TypeScript 構成のプロジェクトを作成した
  • Rust 側に用意された greet コマンドをフロントエンドから呼び出し、メッセージ文字列を画面に表示できるようになった

つまり「Tauri プロジェクトを作って、サンプルは動かせた」というスタートラインには立てた状態です。

今回のテーマ: フロントエンドと Rust の「会話」

実際のアプリ開発では、フロントエンド(Vue / React など)だけで完結するケースは少ないです。

  • ファイルの読み書き
  • OS の機能との連携
  • ネットワークを叩く重い処理
  • セキュリティ上ブラウザ側に置きたくないロジック

こういった「ちょっと裏側で頑張ってほしい処理」を担当するのが Rust であり、その入り口になるのが Tauri の「コマンド」 です。

フロントエンドから見ると、コマンドは「引数を渡すと Promise で結果が返ってくる API エンドポイント」のような存在ですし、Rust から見ると「外の世界(フロントエンド)に公開した関数」というイメージに近いです。

なぜコマンドが大事なのか

このコマンドの扱いに慣れてくると、次のようなことができるようになります。

  • Vue / TypeScript から Rust の関数を 安全に呼び出す
  • 文字列だけでなく、オブジェクトや配列など リッチなデータをやり取りする
  • Rust 側の Result とフロント側の try / catch を組み合わせて エラーをきちんと扱う
  • 時間のかかる処理を 非同期コマンドとしてオフロードし、UI を固めない

言い換えると、「見た目は Web、裏側はネイティブ」というTauri らしい開発スタイルを支えているのが、このコマンド周りの仕組みです。

「コマンド」の仕組みを理解する

ここからは、コードを書く前に「そもそも Tauri のコマンドって何者?」というところを押さえておきます。

コマンドの全体像

公式ドキュメントでは、コマンドについて次のように説明されています。

Tauri は、Web アプリから Rust 関数を呼び出すためのシンプルでありながら強力な「コマンド command」システムを提供しています。
「コマンド」は引数を受け入れ、値を返すことができます。また、エラーを返したり、async(非同期)にしたりもできます。

これをざっくり言い換えると、次の 4 点に集約できます。

  1. シンプルだけど強力な仕組み
    • 「関数に引数を渡して結果を受け取る」という、プログラミングではおなじみのパターンそのものなので直感的です。
    • その一方で、ファイル操作や OS 連携、ネットワークアクセスなど、ネイティブ寄りな処理もまとめてラップできます。
  2. 引数を受け取れる
    • フロントエンドから JSON 形式でデータを渡し、Rust 側では普通の関数引数として受け取れます。
  3. 値を返せる
    • Rust 関数の戻り値が JSON にシリアライズされて、フロントエンド側のinvoke(Promise)の結果として受け取れます。
  4. エラーや非同期にも対応できる
    • Result<T, E> を返すようにしておけば、失敗時にエラーメッセージをフロントエンドへ渡せます。
    • async fn として定義すれば、時間のかかる処理も UI を固めずに裏側で実行できます。

イメージとしては、

  • Web 側から見ると「Promise を返す API 呼び出し」
  • Rust 側から見ると「普通の関数呼び出し」

この流れを簡単なシーケンス図で描くと、こんな感じになります。

ここまでの話を一言でまとめると、

  • フロント側:invoke("名前", 引数) で呼ぶだけ
  • Rust 側:普通の関数に #[tauri::command] を付けて定義するだけ

という、ものすごくシンプルな仕組みになっています。

コマンドの基本形を復習する

次に、公式ドキュメント基本的な例を題材に、コマンドの基本形をおさらいしておきます。
(前回の記事ではgreet関数を題材に解説をしましたが、ここではもっと最小限のコマンドで復習します)

Rust 側:#[tauri::command] を付けた関数を用意する

コマンドは、src-tauri/src/lib.rs にある Rust コードの中で定義します。
関数の上に #[tauri::command] を付けると、その関数が「フロントエンドから呼べるコマンド」として登録されます。

src-tauri/src/lib.rs
#[tauri::command]
fn my_custom_command() {
    println!("I was invoked from JavaScript!");
}

ポイントは次のとおりです。

  • #[tauri::command] が「この関数は Tauri のコマンドです」という印になる。
  • この段階では、まだ「どこからでも呼べるようになった」わけではなく、あくまで「コマンド候補を宣言した」状態です。

Rust 側:invoke_handler に登録する

宣言したコマンドは、run(または main)関数の中で
invoke_handler に渡して登録する必要があります。

src-tauri/src/lib.rs
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![my_custom_command])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

ここでは、tauri::generate_handler![my_custom_command]

  • "my_custom_command" というコマンド名と
  • 実際の my_custom_command 関数

を紐づける「ハンドラー群」を生成し、invoke_handler に渡しています。

これで初めて、フロントエンドから invoke("my_custom_command", …) と呼べる状態になります。

フロントエンド側:invoke で Rust 関数を呼び出す

Vue + TypeScript の場合、公式が提供している@tauri-apps/api/core パッケージの invoke 関数を使って Rust のコマンドを呼び出します。

src/App.vue
import { invoke } from "@tauri-apps/api/core";

async function runMyCommand() {
  await invoke("my_custom_command");
}

先ほど Rust 側で登録したコマンド名(ここでは "my_custom_command")をそのまま文字列で指定するだけです。戻り値がない場合は、await しても特に値は返ってきません(Promise<void> 的な扱いになります)。

Vue コンポーネントでボタンに結びつけると、例えば次のようになります。

src/App.vue
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";

const runMyCommand = async () => {
  await invoke("my_custom_command");
};
</script>

<template>
  <button @click="runMyCommand">
    my_custom_command を実行
  </button>
</template>

ここまでで、

  • Rust 側に「コマンド関数」を用意する
  • run 関数の invoke_handler に登録する
  • フロントから invoke("名前") で呼び出す

という、Tauri のコマンドの基本的な流れがひと通りつながりました。

実践1:データを渡してみよう(引数の扱い)

前のパートでは「コマンドとは何か」を眺めてきましたが、ここからは実際に フロントエンドからデータを渡して Rust 側で受け取る ところまで手を動かしてみます。

何を作るか(ゴール)

ここでは、シンプルな「ユーザー登録フォーム」っぽい機能を例にします。

  • フロントエンド(Vue)側で、名前と年齢を入力する
  • 送信ボタンを押すと、Rust 側のコマンドに { name, age } を渡す
  • Rust 側で受け取ったデータを少し加工して、確認メッセージを返す

という流れです。

前回の記事で作成した空のプロジェクト(tauri-app)にコードを仕込んで動作を確認します。

Rust 側:構造体でデータを受け取る

まずは、Rust 側に「ユーザー情報」を表す構造体と、それを受け取るコマンド関数を用意します。

src-tauri/src/lib.rs を次のように編集します。

src-tauri/src/lib.rs
+ #[derive(serde::Deserialize)]
+ struct UserForm {
+     name: String,
+     age: u32,
+ }

#[tauri::command]
fn greet(name: &str) -> String {
    format!("Hello, {}! You've been greeted from Rust!", name)
}

+ #[tauri::command]
+ fn register_user(user: UserForm) -> String {
+     format!("Registered user: {} (age: {})", user.name, user.age)
+ }

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_opener::init())
-         .invoke_handler(tauri::generate_handler![greet])
+         .invoke_handler(tauri::generate_handler![greet, register_user])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

押さえておきたいポイントは次のとおりです。

  • UserForm 構造体に #[derive(serde::Deserialize)] を付けることで、JSON からこの構造体へ「デシリアライズ」できるようになります。
  • register_user コマンドは、user: UserForm という形でオブジェクトを丸ごと受け取ります。
  • 戻り値は String なので、そのまま JSON の文字列としてフロント側に返されます。
  • invoke_handlerregister_user を追加するのを忘れないようにします。

Vue 側:フォームからオブジェクトを渡す

次に、Vue 側から register_user コマンドを呼び出すコードを見てみます。

src/App.vue<script setup> には、元々の greet に加えて次のような状態と関数を用意します。

src/App.vue
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";

const greetMsg = ref("");
const name = ref("");

+ const userName = ref("");
+ const userAge = ref<number | null>(null);
+ const registerMsg = ref("");

async function greet() {
  greetMsg.value = await invoke("greet", { name: name.value });
}

+ async function register() {
+   registerMsg.value = await invoke<string>("register_user", {
+     user: {
+       name: userName.value,
+       age: userAge.value,
+     },
+   });
}
</script>

テンプレートには、簡単なフォームを追加します。

src/App.vue
<template>
  <!-- 省略: greet 関連の部分 -->

  <section style="margin-top: 2rem">
    <h2>ユーザー登録フォーム(コマンドの引数ハンズオン)</h2>
    <form class="row" @submit.prevent="register">
      <input
        v-model="userName"
        placeholder="Name..."
        style="margin-right: 5px"
      />
      <input
        v-model.number="userAge"
        type="number"
        min="0"
        placeholder="Age..."
        style="margin-right: 5px"
      />
      <button type="submit">Register</button>
    </form>
    <p>{{ registerMsg }}</p>
  </section>
</template>

ここでのポイントは次のとおりです。

  • invoke("register_user", { user: { name, age } }) の形で、Rust 側の user: UserForm に対応するオブジェクトを渡しています。
  • v-model.number を使うことで、userAge が文字列ではなく数値としてバインドされ、Rust 側の age: u32 と型が揃いやすくなります。
  • registerMsg にコマンドの戻り値をそのまま代入し、画面に表示しています。

これだけで、「フォームに入力したデータを Rust に渡し、結果のメッセージを受け取って表示する」という一連の流れが体験できます。

camelCase と snake_case の違い

公式ドキュメントでも触れられているポイントとして、
JavaScript 側と Rust 側で命名スタイルが違う という問題があります。

  • JavaScript / TypeScript: camelCase(例: userName, postalCode
  • Rust: snake_case(例: user_name, postal_code

Tauri は、このギャップをある程度自動で埋めてくれます。
例えば、次のようなコマンド定義があったとします。

#[tauri::command]
fn my_custom_command(invoke_message: String) {
    println!("message: {}", invoke_message);
}

この場合、フロントエンド側では次のように呼び出せます。

invoke("my_custom_command", { invokeMessage: "Hello!" });

invokeMessage(camelCase)のキーで渡しても、Rust 側ではinvoke_message(snake_case)の引数として受け取ってくれる、というイメージです。

より細かく制御したい場合は、

  • コマンド引数に #[tauri::command(rename_all = "snake_case")] を付ける
  • 構造体側で #[serde(rename_all = "camelCase")] を付ける

といった方法もありますが、まずは

  • 「Rust 側は snake_case」
  • 「フロント側は camelCase」

という基本だけ意識しておくと十分です。

型の整合性とエラーになりがちなポイント

引数周りでハマりやすいポイントを軽く整理しておきます。

  • Rust 側で受け取る型は、serde::Deserialize を実装している必要がある
    (今回の UserForm のように #[derive(serde::Deserialize)] を付ける)。
  • フロントエンドから渡す値の型と、Rust 側の型が食い違うとデシリアライズエラーになる(例: age を文字列で送ってしまうなど)。
  • TypeScript の型をうまく書いておくと、「ここは number を渡すべき」といった情報が補完されやすくなる。

今回のサンプルコードでは、v-model.number を使ってuserAge を数値として扱うことで、Rust 側 u32 との整合性を取りやすくしました。

実践2:失敗を許容する(エラーハンドリング)

実際のアプリでは「必ず成功する処理」ばかりではありません。

  • ネットワークが落ちていて API が叩けない
  • ファイルが存在しない/読み込み権限がない
  • ユーザーが 0 で割り算をしようとする

といった「失敗しうる処理」をどう扱うかは、アプリの安定性に直結します。

Tauri のコマンドでは、この「失敗の可能性」を Rust 側のResult<T, E> とフロント側の try / catch で素直に表現できます。
ここでは、イメージしやすい 割り算計算機 を例に、エラーハンドリングを体験してみます。

何を作るか(ゴール)

ここでは、シンプルな「割り算計算機」を作ります。

  • フロントエンド(Vue)側で、2 つの数値 ab を入力する
  • 送信ボタンを押すと、Rust 側の safe_divide コマンドに { a, b } を渡す
  • Rust 側で a / b を計算し、成功時は計算結果を、失敗時はエラーメッセージを返す

という流れです。

Rust 側:Result<T, E> を返すコマンド

まずは、Rust 側に「0 で割ろうとしたらエラーを返す」コマンドを追加します。

src-tauri/src/lib.rs
#[tauri::command]
fn safe_divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err("0 で割ることはできません".to_string())
    } else {
        Ok(a / b)
    }
}

そして、run 関数の invoke_handler にこのコマンドを登録します。

src-tauri/src/lib.rs
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_opener::init())
-         .invoke_handler(tauri::generate_handler![greet, register_user])
+         .invoke_handler(tauri::generate_handler![greet, register_user, safe_divide])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

ここでのポイントは次のとおりです。

  • 戻り値の型が Result<f64, String> になっている
    • Ok(値) の場合 → フロントエンド側の invoke成功 とみなされ、結果が返る
    • Err(文字列) の場合 → invoke失敗 とみなされ、エラーとしてフロントに届く
  • エラー側の型 E として String を使っているので、そのまま JSON の文字列としてシリアライズされます。

公式ドキュメントに書かれている通り、「返すもの(成功値もエラーも)は Rust 側でserde::Serialize を実装している必要がある」点に注意です。

Vue 側:try / catch でエラーを受け取る

次に、Vue 側でこの safe_divide コマンドを呼び出す UI を用意します。

<script setup> に、割り算用の状態と関数を追加します。

src/App.vue
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";

const greetMsg = ref("");
const name = ref("");

const userName = ref("");
const userAge = ref<number | null>(null);
const registerMsg = ref("");

+ const dividend = ref<number | null>(null);
+ const divisor = ref<number | null>(null);
+ const divideResult = ref("");

async function greet() {
  greetMsg.value = await invoke("greet", { name: name.value });
}

async function register() {
  registerMsg.value = await invoke("register_user", {
    user: {
      name: userName.value,
      age: userAge.value,
    },
  });
}

+ async function calculateDivision() {
+   divideResult.value = "";
+   try {
+     const result = await invoke<number>("safe_divide", {
+       a: dividend.value,
+       b: divisor.value,
+     });
+     divideResult.value = `結果: ${result}`;
+   } catch (e) {
+     divideResult.value = `エラー: ${String(e)}`;
+   }
+ }
</script>

テンプレートには、割り算用のフォームを追加します。

src/App.vue
<template>
  <!-- 省略: greet / ユーザー登録フォーム -->

  <section style="margin-top: 2rem">
    <h2>割り算計算機(エラーハンドリング)</h2>
    <form class="row" @submit.prevent="calculateDivision">
      <input
        v-model.number="dividend"
        type="number"
        step="any"
        placeholder="被除数 a"
        style="margin-right: 5px"
      />
      <input
        v-model.number="divisor"
        type="number"
        step="any"
        placeholder="除数 b"
        style="margin-right: 5px"
      />
      <button type="submit">Divide</button>
    </form>
    <p>{{ divideResult }}</p>
  </section>
</template>

ここでのポイントは次のとおりです。

  • await invoke<number>("safe_divide", { a, b }) の周りを try / catch で囲むことで、Rust 側の Err(...) を JS 側の例外として受け取っています。
  • 0 で割ろうとすると Rust 側で Err("0 で割ることはできません") が返り、フロント側の catch (e) に入り、divideResult にエラーメッセージを表示します。
  • v-model.number によって、a / b が数値として Rust に渡るので、型のミスマッチによるデシリアライズエラーも起きにくくなります。

エラー型をどう設計するか

公式ドキュメントでは、もう一歩進んだ話として「標準ライブラリや外部クレートのエラー型は、そのままだと serde::Serialize を実装していないことが多い」という点にも触れています。

シンプルに済ませたい場合は、今回のように

  • map_err(|e| e.to_string())String に変換する
  • もしくは、最初から Result<T, String> を返す

という方針で十分です。

より本格的にやるなら、thiserror などを使って「シリアライズ可能な独自エラー型」を定義し、その型を Result<T, E>E として返す、という設計になります。

この記事ではそこまで踏み込みませんが、「フロントから見えるエラーは結局 JSON になるので、Rust 側のエラー型も JSON にできる形にしておく必要がある」という方向性だけ押さえておくと、後々応用が効きます。

実践3:UI を固まらせない(非同期コマンド)

次に、「重い処理を実行しても UI を固まらせない」ためのパターンとして、 非同期コマンド を試してみます。

何を作るか(ゴール)

ここでは、「指定した秒数だけ時間のかかる処理」を非同期コマンドとして実行し、UI を固めずに進捗メッセージを表示する例を試します。

  • フロントエンド(Vue)側で、待機したい秒数を入力する
  • 実行ボタンを押すと、Rust 側の long_task コマンドに秒数を渡す
  • コマンド実行中は「実行中...」といったメッセージを表示しつつ、ウィンドウ操作や他のボタン操作は継続できる
  • 処理が完了したら、完了メッセージを画面に表示する

という流れです。

なぜ UI が固まるのか

通常の(非 async)コマンドは、Tauri のメインスレッド上で実行されます。
そのため、例えば次のようなコードを書いてしまうと、処理が終わるまでウィンドウ全体がフリーズしたような状態になります。

#[tauri::command]
fn long_task_blocking() {
    // 5 秒間ブロッキング
    std::thread::sleep(std::time::Duration::from_secs(5));
}

UI スレッドで std::thread::sleep を実行してしまうと、その間はユーザーからの入力や再描画が止まってしまう、というのが「UI が固まる」正体です。

async fn コマンドで重い処理をオフロードする

Tauri 2 では、コマンドを async fn として定義するだけで、Tauri が内部の非同期ランタイム上に処理を乗せてくれます。

今回は、次のような「疑似的な重い処理」を用意しました。

src-tauri/src/lib.rs
#[tauri::command]
async fn long_task(seconds: u64) -> String {
    let duration = std::time::Duration::from_secs(seconds);
    // 疑似的に「重い処理」としてスリープする
    std::thread::sleep(duration);
    format!("{seconds} 秒かかる重い処理が完了しました!")
}

そして、run 関数でコマンド一覧に追加します。

src-tauri/src/lib.rs
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_opener::init())
        .invoke_handler(tauri::generate_handler![
            greet,
            register_user,
            safe_divide,
+           long_task
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

ここではあえて std::thread::sleep を使っていますが、ポイントは「コマンド自体が async fn になっている」ことです。
Tauri がこの関数をメインスレッドとは別の非同期タスクとして扱ってくれるため、処理中も UI は反応し続けます。

補足

本来 async fn 内では tokio::time::sleep を使うのが定石ですが、今回は外部クレートを追加せず標準ライブラリだけで動作を試すため、便宜的に std::thread::sleep を使用しています。

実際のアプリでは、ここにネットワーク I/O や大きめのファイル処理など、時間のかかる処理を詰め込むイメージです。

Vue 側:非同期コマンドを待ちながら UI を更新する

Vue 側からは、これまでと同じく invokeawait するだけです。
「同期コマンド」と「非同期コマンド」で呼び出し方は変わりません。

<script setup> では、次のような状態と関数を追加しています。

src/App.vue
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";

// 既存の状態は省略

const longTaskSeconds = ref(3);
const longTaskStatus = ref("");

async function runLongTask() {
  longTaskStatus.value = "重い処理を実行中...";
  try {
    const message = await invoke<string>("long_task", {
      seconds: longTaskSeconds.value,
    });
    longTaskStatus.value = message;
  } catch (e) {
    longTaskStatus.value = `エラー: ${String(e)}`;
  }
}
</script>

テンプレートには、次のようなセクションを追加しています。

src/App.vue
<template>
  <!-- 省略: greet / ユーザー登録 / 割り算計算機 -->

  <section style="margin-top: 2rem">
    <h2>重い処理を非同期コマンドで実行</h2>
    <form class="row" @submit.prevent="runLongTask">
      <input
        v-model.number="longTaskSeconds"
        type="number"
        min="1"
        placeholder="待機秒数"
        style="margin-right: 5px"
      />
      <button type="submit">重い処理を開始</button>
    </form>
    <p>{{ longTaskStatus }}</p>
  </section>
</template>

実際にこの UI で 3 秒や 5 秒の処理を走らせてみると、ボタンを押したあともウィンドウのドラッグや他のボタン操作が固まらないことが分かると思います(同じことを同期コマンドでやると、ウィンドウ全体がカチっと固まってしまうはずです)。

非同期コマンドを使うときの考え方

最後に、非同期コマンドを使うときのざっくりした指針をまとめておきます。

  • 時間のかかる処理は基本 async
    • ネットワーク、ディスク I/O、重めの計算などは、async fn コマンドに寄せる。
  • フロント側は「いつもどおり await invoke」
    • 同期コマンドか非同期コマンドかを意識する必要はほとんどない。
  • UI の「フィードバック」を忘れない
    • 今回の longTaskStatus のように、「処理中です」「完了しました」を文字やインジケータでこまめに出すと体験が良くなる。
  • 本当に重い CPU 処理は専用スレッドも検討
    • 今回は説明のためにスリープで済ませましたが、実アプリではワーカースレッドやキューイングなども視野に入ります。

実践4:状態を記憶する(Managed State)

ここまでは「1 回のコマンド呼び出しの中で完結する処理」を扱ってきましたが、実際のアプリでは 「前回からの続き(状態)」 を扱いたい場面も多く出てきます。

  • アプリ起動中のアクセス回数を数えたい
  • ユーザー設定や一時的なキャッシュをサーバー側ではなくローカルで持ちたい
  • 複数のコマンドから同じ状態(データ)にアクセスしたい

といったケースでは、 毎回まっさらな関数 だけでは少し心許なくなってきます。

Tauri では、こうした「アプリ全体で共有する状態」を扱うためにManaged State という仕組みが用意されています。
ここでは、シンプルな「アクセスカウンター」を題材に、状態を Rust 側に持たせるパターンを見てみます。

何を作るか(ゴール)

ここでは、アプリ起動中に共通で使われる「アクセスカウンター」を Managed State で実装します。

  • Rust 側にカウンタ値を持つ Counter 構造体を用意し、Managed State としてアプリ全体に登録する
  • フロントエンドのボタンを押すたびに、Rust 側の increment_counter コマンドを呼んでカウンタを +1 する
  • コマンドから返ってきた現在のカウント値を、そのまま画面に表示する

という流れです。

課題:コマンドは毎回「使い捨て」

まず、素朴に次のようなコマンドを考えてみます。

#[tauri::command]
fn increment_naive(mut count: u64) -> u64 {
    count += 1;
    count
}

これは「渡された数値を +1 して返す」だけなので、フロント側が自分で状態を持っていれば問題ありませんが、「Rust 側でカウントを覚えておく」ことはできません。

  • コマンドは呼ばれるたびに新しく実行される
  • 関数のローカル変数は、呼び出しが終わると消えてしまう

という性質があるからです。

解決策:Managed State で状態を保持する

そこで登場するのが tauri::State.manage(...) を使った Managed State です。

src-tauri/src/lib.rs
+ struct Counter {
+     value: std::sync::Mutex<u64>,
+ }

+ #[tauri::command]
+ fn increment_counter(state: tauri::State<Counter>) -> u64 {
+     let mut guard = state.value.lock().expect("counter mutex poisoned");
+     *guard += 1;
+     *guard
+ }

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_opener::init())
+         .manage(Counter {
+             value: std::sync::Mutex::new(0),
+         })
        .invoke_handler(tauri::generate_handler![
            greet,
            register_user,
            safe_divide,
            long_task,
+           increment_counter
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

ポイントは次のとおりです。

  • Counter 構造体が「共有したい状態」を表している
    • ここではシンプルに u64 のカウンタを Mutex で包んでいます。
  • run 内で .manage(Counter { ... }) を呼ぶことで、アプリケーション全体に Counter のインスタンスを登録しています。
  • increment_counter コマンドでは、引数に state: tauri::State<Counter> を受け取ることで、登録済みの Counter にアクセスしています。
  • 複数スレッドから同時に触られても安全なように、Mutex で排他制御しています。

一度 .manage(...) した状態は、アプリケーションが動いている間は同じインスタンスが使い回されるため、コマンド呼び出しをまたいで値を保持できます。

Vue 側:ボタンを押すたびにカウンターを増やす

Vue 側では、Tauri の Managed State の存在を特に意識する必要はありません。
いつも通り invoke でコマンドを叩き、その戻り値を表示するだけです。

<script setup> では次のように書いています。

src/App.vue
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";

// 既存の状態は省略

const counterValue = ref<number | null>(null);

async function incrementCounter() {
  const value = await invoke<number>("increment_counter");
  counterValue.value = value;
}
</script>

テンプレート側には、シンプルなボタンと表示欄を追加しています。

src/App.vue
<template>
  <!-- 省略: greet / ユーザー登録 / 割り算 / 非同期処理 -->

  <section style="margin-top: 2rem">
    <h2>アクセスカウンター(Managed State)</h2>
    <button class="row" @click="incrementCounter">
      カウンターを増やす
    </button>
    <p>現在のカウント: {{ counterValue ?? 0 }}</p>
  </section>
</template>

これだけで、ボタンを押すたびに Rust 側の increment_counter が呼ばれ、Managed State 上のカウンタ値が 1 ずつ増えていきます。

アプリを終了して再起動するとカウンタはリセットされますが、アプリが動いている間であれば、複数のコマンドや画面から同じ状態を共有できます。

Managed State を使うときのコツ

最後に、Managed State を使うときの簡単なコツをまとめておきます。

  • *「アプリ全体で共有したいもの」に絞る
    • なんでもかんでも State に入れると、依存関係が複雑になりがちです。
    • 設定、キャッシュ、接続プール、カウンタなど、「どこからでも同じものを参照したい」ものに限定すると整理しやすくなります。
  • 並行アクセスを意識しておく
    • MutexRwLock などで守る必要があるかどうかを検討します。
    • 読み取り専用の設定ならロックなしの共有も選択肢です。
  • フロントエンドからは「ただのコマンド」として扱う
    • Managed State の存在は Rust 側だけの事情なので、フロント側のコードはこれまで通り await invoke("名前") に集中できます。

まとめ

ここまでで、Tauri のコマンドについて

  • 引数・戻り値(構造体の受け渡し)
  • Resulttry / catch によるエラーハンドリング
  • async コマンドによる UI 非ブロッキング化
  • Managed State による状態の共有

といった一連の流れをひと通り体験できました。
このあたりを押さえておけば、Tauri アプリで
「見た目は Web、中身はネイティブ」な処理を組み立てるための
土台はかなり整ってきたはずです。

次回は「Tauriのイベント」についてまとめてみたいと思います。


出典・参考リンク

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?