ファイル変更イベントを受け取って処理する

watch() や notify から届く変更イベントを create / modify / rename / remove に見分け、パスごとにまとめて間引いてから処理する。取りこぼし・一時ファイル・自分の書き込みへの備えも示す。

ファイルシステム 対象: Tauri 2.x 更新日: 読了目安: 約12分 fs-026
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. イベントの種類を見分ける
  4. パスごとにまとめて間引く
  5. 実物を確かめてから処理する
  6. 自分の書き込みを除く
  7. 2. バックエンドから実装する (Rust)
  8. 動作確認
  9. よくあるエラーと対処法
  10. OS ごとの違いと注意点
  11. 関連レシピ

ファイルの監視を始めると、1 回の保存やコピーでも作成・変更・改名のイベントが立て続けに届きます。届くたびに読み直したり画面を描き直したりすると、処理が重なったり、書き込み途中のファイルを読んだりします。このレシピでは、ファイルの変更をリアルタイムで監視する で始めた監視のイベントを種類で見分け、パスごとにまとめて間引き、最後に実物を確かめてから処理する方法を扱います。

前提条件

監視の準備(fs プラグインの watch 機能と権限 fs:allow-watch)は ファイルの変更をリアルタイムで監視する のとおりです。ここではアプリデータの inbox フォルダーを監視し、変化があったら exists() や readDir() で中身を確かめます。この 2 つと readTextFile() は、アプリ用のフォルダーの中なら fs:default に含まれます。自分で設定を書き込む例では fs:allow-write-text-file も使います。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "fs:default",
    "fs:allow-watch",
    "fs:allow-write-text-file"
  ]
}

1. フロントエンドから実装する (TypeScript)

イベントの種類を見分ける

watch() / watchImmediate() のコールバックには、type(種類)、paths(対象のパス)、attrs を持つ WatchEvent が 1 件ずつ届きます。type は文字列かオブジェクトで、次のどれかです。

type意味paths
{ create: { kind } }作成作られたもの
{ modify: { kind } }内容や属性の変更変わったもの
{ modify: { kind: 'rename', mode } }改名・移動mode が 'both' なら元と先の 2 つ
{ remove: { kind } }削除(監視外への移動を含む)消えたもの
{ access: { kind } }開いた・閉じた使った対象
'any' / 'other'種類が分からない空のこともある

細かい kind('file' / 'folder'、'data' / 'metadata' など)は OS によって付かず 'any' になることが多いので、判断は大分類で行います。

import type { WatchEvent } from '@tauri-apps/plugin-fs';

export type ChangeKind = 'create' | 'modify' | 'rename' | 'remove' | 'access' | 'unknown';

export function kindOf(event: WatchEvent): ChangeKind {
  const t = event.type;
  if (typeof t === 'string') return 'unknown'; // 'any' / 'other'
  if ('create' in t) return 'create';
  if ('remove' in t) return 'remove';
  if ('access' in t) return 'access';
  return t.modify.kind === 'rename' ? 'rename' : 'modify';
}

パスごとにまとめて間引く

watch() でも、コールバックはイベント 1 件ごとに呼ばれます。届いたイベントをパスごとの Map にため、静かになってから(ここでは 300 ms 後)まとめて処理します。書き込みが続いて静かにならない場合に備え、最初のイベントから 2 秒たったら途中でも処理します。自分でまとめるなら、2 秒遅れる watch() より watchImmediate() と組み合わせる方が反応が速くなります。

// (続き)
export type Pending = Map<string, ChangeKind>;

export function createBatcher(onFlush: (changes: Pending, rescan: boolean) => void, quietMs = 300, maxWaitMs = 2000) {
  let pending: Pending = new Map();
  let rescan = false;
  let firstAt = 0;
  let timer: number | undefined;

  const flush = () => {
    window.clearTimeout(timer);
    const changes = pending;
    const needRescan = rescan;
    pending = new Map();
    rescan = false;
    firstAt = 0;
    onFlush(changes, needRescan);
  };

  return (event: WatchEvent) => {
    const kind = kindOf(event);
    if (kind === 'access') return; // 開いた・閉じただけなら無視
    if (kind === 'unknown') rescan = true; // 何が変わったか分からない
    for (const p of event.paths) {
      // 作成直後の変更は「作成」のまま。それ以外は最後の種類で上書き
      if (!(pending.get(p) === 'create' && kind === 'modify')) pending.set(p, kind);
    }
    firstAt ||= Date.now();
    window.clearTimeout(timer);
    if (Date.now() - firstAt >= maxWaitMs) flush();
    else timer = window.setTimeout(flush, quietMs);
  };
}

実物を確かめてから処理する

イベントは「このあたりが変わった」という合図として使い、処理の直前に今の状態を確かめます。作成が届いた時にはもう消えている一時ファイルや、改名で元の名前が無くなったパスは珍しくないので、種類より 存在するかどうか を信じます。'any' / 'other' が混ざったときは、パスが空のことがあるので フォルダーの一覧 を丸ごと読み直します。処理は 1 本の列に並べ、前のまとまりが終わる前に次を始めないようにします。

// (続き)
import { watchImmediate, mkdir, readDir, exists, BaseDirectory } from '@tauri-apps/plugin-fs';

const TEMP = /(^|[\\/])(~\$|\.~)|\.(tmp|swp|crdownload|part)$|~$/i; // 一時ファイルらしい名前
let queue: Promise<void> = Promise.resolve();

async function processBatch(changes: Pending, rescan: boolean) {
  if (rescan) {
    const entries = await readDir('inbox', { baseDir: BaseDirectory.AppData });
    console.log('rescan', entries.filter((e) => e.isFile).map((e) => e.name));
    return;
  }
  for (const [path, kind] of changes) {
    if (TEMP.test(path)) continue;
    console.log((await exists(path)) ? 'present' : 'gone', kind, path);
  }
}

const onEvent = createBatcher((changes, rescan) => {
  queue = queue.then(() => processBatch(changes, rescan)).catch((e) => console.error(e));
});

const baseDir = BaseDirectory.AppData;
await mkdir('inbox', { baseDir, recursive: true });
export const stop = await watchImmediate('inbox', onEvent, { baseDir, recursive: true });

自分の書き込みを除く

アプリ自身が監視中のフォルダーに 書き込む と、そのイベントも届きます。保存 → 検知 → 読み直し → 画面の更新 → 保存 … と回り続けないよう、書いた内容を覚えておき、読み直した内容が同じなら何もしません。「保存後 1 秒は無視する」のような時間での除外は、その間の外部の変更まで捨ててしまいます。

import { writeTextFile, readTextFile, BaseDirectory } from '@tauri-apps/plugin-fs';

const baseDir = BaseDirectory.AppConfig;
let lastText: string | null = null;

export async function saveSettings(settings: object) {
  lastText = JSON.stringify(settings, null, 2);
  await writeTextFile('settings.json', lastText, { baseDir });
}

// 変更イベントのまとまりを受けたら呼ぶ
export async function reloadIfChanged(apply: (settings: unknown) => void) {
  const text = await readTextFile('settings.json', { baseDir });
  if (text === lastText) return; // 自分が書いたもの
  lastText = text;
  apply(JSON.parse(text)); // 書き込み途中なら例外になるので、呼び出し側で次のまとまりを待つ
}

2. バックエンドから実装する (Rust)

Rust で監視しているなら、まとめる処理も Rust に置くと、フロントエンドへの送信がまとまりごとの 1 回で済みます。notify のイベントをチャンネルで受け、recv_timeout() で「300 ms 何も来なかった」を検知して送ります。event.need_rescan() が true のイベントは取りこぼしがあったかもしれないという合図なので、全体の読み直しを頼むフラグを立てます。

use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::mpsc::{channel, RecvTimeoutError};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use notify::event::ModifyKind;
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, State};

#[derive(Clone, Serialize)]
struct FileChange {
    path: String,
    kind: &'static str,
}

#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ChangeBatch {
    changes: Vec<FileChange>,
    need_rescan: bool,
}

/// 大分類にする。None は捨てるイベント
fn classify(kind: &EventKind) -> Option<&'static str> {
    match kind {
        EventKind::Create(_) => Some("create"),
        EventKind::Modify(ModifyKind::Name(_)) => Some("rename"),
        EventKind::Modify(ModifyKind::Metadata(_)) => None, // 属性だけの変化
        EventKind::Modify(_) => Some("modify"),
        EventKind::Remove(_) => Some("remove"),
        EventKind::Access(_) => None,
        EventKind::Any | EventKind::Other => Some("unknown"),
    }
}

/// 300 ms 静かになるか、2 秒たつごとに "fs-batch" を 1 回送る
fn watch_batched(app: AppHandle, dir: PathBuf) -> notify::Result<RecommendedWatcher> {
    let (tx, rx) = channel::<notify::Result<notify::Event>>();
    let mut watcher = notify::recommended_watcher(tx)?;
    watcher.watch(&dir, RecursiveMode::Recursive)?;

    std::thread::spawn(move || {
        let (quiet, max_wait) = (Duration::from_millis(300), Duration::from_secs(2));
        let mut pending: HashMap<PathBuf, &'static str> = HashMap::new();
        let mut need_rescan = false;
        let mut first: Option<Instant> = None;
        loop {
            match rx.recv_timeout(quiet) {
                Ok(Ok(event)) => {
                    need_rescan |= event.need_rescan();
                    if let Some(kind) = classify(&event.kind) {
                        for path in event.paths {
                            let slot = pending.entry(path).or_insert(kind);
                            if !(*slot == "create" && kind == "modify") {
                                *slot = kind; // 作成直後の変更は「作成」のまま
                            }
                        }
                    }
                    if first.get_or_insert_with(Instant::now).elapsed() < max_wait {
                        continue; // まだ続きが来るかもしれない
                    }
                }
                Ok(Err(e)) => {
                    eprintln!("watch error: {e}");
                    continue;
                }
                Err(RecvTimeoutError::Timeout) => {} // 300 ms 何も来なかった
                Err(RecvTimeoutError::Disconnected) => break, // 監視役が破棄された
            }
            first = None;
            if pending.is_empty() && !need_rescan {
                continue;
            }
            let changes = pending
                .drain()
                .map(|(p, kind)| FileChange { path: p.to_string_lossy().into_owned(), kind })
                .collect();
            let _ = app.emit("fs-batch", ChangeBatch { changes, need_rescan });
            need_rescan = false;
        }
    });
    Ok(watcher)
}

#[derive(Default)]
struct BatchWatcher(Mutex<Option<RecommendedWatcher>>);

#[tauri::command]
fn watch_inbox(app: AppHandle, state: State<'_, BatchWatcher>) -> Result<(), String> {
    let dir = app.path().app_data_dir().map_err(|e| e.to_string())?.join("inbox");
    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
    let watcher = watch_batched(app.clone(), dir).map_err(|e| e.to_string())?;
    *state.0.lock().map_err(|e| e.to_string())? = Some(watcher); // 前の監視とスレッドは終わる
    Ok(())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_fs::init())
        .manage(BatchWatcher::default())
        .invoke_handler(tauri::generate_handler![watch_inbox])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

フロントエンドでは Rust からのイベントを受信する (listen) の方法で受け取ります。listen() を済ませてから監視を始めると、最初の変化を取りこぼしません。

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

type ChangeBatch = {
  changes: { path: string; kind: 'create' | 'modify' | 'rename' | 'remove' | 'unknown' }[];
  needRescan: boolean;
};

await listen<ChangeBatch>('fs-batch', ({ payload }) => {
  const counts = new Map<string, number>();
  for (const c of payload.changes) counts.set(c.kind, (counts.get(c.kind) ?? 0) + 1);
  console.log([...counts].map(([k, n]) => `${k}: ${n}`).join(', '), payload.needRescan ? '(読み直し)' : '');
});
await invoke('watch_inbox');

動作確認

npm run tauri dev で起動して 2 章のコードを動かし、エクスプローラーなどで小さなファイル 20 個を inbox にまとめてコピーします。fs-batch はたいてい 1 回にまとまって届きます(コピーに時間がかかれば 2 秒ごとに分かれます)。

create: 20

同じ操作を watchImmediate() のコールバックで 1 件ずつ出力すると、作成の後に変更が続くなどして、行数はファイル数より多くなります。1 章のコードでは、inbox に report.tmp を置いても何も出ず、report.csv を置くと約 300 ms 後に present create … と出ます。

よくあるエラーと対処法

  • 「failed to open file at path: … with error: …」: イベントを受けて読みに行ったら、ファイルがもう無いか、ほかのアプリが書き込み中で開けませんでした。まとめる時間を延ばし、読む前に exists() で確かめ、失敗したら次のまとまりを待ちます。
  • JSON の解析に失敗する(SyntaxError): 書き込み途中の内容を読んでいます。静かになるまで待つ時間(quietMs)を延ばし、失敗は無視して次の変化を待ちます。
  • 同じ処理が何度も走る: 監視を二重に始めているか、まとめずに 1 件ずつ処理しています。
  • 保存するたびに読み直しが止まらない: 自分の書き込みのイベントを処理しています。書いた内容と比べて除きます。
  • fs-batch が届かない: listen() より前に監視を始めた、またはイベント名が Rust 側と違います。

OS ごとの違いと注意点

  • 種類の細かさ: kind や mode がどこまで分かるかは OS によって違います。access は一部の OS でしか届きません。
  • 改名: watch() では元と先の対応が付けば mode: 'both' の 1 件(paths は元、先の順)にまとめられます。watchImmediate() や Rust では、'from' と 'to' の 2 件に分かれて届くこともあります。
  • エディターの保存方法: その場で上書きするもの、元を消して作り直すもの、一時ファイルに書いてから改名で置き換えるものがあります。「変更」だけを待たず、作成・改名もまとめて「変わった」として扱います。
  • 取りこぼし: 大量の変化が一度に起きると、個々のイベントが失われ、読み直しの合図だけが届くことがあります。一覧を持っているアプリは、合図を受けたら作り直します。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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