Rust からのイベントを受信する (listen)

listen / once で Rust の emit / emit_to から届くイベントを受け取る。ペイロードの型付け、unlisten による解除、Rust 側での送り先の指定とウィンドウごとの届き方も示す。

フロントエンド 対象: Tauri 2.x 更新日: 読了目安: 約7分 front-005
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 画面を閉じるときに解除する(React / Vue でも同じ)
  4. 2. バックエンドから実装する (Rust)
  5. 送り先を選ぶ (emit_to / emit_filter)
  6. 動作確認
  7. よくあるエラーと対処法
  8. イベントがまったく届かない
  9. event.listen not allowed. Permissions associated with this command: ... という趣旨のエラー
  10. ペイロードが undefined / 期待した形でない
  11. OS ごとの違いと注意点
  12. 関連レシピ

ダウンロードの進捗やバックグラウンド処理の完了のように 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 named emit found という趣旨のコンパイルエラーになります。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 を検討してください。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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