ファイルの監視を始めると、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 件に分かれて届くこともあります。 - エディターの保存方法: その場で上書きするもの、元を消して作り直すもの、一時ファイルに書いてから改名で置き換えるものがあります。「変更」だけを待たず、作成・改名もまとめて「変わった」として扱います。
- 取りこぼし: 大量の変化が一度に起きると、個々のイベントが失われ、読み直しの合図だけが届くことがあります。一覧を持っているアプリは、合図を受けたら作り直します。
