画像のギャラリーやファイルブラウザーのような画面では、フォルダーの中身を一覧します。File System プラグインの readDir() は、指定したフォルダーの直下だけを、名前と種類(ファイル・フォルダー・シンボリックリンク)の配列で返します。サイズや日時は含まれず、並び順も決まっていません。サブフォルダーの中まで一度にたどる方法は サブフォルダも含めて再帰的に一覧取得する で扱います。
前提条件
fs プラグインを追加します。ユーザーにフォルダーを選ばせる例ではダイアログプラグインも使います。
npm run tauri add fs
npm run tauri add dialog
readDir() の権限 fs:allow-read-dir は fs:default に含まれ、アプリ用フォルダー($APPDATA など)の中と、ダイアログで選ばれたフォルダーならそのまま使えます。ほかの場所は範囲(スコープ)を付けて足します。次の例は、ピクチャフォルダー($PICTURE)とその直下のフォルダーの一覧だけを許可します。fs:allow-read-dir に付けた範囲は一覧にだけ効き、ファイルの中身は読めません。
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"fs:default",
"dialog:default",
{
"identifier": "fs:allow-read-dir",
"allow": [{ "path": "$PICTURE" }, { "path": "$PICTURE/*" }]
}
]
}
範囲の判定は readDir() に渡したフォルダーにだけ行われ、一覧に出てくる名前は判定されません。そのため「一覧には出るのに読めない」ことがあります。たとえば Windows で $APPLOCALDATA を一覧すると WebView のデータ用の EBWebView が出てきますが、fs:default がその下を拒否しているので中は読めません。範囲の書き方の基本は ファイルやディレクトリを削除する にまとめています。
1. フロントエンドから実装する (TypeScript)
並べ替えて表示する
readDir() の順番は OS やファイルシステムによって違い、名前順とは限りません。画面に出すなら自分で並べ替えます。Intl.Collator の numeric: true を使うと「img2」が「img10」より前に来て、エクスプローラーや Finder の並びに近くなります。拡張子で絞るときは .PNG のような大文字も考えます。
import { readDir, BaseDirectory, type DirEntry } from '@tauri-apps/plugin-fs';
const collator = new Intl.Collator('ja', { numeric: true, sensitivity: 'base' });
// フォルダーを先に、同じ種類どうしは名前の中の数字を数として並べる
export function sortEntries(entries: DirEntry[]): DirEntry[] {
return [...entries].sort(
(a, b) => Number(b.isDirectory) - Number(a.isDirectory) || collator.compare(a.name, b.name),
);
}
// アプリデータの photos の直下にある画像の名前を返す
export async function listImages(): Promise<string[]> {
const entries = await readDir('photos', { baseDir: BaseDirectory.AppData });
return sortEntries(entries)
.filter((e) => e.isFile && /\.(png|jpe?g|webp)$/i.test(e.name)) // i で大文字の拡張子も拾う
.map((e) => e.name);
}
DirEntry にあるのは name・isDirectory・isFile・isSymlink だけで、name はパスではなく名前です。シンボリックリンクは isSymlink だけが true になり、リンク先がフォルダーでも isDirectory は false です。
ユーザーが選んだフォルダーを一覧する
ダイアログで選ばれたフォルダーと直下は、その起動中だけ自動で範囲に加わります。直下のファイルは読めますが、サブフォルダーの中のファイルは読めません(フォルダ(ディレクトリ)を選択させる)。名前からパスを作るとき、join() を名前ごとに呼ぶと 1 件ごとにプロセス間通信(IPC)が起きるので、すぐに値が返る sep() でつなぎます。
import { open } from '@tauri-apps/plugin-dialog';
import { readDir } from '@tauri-apps/plugin-fs';
import { sep } from '@tauri-apps/api/path';
// フォルダーを選ばせ、直下のファイルのフルパスを返す
export async function pickAndListFiles(): Promise<string[]> {
const dir = await open({ directory: true });
if (!dir) return []; // キャンセル
const entries = await readDir(dir);
return entries
.filter((e) => e.isFile && !e.name.startsWith('.')) // 隠しファイルを除く
.map((e) => `${dir}${sep()}${e.name}`);
}
空・無い・範囲外を見分ける
空のフォルダーでは [] が返り、エラーにはなりません。フォルダーが無い、ファイルを渡した、OS に読み取りを拒否された、のいずれかでは「failed to read directory at path: …」で始まるエラーに、範囲の外では「forbidden path: …」で始まるエラーになります。どれも catch で [] にしてしまうと「空のフォルダー」と区別できず、capability の書き漏れに気付けません。範囲外は別の状態として画面に出します。初回起動時はアプリ用フォルダー自体が無いことにも注意します(新しいディレクトリ(フォルダ)を作成する)。
import { readDir, BaseDirectory, type DirEntry } from '@tauri-apps/plugin-fs';
export type Listing =
| { status: 'ok'; entries: DirEntry[] } // 空のフォルダーなら entries が []
| { status: 'forbidden' | 'failed'; message: string };
export async function tryListDir(dir: string, baseDir?: BaseDirectory): Promise<Listing> {
try {
return { status: 'ok', entries: await readDir(dir, { baseDir }) };
} catch (e) {
const message = String(e);
// 範囲外は「forbidden path」、無い・ファイルだった・拒否されたは「failed to read directory」
return { status: message.startsWith('forbidden path') ? 'forbidden' : 'failed', message };
}
}
2. バックエンドから実装する (Rust)
一覧にサイズや更新日時も出すとき、JS で名前ごとに stat() を呼ぶと件数分の IPC が起きます。Rust の std::fs::read_dir() なら、1 回のコマンドで名前・種類・サイズ・更新日時をまとめて返せます。また readDir() は、名前が UTF-8 として正しくないファイルを一覧に含めませんが、Rust で to_string_lossy() を使えば置き換え文字にして残せます。Rust の std::fs にはスコープが効かないので、受け取ったフォルダー名は確かめてから使います。
use serde::Serialize;
use std::io::ErrorKind;
use std::path::{Component, Path};
use std::time::UNIX_EPOCH;
use tauri::Manager;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ListedEntry {
name: String,
is_dir: bool,
is_symlink: bool,
size: u64,
modified_ms: Option<u64>, // 更新日時(UNIX エポックからのミリ秒)
}
/// アプリデータの中の、名前 1 つのフォルダーを一覧する。無ければ空の一覧
#[tauri::command]
fn list_app_folder(app: tauri::AppHandle, name: String) -> Result<Vec<ListedEntry>, String> {
let mut parts = Path::new(&name).components();
if !matches!((parts.next(), parts.next()), (Some(Component::Normal(_)), None)) {
return Err(format!("invalid folder name: {name}"));
}
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?.join(&name);
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(format!("{}: {e}", dir.display())),
};
let mut out = Vec::new();
for entry in entries.flatten() {
let Ok(kind) = entry.file_type() else { continue };
let meta = entry.metadata().ok(); // リンクはたどらない
out.push(ListedEntry {
name: entry.file_name().to_string_lossy().into_owned(),
is_dir: kind.is_dir(),
is_symlink: kind.is_symlink(),
size: meta.as_ref().map_or(0, |m| m.len()),
modified_ms: meta
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64),
});
}
// フォルダーを先に、同じ種類どうしは名前順(大文字小文字を無視)
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
Ok(out)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![list_app_folder])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';
type ListedEntry = { name: string; isDir: boolean; isSymlink: boolean; size: number; modifiedMs: number | null };
const list = await invoke<ListedEntry[]>('list_app_folder', { name: 'exports' });
for (const e of list) {
const date = e.modifiedMs === null ? '-' : new Date(e.modifiedMs).toLocaleString();
console.log(`${e.isDir ? '[DIR] ' : ''}${e.name} ${e.size} bytes ${date}`);
}
動作確認
npm run tauri dev で起動し、アプリデータの photos に img10.jpg・img2.PNG・img1.png・memo.txt を置いてから listImages() を呼ぶと、数字の順に並び、memo.txt は除かれます。続けて tryListDir('missing', BaseDirectory.AppData) と tryListDir('C:/Windows') の status を表示すると、無いフォルダーと範囲の外が別の結果になります。
[ "img1.png", "img2.PNG", "img10.jpg" ]
failed
forbidden
Rust の list_app_folder は、exports が無ければエラーではなく空の配列を返します。
よくあるエラーと対処法
- 「forbidden path: <パス>, maybe it is not allowed on the scope for
allow-read-dirpermission in your capability file」: 一覧しようとしたフォルダーが範囲の外です。$PICTURE/*だけでは$PICTURE自体は一覧できないので、フォルダー自体も書きます。baseDirの付け忘れも確かめます。リリースビルドでは「forbidden path: <パス>」だけになります。 - 「failed to read directory at path: <パス> with error: …」: フォルダーが無い、ファイルのパスを渡した、OS に読み取りを拒否された、のどれかです。続く OS のメッセージで見分けます。
- 「fs.read_dir not allowed. Permissions associated with this command: fs:allow-app-meta, …」: capability に
fs:defaultもfs:allow-read-dirもありません(リリースビルドでは「Command plugin:fs|read_dir not allowed by ACL」)。 - 一覧に出たファイルを開くと forbidden path になる: 一覧と読み取りの範囲は別です。
fs:allow-read-dirだけに付けた範囲、隠しファイル、denyに書いた名前は、一覧に出ても読めません。
OS ごとの違いと注意点
- macOS / Linux:
.で始まる名前も一覧には出ますが、範囲の*と**に一致しないので、開こうとすると forbidden path になります。一覧の段階で除くと扱いがそろいます。macOS では Finder が作る.DS_Storeもよく混ざります。 - Windows: 隠し属性のファイル(
desktop.iniなど)もそのまま含まれ、DirEntryからは隠しかどうか分かりません。 - 共通: 表示中の一覧を最新に保つなら、定期的に読み直すより ファイルの変更をリアルタイムで監視する を使います。1 つのパスの有無だけなら ファイルやフォルダが存在するか確認する の
exists()で足ります。
