ダウンロードの進捗やバックグラウンド処理の完了のように Rust 側の好きなタイミング で JS に知らせたい場面ではイベントを使います。Rust 側は tauri::Emitter トレイトの emit / emit_to で送り、フロントエンドは @tauri-apps/api/event の listen(継続受信)または once(1 回だけ受信)で受け取ります。Rust 側の送り方と送り先の選び方は 2 章にまとめています。
前提条件
追加プラグインは不要です。イベントの権限 core:event:default はテンプレートの src-tauri/capabilities/default.json にある core:default に含まれるため、通常は編集不要です。
1. フロントエンドから実装する (TypeScript)
listen<T>(event, handler, options?) は Promise<UnlistenFn> を返します。コールバックに渡る event は { event: string, id: number, payload: T } の形で、payload がそのまま Rust 側の値です。
// src/main.ts
import { listen, once, type UnlistenFn } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
interface Progress {
percent: number;
message: string;
}
let unlistenProgress: UnlistenFn | null = null;
async function startDownload() {
// 1. 先にリスナーを登録する(emit より後に登録すると取りこぼす)
unlistenProgress = await listen<Progress>('download-progress', (event) => {
const { percent, message } = event.payload;
console.log(`${percent}% ${message}`);
});
// 2. 完了は 1 回だけ受ければよいので once(呼ばれた後に自動で解除される)
await once<string>('download-finished', (event) => {
console.log('完了:', event.payload);
unlistenProgress?.(); // progress 側は手動で解除
unlistenProgress = null;
});
// 3. Rust 側の処理を開始
await invoke('start_download');
}
document.querySelector('#start')?.addEventListener('click', startDownload);
画面を閉じるときに解除する(React / Vue でも同じ)
画面の部品ごとに listen するなら、閉じるときに解除します。listen は非同期なので、登録が終わる前に閉じられることがあります(React 18 の StrictMode ではマウント→アンマウント→マウントが即座に起きるため顕著)。Promise を保持し、解除は then 経由で行います。
import { listen } from '@tauri-apps/api/event';
interface Progress {
percent: number;
message: string;
}
// 画面を開くときに呼び、閉じるときに戻り値の関数を呼ぶ
export function mountProgress(el: HTMLElement): () => void {
const unlistenPromise = listen<Progress>('download-progress', (e) => {
el.textContent = `${e.payload.percent}%`;
});
return () => {
// 登録完了を待ってから解除する。これをしないとリスナーが二重登録される
void unlistenPromise.then((unlisten) => unlisten());
};
}
React では useEffect のクリーンアップで、Vue 3 では onUnmounted で、この戻り値の関数を呼びます。
2. バックエンドから実装する (Rust)
use tauri::Emitter; が必須です。emit は全ウィンドウに、emit_to は指定したラベルのウィンドウ宛てに送ります(受け手によって届き方が変わる点は後述)。ペイロードは Serialize + Clone を実装した型なら何でも渡せます。
// src-tauri/src/lib.rs
use serde::Serialize;
use tauri::{AppHandle, Emitter};
#[derive(Clone, Serialize)]
struct Progress {
percent: u32,
message: String,
}
#[tauri::command]
async fn start_download(app: AppHandle) -> Result<(), String> {
std::thread::spawn(move || {
for i in 1..=10 {
std::thread::sleep(std::time::Duration::from_millis(300));
let payload = Progress { percent: i * 10, message: format!("chunk {}", i) };
if let Err(e) = app.emit("download-progress", payload) {
eprintln!("emit failed: {e}");
}
}
// main ウィンドウ宛てに送る
let _ = app.emit_to("main", "download-finished", "sample.zip");
});
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![start_download])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
送り先を選ぶ (emit_to / emit_filter)
Emitter は App・AppHandle・Window・Webview・WebviewWindow に実装されていますが、どれから呼んでも emit は全体への送信 です。コマンド引数で受け取った window.emit(...) も、呼び出し元のウィンドウだけには限定されません。宛先を絞るには emit_to(ラベルか EventTarget で指定)か emit_filter(条件で選ぶ)を使います。ペイロードが不要なら () を渡し、JS では null になります。
use tauri::{AppHandle, Emitter};
use tauri::{EventTarget, WebviewWindow};
// 呼び出し元のウィンドウ宛てに返す(generate_handler! への登録も忘れずに)
#[tauri::command]
fn notify_caller(window: WebviewWindow) -> Result<(), String> {
let target = EventTarget::webview_window(window.label());
window.emit_to(target, "job-done", ()).map_err(|e| e.to_string())
}
// ラベルが editor- で始まるウィンドウだけに送る
fn sync_editors(app: &AppHandle, text: &str) -> tauri::Result<()> {
app.emit_filter("editor:sync", text, |target| match target {
EventTarget::WebviewWindow { label } => label.starts_with("editor-"),
_ => false,
})
}
宛先を絞っても、届くかどうかは受け手の書き方で変わります。
| Rust 側 | JS の listen() | JS の getCurrentWebviewWindow().listen() |
|---|---|---|
emit | 全ウィンドウに届く | 全ウィンドウに届く |
emit_to("main", ...) | ラベルに関係なく届く | ラベル main のウィンドウだけ |
emit_filter | 条件に関係なく届く | 条件に合うウィンドウだけ |
emit 系は Result を返します。イベント名に英数字と - / : _ 以外の文字があると「only alphanumeric, '-', '/', ':', '_' permitted for event names」で始まるエラーになるので、? や if let Err で拾います。
動作確認
npm run tauri dev で起動して開始ボタンを押すと、コンソールに 300 ms ごとに次のように出力されます。
10% chunk 1
20% chunk 2
...
100% chunk 10
完了: sample.zip
行が 2 本ずつ出るならリスナーが二重登録されています。unlisten の呼び忘れか StrictMode でのクリーンアップ漏れを疑ってください。
よくあるエラーと対処法
イベントがまったく届かない
- 登録前に送られた:
listenのawaitが終わる前にemitされたイベントは捨てられます。setupフックで起動直後に送る場合に起きやすいので、フロントから「準備完了」をinvokeで知らせてから送る構成にします。 - イベント名の typo: 文字列なのでコンパイラは検出しません。定数にして 1 か所で管理します。
use tauri::Emitter;がない:no method namedemitfoundという趣旨のコンパイルエラーになります。v2 ではemitがトレイトメソッドなのでuseが必須です。
event.listen not allowed. Permissions associated with this command: ... という趣旨のエラー
capabilities/default.json から core:default を外している場合に出ます。core:event:default(または core:event:allow-listen と core:event:allow-unlisten)を追加します。
ペイロードが undefined / 期待した形でない
Rust の構造体フィールドは snake_case のまま JSON になります。TypeScript の interface を合わせるか、構造体に #[serde(rename_all = "camelCase")] を付けます。
OS ごとの違いと注意点
イベントの配送に OS 差はありません。注意すべきは設計面です。
- イベント名に使える文字: 英数字と
-/:_のみです。tauri://で始まる名前は組み込みイベント用に予約されています。 listenは宛先を問わず受け取る: 既定の対象が{ kind: 'Any' }のため、emit_toで他ウィンドウ宛てに送ったイベントも受信します。特定ウィンドウ宛てだけを受けたいときはgetCurrentWebviewWindow().listen(...)(@tauri-apps/api/webviewWindow)を使います。- 高頻度の
emit: 1 秒に数百回のemitは WebView が追いつかなくなります。Rust 側で間引くか、連続データはtauri::ipc::Channelを検討してください。
