外部のエディターで書き換えた設定をすぐ反映する、取り込み用フォルダーに置かれたファイルを自動で処理する、といった機能にはファイルの監視を使います。フロントエンドからは 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-watchpermission 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 側のパスを監視すると、イベントが届かないことがあります。定期的に一覧を読み直す方式と併用します。
- 共通: 監視しているフォルダー自体を消した、という変化を受け取るには、その親フォルダーを監視します。
- 共通: 非常に多くのファイルを監視すると、すべてのイベントを受け取れないことがあります。取りこぼしへの備えは ファイル変更イベントを受け取って処理する を参照してください。
