こんにちは!
前回、前々回と Tauri 2.0 の入門記事を書いてきました。
前回は、フロントエンドから Rust の関数を呼び出す 「コマンド (Command)」 について解説しました。
コマンドは「何かをお願いして(リクエスト)、結果をもらう(レスポンス)」という 1対1の会話 のような通信スタイルでした。
しかし、アプリを作っていると、こんな場面に出くわすことがあります。
- Rust 側で行われている重い処理の 進捗状況(0%... 50%... 100%) をリアルタイムに画面に出したい
- ユーザーが特定の操作をしたときに、他のウィンドウ にも知らせたい
- 特に返事は要らないけれど、一方的に データを送りつけたい
こういうときに便利なのが、今回紹介する 「イベント・システム (Event System)」 です。
今回は、Tauri 2.0 のイベント・システムを使って、以下の3パターンの通信を実験してみます。
-
バックエンド (Rust) → フロントエンド (Vue)
- 「Rust からのお知らせ」を受け取る
-
フロントエンド (Vue) → バックエンド (Rust)
- 「フロントエンドの出来事」を Rust に通知する
-
フロントエンド (Vue) → フロントエンド (Vue)
- 「ウィンドウ間」でメッセージをやり取りする
それでは、実験ノートを開くような気持ちでやっていきましょう!
イベント・システムの基本
Tauri 2.0 のイベントは、「発信者 (Emit)」 と 「受信者 (Listen)」 の関係で成り立っています。
- emit (イベント名, データ): 「このイベントが起きたよ!」と叫ぶ
- listen (イベント名, コールバック関数): 「このイベントが聞こえたら教えて」と待ち構える
コマンドと違うのは、「誰が聞いているか気にしない(投げっぱなし)」 という点です。
1人が叫べば、聞いている全員(Rust も フロントエンド も)がそのメッセージを受け取ることができます。
準備:権限の設定(Capabilities)
Tauri 2.0 ではセキュリティが強化されており、フロントエンドがイベントを利用するには Capabilities(権限設定) が必要になる場合があります。
デフォルトで作成される src-tauri/capabilities/default.json を確認し、core:event:default が含まれていることを確認しておきましょう(通常は初期設定で含まれています)。
{
"permissions": [
"core:default",
"core:event:default",
...
]
}
これが入っていれば、フロントエンドから emit や listen を行う準備はOKです。
1. バックエンド (Rust) → フロントエンド (Vue)
まずは一番よく使うパターン、「Rust 側の処理状況をフロントエンドに通知する」をやってみます。
例として、「5秒カウントダウンするタイマー」 を作ってみましょう。
Rust 側:イベントを発信する (Emit)
Rust 側でイベントを発信するには、tauri::Emitter トレイトを使います。
src-tauri/src/lib.rs を編集します。
use tauri::Emitter; // Emitter トレイトを忘れずに
#[tauri::command]
async fn start_timer(app_handle: tauri::AppHandle) {
// 5秒間カウントダウン
for i in (1..=5).rev() {
// "timer-tick" というイベント名で、現在の秒数 i を発信
app_handle.emit("timer-tick", i).unwrap();
// 1秒待つ
std::thread::sleep(std::time::Duration::from_secs(1));
}
// 最後に完了イベントを発信
app_handle.emit("timer-done", "Time's up!").unwrap();
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
// コマンドを登録
.invoke_handler(tauri::generate_handler![start_timer])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
ポイント:
-
app_handle.emit("イベント名", ペイロード)でイベントを発信できます - ペイロード(送るデータ)は、
serde::Serializeを実装している型なら何でもOKです(数値、文字列、構造体など)
このサンプルでは説明を簡潔にするため、async fn 内で std::thread::sleep を使っています。
本来、非同期処理内で待機する場合は tokio::time::sleep(...).await を使うのが推奨です。
本章のコードは、イベントの流れを理解するための最小例として掲載しています。
Vue 側:イベントをリッスンする (Listen)
次に、フロントエンドでこのイベントを受け取ります。
@tauri-apps/api/event パッケージの listen 関数を使います。
src/App.vue に追記します。
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; // listen をインポート
import type { UnlistenFn } from "@tauri-apps/api/event";
const timerValue = ref("");
let unlistenTick: UnlistenFn | null = null;
let unlistenDone: UnlistenFn | null = null;
async function startTimer() {
// Rust の start_timer コマンドを呼ぶ
timerValue.value = "Starting...";
await invoke("start_timer");
}
onMounted(async () => {
// "timer-tick" イベントをリッスン
unlistenTick = await listen<number>("timer-tick", (event) => {
timerValue.value = `残り: ${event.payload} 秒`;
});
// "timer-done" イベントをリッスン
unlistenDone = await listen<string>("timer-done", (event) => {
timerValue.value = event.payload;
});
});
onUnmounted(() => {
// コンポーネント破棄時にリッスンを解除(メモリリーク防止)
if (unlistenTick) {
unlistenTick();
}
if (unlistenDone) {
unlistenDone();
}
});
</script>
<template>
<main class="container">
<h2>Rust -> Frontend 通信</h2>
<button type="button" @click="startTimer">タイマー開始</button>
<p>{{ timerValue }}</p>
</main>
</template>
<style scoped>
.container {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin: 4rem auto;
text-align: center;
}
</style>
これで「タイマー開始」ボタンを押すと、Rust 側でループが回り、1秒ごとに画面の表示が「残り: 5 秒」→「残り: 4 秒」...と更新されます。
コマンドの戻り値(return)を待つのとは違い、処理の途中で何度もデータを受け取れる のがイベントの強みです。
2. フロントエンド (Vue) → バックエンド (Rust)
次は逆方向です。フロントエンドで起きたイベントを Rust 側で検知します。
通常は invoke(コマンド)を使えば事足りますが、
「返事を待つ必要がない」「とにかくログとして投げつけておきたい」といった場合に有効です。
例として、「ボタンを押したらバックエンドにログを投げつけるアプリ」 を作ってみましょう。
Vue 側:イベントを発信する (Emit)
emit 関数を使います。
<script setup lang="ts">
import { ref } from "vue";
import { emit } from "@tauri-apps/api/event";
const sendResult = ref("");
async function sendLog() {
// "frontend-log" というイベント名で、オブジェクトを送信
await emit("frontend-log", {
level: "info",
message: "ボタンが押されました",
});
sendResult.value = "frontend-log を送信しました";
}
</script>
<template>
<main class="container">
<h1>Frontend -> Rust イベント通信</h1>
<button @click="sendLog">Rustへログ送信</button>
<p>{{ sendResult }}</p>
</main>
</template>
<style scoped>
.container {
margin: 0;
min-height: 100vh;
display: grid;
place-content: center;
gap: 16px;
text-align: center;
}
button {
border-radius: 8px;
border: 1px solid #c7c7c7;
padding: 0.6em 1.2em;
font-size: 1em;
cursor: pointer;
background: #fff;
}
p {
min-height: 1.5em;
}
</style>
Rust 側:イベントをリッスンする (Listen)
Rust 側でイベントを待ち受けるには、setup フックの中で app.listen を使います。
src-tauri/src/lib.rs の run 関数を修正します。
use tauri::Listener;
// ペイロードを受け取るための構造体
#[derive(Debug, Clone, serde::Deserialize)]
struct LogPayload {
level: String,
message: String,
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.setup(|app| {
// "frontend-log" イベントをリッスン
// Listener トレイトが必要
app.listen("frontend-log", |event| {
// ペイロードを取り出す
if let Ok(payload) = serde_json::from_str::<LogPayload>(event.payload()) {
println!("Rust received event: [{}] {}", payload.level, payload.message);
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
これで、フロントエンドのボタンを押すたびに、Rust 側のターミナルにログが表示されるようになります。
3. フロントエンド (Vue) → フロントエンド (Vue / 別ウィンドウ)
最後は、フロントエンド同士の通信です。
Tauri 2.0 では、あるウィンドウから発したイベントを、別のウィンドウ で受け取ることができます。
今回は、よくある 「設定画面でテーマを変更したら、メイン画面の色が即座に変わる」 という機能を実装してみます。
- メイン画面: 「設定を開く」ボタンがある。背景色が変わる
- 設定画面: 新しいウィンドウとして開く。背景色を選択して送信する
という流れで作ってみましょう。
Step 1: 設定ウィンドウを開く (Main Window)
まず、メイン画面 (App.vue) に「設定画面を開くボタン」と「イベントの受信処理」を追加します。
新しいウィンドウを開くには、@tauri-apps/api/webviewWindow の WebviewWindow クラスを使います。
<script setup lang="ts">
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { onMounted, onUnmounted, ref } from "vue";
const backgroundColor = ref("#ffffff");
const statusMessage = ref("設定ウィンドウで背景色を選択してください。");
let unlistenThemeChange: UnlistenFn | null = null;
async function openSettings() {
// 既に設定ウィンドウが存在する場合は新規作成せず、前面に出す
const existingWindow = await WebviewWindow.getByLabel("settings-window");
if (existingWindow) {
await existingWindow.setFocus();
return;
}
// 別ウィンドウとして設定画面を開く
const settingsWindow = new WebviewWindow("settings-window", {
url: "/settings.html",
title: "設定",
width: 420,
height: 280,
resizable: false,
});
settingsWindow.once("tauri://created", () => {
statusMessage.value = "設定ウィンドウを開きました。";
});
settingsWindow.once("tauri://error", (event) => {
console.error("設定ウィンドウの作成に失敗しました:", event);
statusMessage.value = "設定ウィンドウの作成に失敗しました。";
});
}
onMounted(async () => {
// 設定ウィンドウから送信された "theme-change" を受信して背景色を更新する
unlistenThemeChange = await listen<string>("theme-change", (event) => {
backgroundColor.value = event.payload;
statusMessage.value = `背景色を ${event.payload} に変更しました。`;
});
});
onUnmounted(() => {
// コンポーネント破棄時にリスナーを解除してメモリリークを防ぐ
if (unlistenThemeChange) {
unlistenThemeChange();
unlistenThemeChange = null;
}
});
</script>
<template>
<main class="main" :style="{ backgroundColor }">
<h1>メイン画面</h1>
<p>現在の背景色: {{ backgroundColor }}</p>
<button type="button" @click="openSettings">設定画面を開く</button>
<p class="status">{{ statusMessage }}</p>
</main>
</template>
<style scoped>
.main {
box-sizing: border-box;
width: 100%;
min-height: 100vh;
padding: 24px;
color: #1f2937;
}
button {
border: 1px solid #4b5563;
border-radius: 8px;
padding: 10px 14px;
background: #ffffff;
cursor: pointer;
}
.status {
margin-top: 16px;
}
</style>
これで、メイン画面は「theme-change イベントが来たら背景色を変える」という準備が整いました。
Step 2: 設定ウィンドウの中身を作る (Settings Window)
次に、設定画面の中身を作ります。
今回は 別ウィンドウ用の独立したHTML として settings.html をプロジェクト直下に作成します。
settings.html の中身は、以下のようにシンプルなボタンUIだけにします。
今回は分かりやすさ優先で、イベント送信ロジックも settings.html に直書き します。
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>設定</title>
<style>
body {
margin: 0;
padding: 20px;
font-family: sans-serif;
text-align: center;
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
button {
border: 1px solid #9ca3af;
border-radius: 8px;
padding: 10px;
cursor: pointer;
}
</style>
</head>
<body>
<h3>背景色を選んでください</h3>
<div class="actions">
<button type="button" data-color="#ffcccc">赤系</button>
<button type="button" data-color="#ccffcc">緑系</button>
<button type="button" data-color="#ccccff">青系</button>
<button type="button" data-color="#ffffff">リセット</button>
</div>
<p id="result">未選択</p>
<script type="module">
import { emit } from "@tauri-apps/api/event";
function setupColorButtons() {
const result = document.querySelector("#result");
const buttons = document.querySelectorAll("button[data-color]");
for (const button of buttons) {
button.addEventListener("click", async () => {
const color = button.dataset.color;
if (!color) {
return;
}
await emit("theme-change", color);
if (result) {
result.textContent = `${color} を送信しました`;
}
});
}
}
setupColorButtons();
</script>
</body>
</html>
vite.config.ts で settings.html をビルド対象に追加
Vite のデフォルトは index.html 単一ページなので、settings.html を配布物に含めるためにマルチページ設定を追加します。
import { fileURLToPath, URL } from "node:url";
export default defineConfig(async () => ({
// ... (既存の記述はそのまま。以下を追記)
build: {
rollupOptions: {
input: {
main: fileURLToPath(new URL("./index.html", import.meta.url)),
settings: fileURLToPath(new URL("./settings.html", import.meta.url)),
},
},
},
}));
src-tauri/capabilities/settings-window.json を追加
Tauri 2.0 では、main 以外の新規ウィンドウには IPC 権限が自動では付きません。
そのため、settings-window ラベルのウィンドウが emit できるように Capability を追加します。
App.vue で WebviewWindow("settings-window", ...) と記述し、ウィンドウラベルを "settings-window" に設定しています。
したがって、この設定ファイルでも windows 配列に "settings-window" を記述し、紐付ける必要があります。
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "settings-window",
"description": "Capability for the settings window",
"windows": ["settings-window"],
"permissions": ["core:event:allow-emit"]
}
このように、emit はウィンドウの壁を越えてイベントを届けてくれるため、状態管理ライブラリ(Pinia など)を使わずとも、サクッとウィンドウ間の連携が実現できます。
4. イベントのスコープと解除
ここまで紹介してきたイベントは、基本的に 「グローバルイベント」 と呼ばれるものです。
Tauri 2.0 には、より細かい制御を行うための仕組みも用意されています。
グローバルイベント (Global Events)
これまでに使った emit は、アプリ内の すべてのウィンドウ、そして Rust バックエンド 全てに届く「ブロードキャスト」です。
- 特徴: 誰でも発信でき、誰でも受信できる
- 使い所: アプリ全体の状態同期、システム通知など
WebViewイベント (WebView Events)
一方で、「特定のウィンドウだけにメッセージを送りたい」というケースもあります。
例えば、マルチウィンドウのアプリで「メイン画面」だけに通知を送りたい場合です。
Rust 側からは emit_to を使うことで、ターゲット(ウィンドウのラベル)を指定してイベントを送ることができます。
// "main" というラベルが付いたウィンドウだけに送信
app_handle.emit_to("main", "secret-msg", "これはメイン画面だけの秘密です").unwrap();
これにより、無関係なウィンドウに不要なイベントが飛ぶのを防ぐことができます。
Unlisten (リッスン解除)
第1章のコード例で unlistenTick という変数が出てきましたが、これは非常に重要です。
Tauri 2.0 のイベントリスナーは、明示的に解除しない限り アプリが終了するまで生き続けます。
Vue や React のような SPA (Single Page Application) では、画面遷移してコンポーネントが消えても、リスナーだけがメモリに残ってしまうことがあります。
これを放置すると、画面を行き来するたびにリスナーが増殖し、イベントが1回飛んできただけでログが10回、100回と表示される……なんてことになりかねません。
listen 関数は、Promise の結果として 「解除関数 (UnlistenFn)」 を返します。
コンポーネントのライフサイクル (onUnmounted や useEffect のクリーンアップ) に合わせて、必ずこの関数を呼ぶようにしましょう。
// リッスン開始
const unlisten = await listen<string>("my-event", (event) => {
console.log(event);
});
// ...
// リッスン解除(もう聞かなくてよくなったら呼ぶ)
unlisten();
まとめ
今回は Tauri 2.0 の イベント・システム について解説しました。
- コマンド (invoke): 1対1 の リクエスト/レスポンス。「結果が欲しい」とき
- イベント (emit/listen): 1対多 の ブロードキャスト。「知らせたい」とき
この2つを使い分けることで、
「重い処理はコマンドで裏に投げつつ、進捗はイベントで受け取る」
「設定変更をイベントで全ウィンドウに通知する」
といった、ネイティブアプリらしいリッチな挙動が実現できます。
次回は、フロントエンドとバックエンドの通信については最後となる、「Tauri 2.0 のチャネル」についてまとめてみたいと思います。
参考リンク


