設定ファイルが無ければ既定値で始める、保存先に同じ名前があれば確認を出す、といった判断には File System プラグインの exists() を使います。ファイルでもフォルダーでも、あれば true、無ければ false が返ります。注意したいのは、許可していない場所(スコープの外)では false ではなく例外になることと、確かめた直後に状況が変わりうることです。Rust では Path::try_exists() を使うと、「無い」と「調べられなかった」を区別できます。
前提条件
fs プラグインを追加します。
npm run tauri add fs
exists() の権限 fs:allow-exists は fs:default に含まれ、アプリ用フォルダー($APPDATA など)の中ならそのまま使えます。ほかの場所は範囲を付けて足します。ファイルかフォルダーかを見分ける stat()(fs:allow-stat)と、後述の writeTextFile()(fs:allow-write-text-file)は fs:default に含まれないので追加します。範囲の決まり方は ファイルやディレクトリを削除する にまとめています。
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"fs:default",
"fs:allow-stat",
"fs:allow-write-text-file",
{
"identifier": "fs:allow-exists",
"allow": [{ "path": "$DOCUMENT/MyApp/**" }]
}
]
}
1. フロントエンドから実装する (TypeScript)
無ければ既定値で始める
初回起動では、設定ファイルもそれを入れるフォルダーもまだありません。親のフォルダーが無くても exists() はエラーにならず false を返すので、そのまま既定値に切り替えられます。
import { exists, readTextFile, BaseDirectory } from '@tauri-apps/plugin-fs';
type Settings = { theme: 'light' | 'dark'; fontSize: number };
const DEFAULTS: Settings = { theme: 'light', fontSize: 14 };
export async function loadSettings(): Promise<Settings> {
const opts = { baseDir: BaseDirectory.AppConfig };
if (!(await exists('settings.json', opts))) return DEFAULTS; // 初回起動
const saved = JSON.parse(await readTextFile('settings.json', opts)) as Partial<Settings>;
return { ...DEFAULTS, ...saved }; // 後から増えた項目は既定値で埋める
}
「無い」と「範囲の外」を区別する
exists() が false を返すのは、範囲の中で本当に無いときだけです。範囲の外のパスは、ファイルがあってもなくても「forbidden path: …」で始まるエラーになります。catch(() => false) のようにまとめて false に置き換えると、capability の書き漏れや baseDir の付け忘れが「ファイルが無い」に見えてしまい、原因を探す手がかりが消えます。範囲外は別の結果として扱います。
import { exists, BaseDirectory } from '@tauri-apps/plugin-fs';
export type Presence = 'exists' | 'missing' | 'forbidden';
export async function probe(path: string, baseDir?: BaseDirectory): Promise<Presence> {
try {
return (await exists(path, { baseDir })) ? 'exists' : 'missing';
} catch (e) {
if (String(e).startsWith('forbidden path')) return 'forbidden'; // 範囲外。「無い」とは限らない
throw e; // 権限(fs:allow-exists)の不足などはそのまま上げる
}
}
ファイルかフォルダーかを見分ける
exists() は種類を返しません。同じ名前でファイルとフォルダーのどちらもありうるときは stat() を使います。stat() は無いパスで「failed to get metadata of path: …」で始まるエラーになるので、1 回の呼び出しで有無と種類が分かります。シンボリックリンクは先をたどって判定されるため、リンク自体を調べるなら lstat() を使います。サイズや更新日時の読み方は ファイルのメタデータ(サイズ・作成日時・更新日時)を取得する で扱います。
import { stat, BaseDirectory } from '@tauri-apps/plugin-fs';
export type Kind = 'file' | 'directory' | 'other' | 'missing';
export async function kindOf(path: string, baseDir: BaseDirectory): Promise<Kind> {
try {
const info = await stat(path, { baseDir }); // fs:allow-stat が必要
return info.isDirectory ? 'directory' : info.isFile ? 'file' : 'other';
} catch (e) {
if (String(e).startsWith('failed to get metadata')) return 'missing';
throw e; // 範囲外(forbidden path)は「無い」にしない
}
}
確かめてから書かない
exists() で無いのを確かめてから書くと、その間に別の処理(2 つ目のウィンドウや同期ソフトなど)が同じ名前で作ったとき、それを上書きしてしまいます。「無ければ作る」は writeTextFile() の createNew: true で 1 回にまとめます。既にあると失敗するので、上書きを確実に防げます。フォルダーなら mkdir() の recursive: true が「無ければ作る」に当たります(新しいディレクトリ(フォルダ)を作成する)。
import { exists, mkdir, writeTextFile, BaseDirectory } from '@tauri-apps/plugin-fs';
// notes/<name> に、同じ名前が無いときだけ書く。書いたら true、既にあれば false
export async function saveAsNew(name: string, text: string): Promise<boolean> {
const baseDir = BaseDirectory.AppData;
await mkdir('notes', { baseDir, recursive: true }); // フォルダーが無いことによる失敗を先に除く
try {
await writeTextFile(`notes/${name}`, text, { baseDir, createNew: true });
return true;
} catch (e) {
if (await exists(`notes/${name}`, { baseDir })) return false; // 既にあったので書かなかった
throw e; // 書き込み権が無いなど、ほかの理由
}
}
2. バックエンドから実装する (Rust)
Path::exists() は、読み取り権が無いなどで調べられなかったときも false を返します。try_exists() は Result<bool> を返すので、調べられなかったことをエラーとして区別できます。種類まで知りたいときは std::fs::symlink_metadata() を使えば、リンク切れも「リンクがある」と分かります(Path::exists() はリンク先を見るので、リンク切れは false)。「無ければ作る」は File::create_new() が既存のファイルで ErrorKind::AlreadyExists を返すので、確かめる手順が要りません。どれも capability とスコープの外で動くため、受け取った名前は確かめてから使います。
use serde::Serialize;
use std::io::{ErrorKind, Write};
use std::path::{Component, Path, PathBuf};
use tauri::Manager;
#[derive(Serialize)]
#[serde(rename_all = "lowercase")]
enum PathKind {
File,
Dir,
Symlink,
Missing,
}
/// アプリデータの中の、名前 1 つだけのパスにする
fn app_data_entry(app: &tauri::AppHandle, name: &str) -> Result<PathBuf, String> {
let mut parts = Path::new(name).components();
if !matches!((parts.next(), parts.next()), (Some(Component::Normal(_)), None)) {
return Err(format!("invalid name: {name}"));
}
Ok(app.path().app_data_dir().map_err(|e| e.to_string())?.join(name))
}
#[tauri::command]
fn path_kind(app: tauri::AppHandle, name: String) -> Result<PathKind, String> {
let path = app_data_entry(&app, &name)?;
// symlink_metadata はリンクをたどらない
match std::fs::symlink_metadata(&path) {
Ok(m) if m.is_symlink() => Ok(PathKind::Symlink),
Ok(m) if m.is_dir() => Ok(PathKind::Dir),
Ok(_) => Ok(PathKind::File),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(PathKind::Missing),
Err(e) => Err(format!("{}: {e}", path.display())), // 調べられなかった。「無い」にしない
}
}
#[tauri::command]
fn has_entry(app: tauri::AppHandle, name: String) -> Result<bool, String> {
let path = app_data_entry(&app, &name)?;
path.try_exists().map_err(|e| format!("{}: {e}", path.display()))
}
/// 無いときだけ作って書く。作ったら true、既にあれば false
#[tauri::command]
fn create_note(app: tauri::AppHandle, name: String, text: String) -> Result<bool, String> {
let path = app_data_entry(&app, &name)?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
match std::fs::File::create_new(&path) {
Ok(mut file) => file.write_all(text.as_bytes()).map(|_| true).map_err(|e| e.to_string()),
Err(e) if e.kind() == ErrorKind::AlreadyExists => Ok(false),
Err(e) => Err(format!("{}: {e}", path.display())),
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.invoke_handler(tauri::generate_handler![path_kind, has_entry, create_note])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';
console.log(await invoke<string>('path_kind', { name: 'memo.txt' })); // "file" / "dir" / "symlink" / "missing"
console.log(await invoke<boolean>('create_note', { name: 'memo.txt', text: 'hello' }));
console.log(await invoke<boolean>('create_note', { name: 'memo.txt', text: 'again' })); // false(上書きしない)
動作確認
npm run tauri dev で起動し、probe() を 3 回呼んで結果を表示します。1 回目は probe('notes/memo.txt', BaseDirectory.AppData)、次に saveAsNew('memo.txt', 'hello') を呼んでから同じものを 2 回目、最後に範囲の外の probe('C:/Windows/win.ini')(macOS / Linux なら /etc/hosts)です。3 つ目はファイルがあっても forbidden になります。
missing
exists
forbidden
Rust の create_note は 1 回目が true、2 回目が false を返し、ファイルの中身は「hello」のままです。
よくあるエラーと対処法
- 「forbidden path: <パス>, maybe it is not allowed on the scope for
allow-existspermission in your capability file」: パスが範囲の外です。存在の有無とは関係ありません。範囲に足すか、baseDirの付け忘れで相対パスのまま渡していないかを確かめます。リリースビルドでは「forbidden path: <パス>」だけになります。 - 「fs.exists not allowed. Permissions associated with this command: fs:allow-app-meta, …」: capability に
fs:defaultもfs:allow-existsもありません(リリースビルドでは「Command plugin:fs|exists not allowed by ACL」)。 - 「fs.stat not allowed. …」:
stat()の権限fs:allow-statはfs:defaultに含まれないので追加します。 - あるはずなのに
false:baseDirの取り違え(書いたときはAppLocalData、確かめるときはAppDataなど)がよくある原因です。Windows ではこの 2 つは別のフォルダーです。Linux では大文字と小文字の違いでも別の名前になります。
OS ごとの違いと注意点
- macOS / Linux: 範囲の
*と**は.で始まる名前に一致しないので、.envのような隠しファイルの確認はアプリ用フォルダーの中でもforbiddenになります。範囲に.から書くか、tauri.conf.jsonのplugins.fs.requireLiteralLeadingDotをfalseにします。Windows では既定で一致します。 - Windows / macOS: 標準の設定のファイルシステムは大文字と小文字を区別しないので、
Memo.txtを確かめてもmemo.txtがあればtrueです。「無いから作る」と判断した名前が既存のファイルと重なることがあるので、作るときはcreateNewで確実に防ぎます。 - 共通: 結果は確かめた瞬間のものです。多数のファイルの有無をまとめて調べるなら、1 件ずつ
exists()を呼ぶより、フォルダ内のファイル一覧を取得する のreadDir()で一覧を 1 回取る方がプロセス間通信(IPC)が少なく済みます。
