mcpField
T
tauri-event-system

tauri-event-system

github.com/bobmatnyc開発78203 回インストール15 週間前に更新
リポジトリ

tauri-event-system について

TauriアプリでRustバックエンドとTypeScriptフロントエンドをつなぐイベント通信のガイドです。型付きペイロード、ストリーミング、ウィンドウ間通信に加え、リスナー管理と性能面の実践方法を扱います。

主な機能

  1. 01バックエンドからフロントエンドへのイベント送信
  2. 02型付き・構造化ペイロード
  3. 03リアルタイム・バッファードストリーム
  4. 04全体配信と特定ウィンドウへの送信
  5. 05フロントエンドからバックエンドへのイベント
  6. 06リスナーの解除とイベント頻度制御

活用シーン

  1. 01ダウンロードの進捗をフロントエンドへ通知
  2. 02センサー値やログ行のストリーミング
  3. 03アプリ内のウィンドウ間で更新を中継

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/bobmatnyc/claude-mpm-skills --skill tauri-event-system

インストール後、Claude Code で/tauri-event-systemを入力して読み込まれたか確認します。

同梱ファイルmetadata.json

詳細

Tauri Advanced Event System

イベントの基本

バックエンド → フロントエンドのイベント

基本的なイベントの送信:

use tauri::Window;

#[tauri::command]
async fn start_download(
    url: String,
    window: Window,
) -> Result<(), String> {
    window.emit("download-started", url)
        .map_err(|e| e.to_string())?;

    // Perform download...

    window.emit("download-complete", "Success")
        .map_err(|e| e.to_string())
}

フロントエンドのリスナー:

import { listen, UnlistenFn } from '@tauri-apps/api/event';

const unlisten = await listen<string>('download-started', (event) => {
    console.log('Download started:', event.payload);
});

// Clean up when done
unlisten();

構造化イベントペイロード

Serde を使用した型付きイベント

バックエンド:

use serde::Serialize;

#[derive(Serialize, Clone)]
struct ProgressEvent {
    current: usize,
    total: usize,
    percentage: f64,
    message: String,
    speed_mbps: Option<f64>,
}

#[tauri::command]
async fn download_file(
    url: String,
    window: Window,
) -> Result<(), String> {
    let total_size = get_file_size(&url).await?;

    for chunk in 0..total_size {
        // Download chunk...

        let progress = ProgressEvent {
            current: chunk,
            total: total_size,
            percentage: (chunk as f64 / total_size as f64) * 100.0,
            message: format!("Downloading... {}/{}", chunk, total_size),
            speed_mbps: Some(calculate_speed()),
        };

        window.emit("download-progress", progress)
            .map_err(|e| e.to_string())?;
    }

    Ok(())
}

フロントエンド:

interface ProgressEvent {
    current: number;
    total: number;
    percentage: number;
    message: string;
    speed_mbps?: number;
}

const unlisten = await listen<ProgressEvent>('download-progress', (event) => {
    const { current, total, percentage, message, speed_mbps } = event.payload;

    updateProgressBar(percentage);
    updateStatus(message);

    if (speed_mbps) {
        updateSpeed(speed_mbps);
    }
});
複雑なイベントペイロード
#[derive(Serialize, Clone)]
#[serde(tag = "type", content = "data")]
enum AppEvent {
    UserLoggedIn { user_id: String, username: String },
    UserLoggedOut { user_id: String },
    DataSynced { items_count: usize, timestamp: String },
    ErrorOccurred { code: String, message: String, recoverable: bool },
}

#[tauri::command]
async fn perform_login(
    username: String,
    password: String,
    window: Window,
) -> Result<String, String> {
    let user = authenticate(&username, &password).await?;

    // Emit structured event
    window.emit("app-event", AppEvent::UserLoggedIn {
        user_id: user.id.clone(),
        username: user.username.clone(),
    }).map_err(|e| e.to_string())?;

    Ok(user.id)
}

フロントエンド:

type AppEvent =
    | { type: 'UserLoggedIn'; data: { user_id: string; username: string } }
    | { type: 'UserLoggedOut'; data: { user_id: string } }
    | { type: 'DataSynced'; data: { items_count: number; timestamp: string } }
    | { type: 'ErrorOccurred'; data: { code: string; message: string; recoverable: boolean } };

listen<AppEvent>('app-event', (event) => {
    const appEvent = event.payload;

    switch (appEvent.type) {
        case 'UserLoggedIn':
            handleLogin(appEvent.data.user_id, appEvent.data.username);
            break;
        case 'UserLoggedOut':
            handleLogout(appEvent.data.user_id);
            break;
        case 'DataSynced':
            showSyncSuccess(appEvent.data.items_count);
            break;
        case 'ErrorOccurred':
            handleError(appEvent.data);
            break;
    }
});

ストリーミングデータのパターン

リアルタイムデータストリーム
#[tauri::command]
async fn stream_sensor_data(
    sensor_id: String,
    window: Window,
) -> Result<(), String> {
    let mut interval = tokio::time::interval(Duration::from_millis(100));

    for _ in 0..100 {
        interval.tick().await;

        let reading = read_sensor(&sensor_id).await?;

        window.emit("sensor-reading", reading)
            .map_err(|e| e.to_string())?;
    }

    window.emit("sensor-stream-ended", sensor_id)
        .map_err(|e| e.to_string())
}

React を使用したフロントエンド:

import { useEffect, useState } from 'react';
import { listen } from '@tauri-apps/api/event';

interface SensorReading {
    value: number;
    timestamp: number;
    unit: string;
}

function SensorMonitor() {
    const [readings, setReadings] = useState<SensorReading[]>([]);

    useEffect(() => {
        let unlisten: UnlistenFn | undefined;

        listen<SensorReading>('sensor-reading', (event) => {
            setReadings(prev => [...prev.slice(-99), event.payload]);
        }).then(fn => unlisten = fn);

        return () => unlisten?.();
    }, []);

    return (
        <div>
            {readings.map((r, i) => (
                <div key={i}>{r.value} {r.unit}</div>
            ))}
        </div>
    );
}
バッファリングされたストリーミング
#[tauri::command]
async fn stream_logs(
    log_file: String,
    window: Window,
) -> Result<(), String> {
    use tokio::io::{AsyncBufReadExt, BufReader};
    use tokio::fs::File;

    let file = File::open(log_file).await
        .map_err(|e| e.to_string())?;

    let reader = BufReader::new(file);
    let mut lines = reader.lines();

    let mut buffer = Vec::new();

    while let Some(line) = lines.next_line().await
        .map_err(|e| e.to_string())? {

        buffer.push(line);

        // Send in batches of 10 lines
        if buffer.len() >= 10 {
            window.emit("log-batch", buffer.clone())
                .map_err(|e| e.to_string())?;
            buffer.clear();
        }
    }

    // Send remaining lines
    if !buffer.is_empty() {
        window.emit("log-batch", buffer)
            .map_err(|e| e.to_string())?;
    }

    Ok(())
}

複数ウィンドウ間の通信

すべてのウィンドウへのブロードキャスト
use tauri::{AppHandle, Manager};

#[tauri::command]
async fn broadcast_message(
    message: String,
    app: AppHandle,
) -> Result<(), String> {
    // Emit to ALL windows
    app.emit_all("broadcast", message)
        .map_err(|e| e.to_string())
}
特定のウィンドウへのメッセージ送信
#[tauri::command]
async fn send_to_window(
    target_window: String,
    message: String,
    app: AppHandle,
) -> Result<(), String> {
    // Get specific window
    if let Some(window) = app.get_window(&target_window) {
        window.emit("private-message", message)
            .map_err(|e| e.to_string())?;
        Ok(())
    } else {
        Err(format!("Window '{}' not found", target_window))
    }
}
バックエンド経由のウィンドウ間通信

ウィンドウ A(送信側):

import { invoke } from '@tauri-apps/api/core';

async function sendToSettings(data: any) {
    await invoke('relay_to_settings', { data });
}

バックエンドの中継:

#[tauri::command]
async fn relay_to_settings(
    data: serde_json::Value,
    app: AppHandle,
) -> Result<(), String> {
    if let Some(settings_window) = app.get_window("settings") {
        settings_window.emit("data-update", data)
            .map_err(|e| e.to_string())?;
    }
    Ok(())
}

ウィンドウ B(受信側 - 設定):

import { listen } from '@tauri-apps/api/event';

useEffect(() => {
    let unlisten: UnlistenFn | undefined;

    listen('data-update', (event) => {
        console.log('Received from main window:', event.payload);
        updateSettings(event.payload);
    }).then(fn => unlisten = fn);

    return () => unlisten?.();
}, []);

フロントエンド → バックエンドのイベント

カスタムフロントエンドイベント
import { emit } from '@tauri-apps/api/event';

// Frontend emits event
await emit('user-action', {
    action: 'button-click',
    button_id: 'save-button',
    timestamp: Date.now()
});

バックエンドのリスナー:

use tauri::{Manager, Listener};

fn main() {
    tauri::Builder::default()
        .setup(|app| {
            let app_handle = app.handle();

            // Listen for frontend events
            app_handle.listen_global("user-action", move |event| {
                if let Some(payload) = event.payload() {
                    println!("User action: {}", payload);
                    // Process event...
                }
            });

            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

高度なリスナー管理

イベント用 React フック
import { useEffect, useState } from 'react';
import { listen, UnlistenFn } from '@tauri-apps/api/event';

function useEvent<T>(eventName: string): T | null {
    const [payload, setPayload] = useState<T | null>(null);

    useEffect(() => {
        let unlisten: UnlistenFn | undefined;

        listen<T>(eventName, (event) => {
            setPayload(event.payload);
        }).then(fn => unlisten = fn);

        return () => unlisten?.();
    }, [eventName]);

    return payload;
}

// Usage
function ProgressDisplay() {
    const progress = useEvent<ProgressEvent>('download-progress');

    if (!progress) return null;

    return (
        <div>
            Progress: {progress.percentage.toFixed(2)}%
        </div>
    );
}
イベントキューパターン
import { listen } from '@tauri-apps/api/event';

class EventQueue<T> {
    private queue: T[] = [];
    private unlisten?: UnlistenFn;

    async start(eventName: string) {
        this.unlisten = await listen<T>(eventName, (event) => {
            this.queue.push(event.payload);
        });
    }

    dequeue(): T | undefined {
        return this.queue.shift();
    }

    clear() {
        this.queue = [];
    }

    stop() {
        this.unlisten?.();
    }

    get length() {
        return this.queue.length;
    }
}

// Usage
const progressQueue = new EventQueue<ProgressEvent>();
await progressQueue.start('download-progress');

// Process queue periodically
setInterval(() => {
    while (progressQueue.length > 0) {
        const event = progressQueue.dequeue();
        processProgress(event);
    }
}, 100);
1 回限りのイベント
import { once } from '@tauri-apps/api/event';

// Listen for event only once
await once<string>('initialization-complete', (event) => {
    console.log('App initialized:', event.payload);
    startApp();
});

イベントのエラー処理

安全なイベント送信
async fn emit_safe(window: &Window, event: &str, payload: impl Serialize) -> Result<(), String> {
    window.emit(event, payload)
        .map_err(|e| {
            eprintln!("Failed to emit event '{}': {}", event, e);
            e.to_string()
        })
}

#[tauri::command]
async fn process_with_events(
    window: Window,
) -> Result<(), String> {
    emit_safe(&window, "processing-started", "Starting...")
        .await?;

    // Process...

    emit_safe(&window, "processing-complete", "Done!")
        .await?;

    Ok(())
}

パフォーマンスに関する考慮事項

イベントのスロットリング
use std::time::{Duration, Instant};

#[tauri::command]
async fn high_frequency_updates(
    window: Window,
) -> Result<(), String> {
    let mut last_emit = Instant::now();
    let throttle_duration = Duration::from_millis(100);

    for i in 0..10000 {
        let value = compute_value(i);

        // Only emit every 100ms
        if last_emit.elapsed() >= throttle_duration {
            window.emit("update", value)
                .map_err(|e| e.to_string())?;
            last_emit = Instant::now();
        }
    }

    Ok(())
}
イベントのバッチ処理
#[tauri::command]
async fn batch_updates(
    window: Window,
) -> Result<(), String> {
    let mut batch = Vec::new();

    for item in process_items() {
        batch.push(item);

        // Emit in batches of 50
        if batch.len() >= 50 {
            window.emit("batch-update", batch.clone())
                .map_err(|e| e.to_string())?;
            batch.clear();
        }
    }

    // Emit remaining items
    if !batch.is_empty() {
        window.emit("batch-update", batch)
            .map_err(|e| e.to_string())?;
    }

    Ok(())
}

ベストプラクティス

  1. リスナーは必ずクリーンアップする - メモリリークを防ぐため、unlisten() を使用します
  2. イベントペイロードに型を付ける - 型安全性のためにインターフェースを定義します
  3. 構造化イベントを使用する - 複数のイベントタイプにはタグ付きユニオンを使用します
  4. 高頻度イベントをスロットリングする - フロントエンドへの過剰な負荷を防ぎます
  5. 可能な場合はバッチ処理する - シリアライズのオーバーヘッドを減らします
  6. エラーを適切に処理する - 送信に失敗した場合はログに記録し、クラッシュさせないようにします
  7. 1 回限りのイベントには once() を使用する - 初期化や完了通知に使用します
  8. イベント名に名前空間を付ける - "download:"、"user:"、"system:" のようなプレフィックスを使用します

よくある落とし穴

❌ unlisten を忘れる:

// WRONG - memory leak
function Component() {
    listen('my-event', handler);  // Never cleaned up!
}

// CORRECT
function Component() {
    useEffect(() => {
        let unlisten: UnlistenFn | undefined;
        listen('my-event', handler).then(fn => unlisten = fn);
        return () => unlisten?.();
    }, []);
}

❌ シリアライズエラーを処理しない:

// WRONG - struct can't serialize
#[derive(Clone)]  // Missing Serialize!
struct Event { }

window.emit("event", Event {});  // Runtime error!

// CORRECT
#[derive(Serialize, Clone)]
struct Event { }

❌ イベントを頻繁に送信しすぎる:

// WRONG - 10000 events in quick succession
for i in 0..10000 {
    window.emit("update", i);  // Overwhelming!
}

// CORRECT - throttle or batch

まとめ

  • イベントは非同期です - バックエンド → フロントエンドの通信
  • ペイロードには必ず型を付けます - serde::Serialize と TypeScript インターフェースを使用します
  • リスナーをクリーンアップします - クリーンアップ時に unlisten() を呼び出します
  • スロットリング/バッチ処理を行います - 高頻度イベントにはレート制限が必要です
  • 構造化ペイロードを使用します - 複数のイベントタイプにはタグ付きユニオンを使用します
  • ウィンドウを指定します - 特定のウィンドウには emit()、ブロードキャストには emit_all() を使用します
  • フロントエンドイベント - フロントエンドから emit() を使用し、バックエンドのセットアップでリッスンします

コメント