ファイルを開くダイアログを表示する(複数選択・拡張子の絞り込み)

dialog プラグインの open() でファイルを選ばせ、multiple で複数選択、filters で拡張子を絞る。選んだファイルだけが fs プラグインで読める仕組みと、再起動後に読めなくなる落とし穴も示す。

ダイアログ・通知 対象: Tauri 2.x 更新日: 読了目安: 約10分 dlg-004
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. ファイルを 1 つ選んで読む
  4. 複数選択と拡張子の絞り込み
  5. multiple を変数で渡すときは配列かどうかを確かめる
  6. 読めるのは「選んだファイル」を「今回の起動中」だけ
  7. 2. バックエンドから実装する (Rust)
  8. 動作確認
  9. よくあるエラーと対処法
  10. OS ごとの違いと注意点
  11. 関連レシピ

「ファイルを開く」「画像を追加」のようにユーザーにファイルを選ばせるには、dialog プラグインの open() を使います。multiple: true で複数選択、filters で表示する拡張子の絞り込みができ、戻り値は選ばれたパス(キャンセル時は null)です。選んだファイルは、その起動中に限って fs プラグインで読めるようになります。この「読める範囲(スコープ)」でつまずきやすいので、あわせて説明します。

前提条件

dialog プラグインを追加します。選んだファイルを JS で読むなら fs プラグインも追加します。

npm run tauri add dialog
npm run tauri add fs

open() の権限は dialog:allow-open です。dialog:default には含まれますが、core:default には含まれません。読み込みに使う fs:allow-read-text-file と fs:allow-read-file は fs:default に含まれます。選んだファイルは自動で読める範囲に加わるので、$HOME/** のような広い fs:scope を足す必要はありません。広げると、JS からそのフォルダの全ファイルが読めてしまいます。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "dialog:allow-open",
    "fs:allow-read-text-file",
    "fs:allow-read-file"
  ]
}

使い分けは次のとおりです。

やりたいこと使うもの
ファイルを 1 つ選ばせるopen()
まとめて選ばせるopen({ multiple: true })
種類を絞るfilters
保存先を選ばせるsave()(保存する場所を選ばせる)
フォルダを選ばせるopen({ directory: true })(フォルダを選択させる)

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

ファイルを 1 つ選んで読む

defaultPath に既存のフォルダを渡すとそこから開き、フォルダ以外のパスなら親フォルダが開いてファイル名の欄に入ります。filters の拡張子には . を付けません。

import { open } from '@tauri-apps/plugin-dialog';
import { readTextFile } from '@tauri-apps/plugin-fs';
import { documentDir } from '@tauri-apps/api/path';

export async function openTextFile(): Promise<{ path: string; text: string } | null> {
  const path = await open({
    title: 'テキストファイルを開く',
    defaultPath: await documentDir(), // 既存のフォルダを渡すと、そこから開く
    filters: [{ name: 'テキスト', extensions: ['txt', 'md', 'csv'] }], // 「.」は付けない
  });
  if (path === null) return null; // キャンセル
  const text = await readTextFile(path); // 選ばれたファイルなので読める
  return { path, text };
}

文字コードや大きなファイルの読み方は テキストファイルを読み込む で扱います。

複数選択と拡張子の絞り込み

multiple: true にすると戻り値は配列になり、キャンセル時は空の配列ではなく null です。filters には名前と拡張子の組を複数渡せます。ただし絞り込みは表示の補助で、拡張子を変えただけのファイルも選べます。選ばれた後にも拡張子を確かめ、件数の上限も決めておきます。

import { open } from '@tauri-apps/plugin-dialog';
import { readFile } from '@tauri-apps/plugin-fs';
import { basename, extname } from '@tauri-apps/api/path';

const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'webp', 'gif'];

async function extOf(path: string): Promise<string> {
  try {
    return (await extname(path)).toLowerCase();
  } catch {
    return ''; // 拡張子が無いと例外になる
  }
}

export async function pickImages(container: HTMLElement) {
  const paths = await open({
    title: '画像を選択(複数可)',
    multiple: true,
    filters: [
      { name: '画像', extensions: IMAGE_EXTS },
      { name: 'PNG のみ', extensions: ['png'] },
    ],
  });
  if (paths === null) return; // キャンセル

  for (const path of paths.slice(0, 50)) { // 件数の上限
    if (!IMAGE_EXTS.includes(await extOf(path))) continue; // 選ばれた後にも確かめる
    const bytes = await readFile(path); // fs:allow-read-file が必要
    const img = document.createElement('img');
    img.src = URL.createObjectURL(new Blob([bytes]));
    img.onload = () => URL.revokeObjectURL(img.src); // 表示したら URL を解放する
    img.alt = await basename(path);
    img.width = 120;
    container.append(img);
  }
}

バイナリの扱いは 画像などのバイナリファイルを読み込む で詳しく扱います。

multiple を変数で渡すときは配列かどうかを確かめる

戻り値が string[] | null 型になるのは、multiple: true と直接書いたときだけです。boolean の変数などを渡すと型は string | null のままで、実際には配列が返ることがあります。Array.isArray() で配列にそろえます。

import { open } from '@tauri-apps/plugin-dialog';

export async function pickFiles(allowMany: boolean): Promise<string[]> {
  // allowMany が true でも、型の上では string | null になる
  const result: string | string[] | null = await open({ multiple: allowMany });
  if (result === null) return [];
  return Array.isArray(result) ? result : [result];
}

読めるのは「選んだファイル」を「今回の起動中」だけ

open() で選ばれたファイルは、そのファイルだけが fs プラグインの読める範囲(スコープ)に加わります。次の場合は読めません。

  • 同じフォルダにある別のファイル(Markdown から参照している画像など)
  • アプリを再起動した後(「最近使ったファイル」として保存したパスなど)

同じフォルダのファイルも扱うならフォルダを選ばせます(フォルダを選択させる)。再起動後も使うなら Persisted Scope プラグイン で許可を保存するか、次の Rust のコマンドで読みます。

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

DialogExt の dialog().file() でビルダーを作ります。閉じるまで待つ blocking_pick_files() などは async コマンドで使い、メインスレッドで動く setup やメニューのイベントでは、コールバックで受ける pick_files() などを使います。

Rust で選んだパスはスコープに加わりません。Rust の中で読むだけなら std::fs で読めますが、パスを JS に返して fs プラグインで読ませるなら fs_scope().allow_file() で加えます。

use tauri_plugin_dialog::DialogExt;
use tauri_plugin_fs::FsExt;

#[derive(serde::Serialize)]
struct PickedText {
    path: String,
    text: String,
}

/// テキストファイルを複数選ばせ、Rust で読んで中身を返す(スコープは関係ない)
#[tauri::command]
async fn pick_and_read(window: tauri::WebviewWindow) -> Result<Vec<PickedText>, String> {
    let builder = window
        .dialog()
        .file()
        .set_title("読み込むファイルを選択")
        .add_filter("テキスト", &["txt", "md", "csv"]);
    #[cfg(desktop)]
    let builder = builder.set_parent(&window); // 呼び出し元のウィンドウに結び付ける
    let Some(files) = builder.blocking_pick_files() else {
        return Ok(Vec::new()); // キャンセル
    };
    let mut out = Vec::new();
    for file in files {
        let path = file.into_path().map_err(|e| e.to_string())?;
        let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
        out.push(PickedText { path: path.display().to_string(), text });
    }
    Ok(out)
}

/// 画像を選ばせてパスだけを返す。JS の fs プラグインで読めるよう、スコープに加える
#[tauri::command]
async fn pick_image_paths(app: tauri::AppHandle) -> Result<Vec<String>, String> {
    let picked = app
        .dialog()
        .file()
        .add_filter("画像", &["png", "jpg", "jpeg", "webp"])
        .blocking_pick_files();
    let Some(files) = picked else {
        return Ok(Vec::new());
    };
    let scope = app.fs_scope();
    let mut paths = Vec::new();
    for file in files {
        let path = file.into_path().map_err(|e| e.to_string())?;
        scope.allow_file(&path).map_err(|e| e.to_string())?; // これが無いと JS から読めない
        paths.push(path.display().to_string());
    }
    Ok(paths)
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_dialog::init())
        .plugin(tauri_plugin_fs::init())
        .invoke_handler(tauri::generate_handler![pick_and_read, pick_image_paths])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';
import { readFile } from '@tauri-apps/plugin-fs';

type PickedText = { path: string; text: string };

const texts = await invoke<PickedText[]>('pick_and_read');
console.log(texts.map((t) => `${t.path}: ${t.text.length} 文字`));

const images = await invoke<string[]>('pick_image_paths');
if (images.length > 0) {
  const bytes = await readFile(images[0]); // Rust 側でスコープに加えたので読める
  console.log(`${bytes.byteLength} バイト`);
}

動作確認

npm run tauri dev で起動して openTextFile() を呼ぶと、ドキュメントフォルダからダイアログが開き、選んだファイルの中身が返ります。pickImages(document.body) では、拡張子の合う画像が最大 50 枚並びます。

選んだパスを控えて再起動し、そのパスを readTextFile() に渡すと次のエラーになります(Windows のデバッグビルドの例)。

forbidden path: C:\Users\me\Documents\memo.txt, maybe it is not allowed on the scope for `allow-read-text-file` permission in your capability file

よくあるエラーと対処法

  • 「dialog.open not allowed. Permissions associated with this command: dialog:allow-open, dialog:default」: 権限の追加漏れです。リリースビルドでは「Command plugin:dialog|open not allowed by ACL」だけになります。追加して tauri dev を再起動します。
  • 「forbidden path: 」で始まるエラー: 今回の起動中に選ばれていないパスを読もうとしています。「maybe it is not allowed…」以降はデバッグビルドだけに付く説明です。fs:scope を広げて逃げず、選び直してもらうか Rust で読みます。
  • 配列を 1 つのパスとして扱ってしまう: multiple を変数で渡すと型が string | null になるため、配列のまま fs の関数に渡し、引数の型が合わない趣旨のエラーになります。Array.isArray() で判定します。
  • オプションを使い回すと、書き換えた行で TypeError になる: open() に渡したオブジェクトは変更できない状態(凍結)にされます。呼ぶたびに新しいオブジェクトを作ります。
  • Rust から開くとアプリが固まる: メインスレッドで blocking_pick_file() を呼んでいます。async コマンドにするか pick_file() に替えます。

OS ごとの違いと注意点

  • iOS / Android の絞り込み: Android では extensions に拡張子を書いても効かず、MIME タイプ(image/png など)で指定します。iOS でも写真を選ぶ画面(メディアピッカー)では拡張子が効きません。どちらの画面を出すかは pickerMode で指定でき、デスクトップでは無視されます。
  • Android の戻り値: 選ばれたファイルはパスではなく content:// で始まる URI で返ります。fs プラグインの関数にはそのまま渡せます。Rust では std::fs ではなく、fs プラグインの FsExt の app.fs().read_to_string() で読みます。
  • iOS のコピー: 既定(fileAccessMode: 'copy')では、選んだファイルがアプリ内にコピーされてそのパスが返ります。不要になったら消すのはアプリの役目で、'scoped' なら元の場所のまま扱えます(iOS 14 以降)。
  • title: デスクトップ専用です。
  • フォルダの選択: モバイルでは使えません(フォルダを選択させる)。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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