ファイルの変更をリアルタイムで監視する

fs プラグインの watch() / watchImmediate()(Cargo の watch 機能と fs:allow-watch が必要)や Rust の notify でフォルダーの変化を受け取る。止め方と対象の選び方も示す。

ファイルシステム 対象: Tauri 2.x 更新日: 読了目安: 約10分 fs-025
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. watch() と watchImmediate() の使い分け
  4. 設定ファイルの変更を反映する
  5. 2. バックエンドから実装する (Rust)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

外部のエディターで書き換えた設定をすぐ反映する、取り込み用フォルダーに置かれたファイルを自動で処理する、といった機能にはファイルの監視を使います。フロントエンドからは File System プラグインの watch() と watchImmediate()、Rust からは notify クレートで監視します。届いたイベントの見分け方やまとめ方は ファイル変更イベントを受け取って処理する で扱います。

前提条件

fs プラグインを追加します。

npm run tauri add fs

監視は既定では組み込まれません。tauri add は機能を指定せずに追加するので、src-tauri/Cargo.toml の tauri-plugin-fs に watch 機能を手で足します。Rust で監視する場合は notify も追加します(src-tauri で cargo add notify)。

[dependencies]
tauri-plugin-fs = { version = "2", features = ["watch"] }
notify = "8"

監視の権限 fs:allow-watch は fs:default に含まれないので追加します。fs:default はアプリ用のフォルダー($APPCONFIG・$APPDATA など)とその中身をパスの範囲(スコープ)に含むので、そこを監視するだけなら fs:allow-watch を加えるだけで済みます。それ以外の場所は allow に書きます。範囲の書き方の基本は ファイルやディレクトリを削除する を参照してください。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "fs:default",
    {
      "identifier": "fs:allow-watch",
      "allow": [{ "path": "$DOCUMENT/MyApp" }, { "path": "$DOCUMENT/MyApp/**" }]
    }
  ]
}
  • 範囲の判定は、監視を始めるパスにだけ行われます。$DOCUMENT/MyApp を recursive: true で監視すると、中のファイルのイベントは deny に書いたパスのものも含めてすべて届きます。
  • 監視を止める処理は core:default に含まれる権限で動くので、fs:allow-unwatch は要りません。

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

watch() と watchImmediate() の使い分け

watch()watchImmediate()
届くタイミング変化からおよそ delayMs(既定 2000 ms)後変化の直後
重複同じファイルの連続した変化をある程度まとめる届いたものをそのまま渡す
向いている用途設定の読み直し、一覧の更新すぐに反応したい処理、自前でまとめる処理

どちらも第 1 引数にパス(配列で複数も可)、第 2 引数にコールバック、第 3 引数に recursive(サブフォルダーも監視)と baseDir を取り、監視を止める関数を返します。recursive を付けないと、フォルダー直下の変化だけが届きます。

import { watch, watchImmediate, mkdir, BaseDirectory } from '@tauri-apps/plugin-fs';

const baseDir = BaseDirectory.AppData;
await mkdir('inbox', { baseDir, recursive: true }); // 無いフォルダーは監視できない

// 変化からおよそ 2 秒後に、ある程度まとめて届く
const stopWatch = await watch('inbox', (event) => {
  console.log('watch', JSON.stringify(event));
}, { baseDir, recursive: true });

// 変化の直後に届く
const stopImmediate = await watchImmediate('inbox', (event) => {
  console.log('immediate', JSON.stringify(event));
}, { baseDir, recursive: true });

// 返された関数を呼ぶと監視が止まる
export function stopAll() {
  stopWatch();
  stopImmediate();
}

設定ファイルの変更を反映する

settings.json を読み直す例です。監視を始める関数が 2 回呼ばれると同じ変更が 2 回届くので、始める前に前の監視を止めます。開発中のホットリロードで起きやすい失敗です。

import { watch, mkdir, readTextFile, type UnwatchFn } from '@tauri-apps/plugin-fs';
import { appConfigDir, join } from '@tauri-apps/api/path';

let unwatch: UnwatchFn | null = null;

export async function watchSettings(apply: (settings: unknown) => void) {
  stopSettingsWatch(); // 二重に始めない
  const dir = await appConfigDir();
  const file = await join(dir, 'settings.json');
  await mkdir(dir, { recursive: true }); // $APPCONFIG は自動では作られない

  unwatch = await watch(
    dir, // ファイルではなくフォルダーを監視し、名前で絞る
    async (event) => {
      if (!event.paths.some((p) => /[\\/]settings\.json$/.test(p))) return;
      try {
        apply(JSON.parse(await readTextFile(file)));
      } catch (e) {
        console.warn('settings.json を読めませんでした', e); // 削除された、書き込み途中など
      }
    },
    { delayMs: 500 }, // 既定の 2 秒では反映が遅く感じるので短くする
  );
}

export function stopSettingsWatch() {
  unwatch?.();
  unwatch = null;
}

ファイルを直接監視しない理由は 2 つあります。存在しないパスの監視は失敗するため、初回の保存前は監視を始められません。また、エディターによっては保存時に元のファイルを消して作り直すため、ファイル単体の監視では「削除」として届きます。親フォルダーを監視してファイル名で絞れば、どちらも避けられます。

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

notify の監視役(RecommendedWatcher)は、値が生きている間だけ監視を続け、破棄されると止まります。関数の中で作ってそのまま抜けるとすぐ止まるので、State に持たせます。変化は別のスレッドでコールバックに届くので、そこから emit() でフロントエンドへ送ります。notify::Event をそのまま送るには notify の serde 機能が要るため、ここでは必要な項目だけの構造体に詰め替えます。

JS から受け取ったパスをそのまま監視すると、capability と関係なくどのフォルダーの変化でも覗けてしまいます。監視する場所はコマンドの側で決めます。

use std::path::{Path, PathBuf};
use std::sync::Mutex;
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, State};

/// フロントエンドへ送る内容
#[derive(Clone, Serialize)]
struct FsChange {
    kind: &'static str,
    paths: Vec<String>,
}

/// 監視役を持っておく。None にすると破棄されて監視が止まる
#[derive(Default)]
struct InboxWatcher(Mutex<Option<RecommendedWatcher>>);

fn kind_name(kind: &EventKind) -> &'static str {
    match kind {
        EventKind::Create(_) => "create",
        EventKind::Modify(_) => "modify",
        EventKind::Remove(_) => "remove",
        EventKind::Access(_) => "access",
        _ => "other",
    }
}

/// dir 以下を監視し、変化を "fs-change" イベントで全ウィンドウへ送る
fn watch_dir(app: &AppHandle, dir: &Path) -> notify::Result<RecommendedWatcher> {
    let app = app.clone();
    let mut watcher = notify::recommended_watcher(move |res: notify::Result<Event>| match res {
        Ok(event) => {
            let paths = event.paths.iter().map(|p| p.to_string_lossy().into_owned()).collect();
            let _ = app.emit("fs-change", FsChange { kind: kind_name(&event.kind), paths });
        }
        Err(e) => eprintln!("watch error: {e}"),
    })?;
    watcher.watch(dir, RecursiveMode::Recursive)?;
    Ok(watcher)
}

/// 監視してよい場所はここで決める(アプリデータの inbox)
fn inbox_dir(app: &AppHandle) -> Result<PathBuf, 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())?; // 無いフォルダーは監視できない
    Ok(dir)
}

#[tauri::command]
fn start_inbox_watch(app: AppHandle, state: State<'_, InboxWatcher>) -> Result<(), String> {
    let dir = inbox_dir(&app)?;
    let watcher = watch_dir(&app, &dir).map_err(|e| e.to_string())?;
    *state.0.lock().map_err(|e| e.to_string())? = Some(watcher); // 前の監視はここで止まる
    Ok(())
}

#[tauri::command]
fn stop_inbox_watch(state: State<'_, InboxWatcher>) {
    if let Ok(mut slot) = state.0.lock() {
        slot.take(); // 破棄すると監視が止まる
    }
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_fs::init())
        .manage(InboxWatcher::default())
        .setup(|app| {
            // 起動と同時に監視を始める(ウィンドウの JS を待たない)
            let dir = inbox_dir(app.handle())?;
            let watcher = watch_dir(app.handle(), &dir)?;
            *app.state::<InboxWatcher>().0.lock().unwrap() = Some(watcher);
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![start_inbox_watch, stop_inbox_watch])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

フロントエンドでは Rust からのイベントを受信する (listen) の方法で受け取ります。setup で始めた監視の変化のうち、ページが listen() する前に起きたものは届きません。画面に出すための監視なら、listen() を済ませてから start_inbox_watch で始めます。

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

type FsChange = { kind: 'create' | 'modify' | 'remove' | 'access' | 'other'; paths: string[] };

await listen<FsChange>('fs-change', ({ payload }) => {
  console.log(payload.kind, payload.paths);
});
await invoke('start_inbox_watch'); // listen の後で始める(setup で始めた監視は置き換わる)

動作確認

npm run tauri dev で起動して 1 章の最初のコードを実行し、PowerShell で inbox フォルダーに移動して Set-Content memo.txt hello を実行します。フォルダーの場所は アプリ専用のデータ保存フォルダパスを取得する で確かめられます。Windows では、まず immediate が作成と変更を別々に受け取り(変更は複数回届くこともあります)、2 秒ほど遅れて watch が作成の 1 件だけを受け取ります。

immediate {"type":{"create":{"kind":"any"}},"paths":["C:\\Users\\me\\AppData\\Roaming\\com.example.app\\inbox\\memo.txt"],"attrs":{}}
immediate {"type":{"modify":{"kind":"any"}},"paths":["C:\\Users\\me\\AppData\\Roaming\\com.example.app\\inbox\\memo.txt"],"attrs":{}}
watch {"type":{"create":{"kind":"any"}},"paths":["C:\\Users\\me\\AppData\\Roaming\\com.example.app\\inbox\\memo.txt"],"attrs":{}}

よくあるエラーと対処法

  • 「fs.watch not allowed. Permissions associated with this command: …」: fs:allow-watch の追加漏れです(この詳しい文面は開発ビルドのもので、リリースビルドでは「Command plugin:fs|watch not allowed by ACL」)。
  • 「Command watch not found」: Cargo.toml の tauri-plugin-fs に watch 機能が付いていません。
  • 「forbidden path: <パス>, maybe it is not allowed on the scope for allow-watch permission in your capability file」: 監視するパスが範囲の外です。フォルダー自体($DOCUMENT/MyApp)が allow にあるか見直します。リリースビルドでは「forbidden path: <パス>」だけです。
  • 「No path was found.」で始まるエラー: 監視するパスが存在しません。mkdir() で作ってから始めるか、親フォルダーを監視します。
  • Rust の監視がすぐ止まる: 監視役が関数の終わりで破棄されています。State に持たせます。

OS ごとの違いと注意点

  • Linux: 監視できる数に上限があり、再帰的な監視では中のファイルとフォルダーがすべて数に入ります。超えると「OS file watch limit reached.」で始まるエラーになります。システム設定(fs.inotify.max_user_watches など)で上げられますが、まずは監視する範囲を狭くします。
  • macOS: 自分以外のユーザーが所有するファイルは、変化を拾えないことがあります。
  • ネットワークドライブ・WSL: NFS のようなネットワーク上のフォルダーや、WSL から Windows 側のパスを監視すると、イベントが届かないことがあります。定期的に一覧を読み直す方式と併用します。
  • 共通: 監視しているフォルダー自体を消した、という変化を受け取るには、その親フォルダーを監視します。
  • 共通: 非常に多くのファイルを監視すると、すべてのイベントを受け取れないことがあります。取りこぼしへの備えは ファイル変更イベントを受け取って処理する を参照してください。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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