ファイルを保存する場所を選ばせる

dialog プラグインの save() で保存先を選ばせ、fs プラグインの writeTextFile() で書き込む。書き込めるのは選ばれた 1 ファイルだけという権限の範囲と、拡張子や上書き保存の扱いも示す。

ダイアログ・通知 対象: Tauri 2.x 更新日: 読了目安: 約9分 dlg-005
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 保存先を選んで書き込む
  4. 形式は返ってきたパスの拡張子で決める
  5. 2 回目からは同じパスに上書き保存する
  6. 2. バックエンドから実装する (Rust)
  7. 動作確認
  8. よくあるエラーと対処法
  9. OS ごとの違いと注意点
  10. 関連レシピ

「名前を付けて保存」「CSV に書き出す」のように保存先をユーザーに決めてもらうには、dialog プラグインの save() を使います。save() はパスを返すだけでファイルは作らないので、書き込みは fs プラグインの writeTextFile() などで行います。選ばれたパスはその起動中だけ書き込めますが、許されるのはそのファイル 1 つだけです。これが拡張子の補い方や一時ファイルを使う保存で問題になるので、対処とあわせて説明します。

前提条件

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

npm run tauri add dialog
npm run tauri add fs

save() の権限は dialog:allow-save(dialog:default に含まれる)です。書き込みに使う fs:allow-write-text-file(バイナリなら fs:allow-write-file)は、fs:default に含まれないので追加します。fs:default はアプリ用フォルダの読み取りなどを許すだけのセットです。選ばれたパスは自動で書き込める範囲(スコープ)に加わるので、保存先のフォルダを fs:scope で許可する必要はありません。

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

既存のファイルを選ばせて開くなら ファイルを開くダイアログ、書き出し先をフォルダ単位で決めさせるなら フォルダを選択させる を使います。

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

保存先を選んで書き込む

defaultPath にフォルダとファイル名をつなげたパスを渡すと、そのフォルダが開いてファイル名の欄が埋まります。ファイル名だけなら、開く場所は OS に任せられます。戻り値が null ならキャンセルで、エラーとして扱う必要はありません。

import { save } from '@tauri-apps/plugin-dialog';
import { writeTextFile } from '@tauri-apps/plugin-fs';
import { documentDir, join } from '@tauri-apps/api/path';

export async function exportCsv(csv: string): Promise<string | null> {
  const path = await save({
    title: 'CSV に書き出す',
    defaultPath: await join(await documentDir(), 'report.csv'), // フォルダ + ファイル名の初期値
    filters: [{ name: 'CSV ファイル', extensions: ['csv'] }],
  });
  if (path === null) return null; // キャンセル
  await writeTextFile(path, csv); // 選ばれたパスには書ける(fs:allow-write-text-file が必要)
  return path;
}

追記や文字コードは テキストファイルに書き込む、画像などは バイナリデータをファイルに保存する で扱います。

形式は返ってきたパスの拡張子で決める

フィルターを複数並べても、どれが選ばれたかは返ってきません。形式は返ってきたパスの拡張子で判断します。ユーザーが拡張子を消して入力すると、拡張子の無いパスが返ることもあります。このとき path + '.csv' に書こうとしても失敗します。許されているのは返ってきたパスそのものだけだからです。付け足した名前を defaultPath にしてもう一度ダイアログを出し、確定してもらいます。

import { save } from '@tauri-apps/plugin-dialog';
import { writeTextFile } from '@tauri-apps/plugin-fs';
import { extname } from '@tauri-apps/api/path';

type Row = Record<string, string | number>;

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

// 値にカンマや改行を含まない前提の簡易版
function toCsv(rows: Row[]): string {
  const keys = Object.keys(rows[0] ?? {});
  return [keys.join(','), ...rows.map((r) => keys.map((k) => String(r[k])).join(','))].join('\n');
}

export async function exportTable(rows: Row[]): Promise<string | null> {
  let defaultPath = 'table.csv';
  for (;;) {
    const path = await save({
      defaultPath,
      filters: [
        { name: 'CSV', extensions: ['csv'] },
        { name: 'JSON', extensions: ['json'] },
      ],
    });
    if (path === null) return null;
    const ext = await extOf(path);
    if (ext === 'csv' || ext === 'json') {
      await writeTextFile(path, ext === 'csv' ? toCsv(rows) : JSON.stringify(rows, null, 2));
      return path;
    }
    // path + '.csv' は許可されていない。付け足した名前を初期値にして選び直してもらう
    defaultPath = `${path}.csv`;
  }
}

2 回目からは同じパスに上書き保存する

一度選ばれたパスには、その起動中は何度でも書き込めます。エディターの「上書き保存」は控えたパスへダイアログなしで書き、「名前を付けて保存」のときだけ save() を出します。再起動すると許可は消えるので、パスを保存しておき次の起動で黙って書く作りはできません。必要なら Persisted Scope プラグイン で許可を保存するか、Rust で書きます。

import { save } from '@tauri-apps/plugin-dialog';
import { writeTextFile } from '@tauri-apps/plugin-fs';

let currentPath: string | null = null; // 開いている文書の保存先

// 上書き保存は saveDocument(text)、名前を付けて保存は saveDocument(text, true)
export async function saveDocument(text: string, saveAs = false): Promise<boolean> {
  if (currentPath === null || saveAs) {
    const picked = await save({
      defaultPath: currentPath ?? '無題.txt', // 前回の場所と名前を初期値にする
      filters: [{ name: 'テキスト', extensions: ['txt'] }],
    });
    if (picked === null) return false; // キャンセル。currentPath は変えない
    currentPath = picked;
  }
  await writeTextFile(currentPath, text); // 起動中は同じパスに何度でも書ける
  return true;
}

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

Rust では blocking_save_file() を async コマンドで呼びます。メニューのイベントなどメインスレッドから出すなら、コールバックで受ける save_file() を使います。初期値は set_file_name() と set_directory() で指定します。

Rust の std::fs はスコープの制限を受けないので、JS ではできない「同じフォルダに一時ファイルを書いてから置き換える」保存ができます。書き込みの途中で失敗しても、元のファイルが中途半端な内容で残りません。

use std::io::Write;
use tauri_plugin_dialog::DialogExt;

/// 一時ファイルに書き終えてから置き換える(途中で失敗しても元のファイルを壊さない)
fn write_replacing(path: &std::path::Path, data: &[u8]) -> std::io::Result<()> {
    let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
    let tmp = path.with_file_name(format!("{name}.saving"));
    let result = (|| {
        let mut file = std::fs::File::create(&tmp)?;
        file.write_all(data)?;
        file.sync_all()?; // ディスクに書き終えてから置き換える
        drop(file);
        std::fs::rename(&tmp, path)
    })();
    if result.is_err() {
        let _ = std::fs::remove_file(&tmp); // 失敗したら一時ファイルを片付ける
    }
    result
}

/// 保存先を選ばせて書き込み、保存したパスを返す(キャンセルなら None)
#[tauri::command]
async fn save_report_as(window: tauri::WebviewWindow, text: String) -> Result<Option<String>, String> {
    let builder = window
        .dialog()
        .file()
        .set_title("レポートを保存")
        .set_file_name("report.txt")
        .add_filter("テキスト", &["txt"]);
    #[cfg(desktop)]
    let builder = builder.set_parent(&window); // 呼び出し元のウィンドウに結び付ける
    let Some(file) = builder.blocking_save_file() else {
        return Ok(None);
    };
    let path = file.into_path().map_err(|e| e.to_string())?;
    write_replacing(&path, text.as_bytes()).map_err(|e| format!("{}: {e}", path.display()))?;
    Ok(Some(path.display().to_string()))
}

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

const saved = await invoke<string | null>('save_report_as', { text: '今月の売上: 120 万円' });
console.log(saved ?? 'キャンセルされました');

動作確認

npm run tauri dev で起動して exportCsv('名前,点数\n佐藤,80\n') を呼ぶと、ドキュメントフォルダで「report.csv」が入った保存ダイアログが開き、保存するとそのパスが返ります。exportTable() でファイル名から拡張子を消して保存すると、「.csv」を付けた名前でダイアログが開き直します。saveDocument() は初回だけダイアログを出し、2 回目からは同じファイルを上書きします。

よくあるエラーと対処法

  • 「dialog.save not allowed. Permissions associated with this command: dialog:allow-save, dialog:default」: 権限の追加漏れです。リリースビルドでは「Command plugin:dialog|save not allowed by ACL」だけになります。
  • 「fs.write_text_file not allowed. Permissions associated with this command:」の後に候補が 50 以上並ぶ: 書き込みの権限がありません。fs:default だけでは書けないので fs:allow-write-text-file を追加します。
  • 「forbidden path: 」で始まるエラー: 返ってきたパス以外に書こうとしています。拡張子を付け足した、.bak を作ろうとした、再起動後に保存済みのパスへ書こうとした、のいずれかです。デバッグビルドでは後ろに「maybe it is not allowed on the scope for allow-write-text-file permission in your capability file」が付きます。
  • Android でファイル名が「(invalid).txt」になる: defaultPath にファイル名を渡していません。

OS ごとの違いと注意点

  • iOS / Android: defaultPath はファイル名の部分だけが使われ、開く場所は指定できません。Android でファイル名を渡さないと「(invalid).txt」になるので、必ず渡します。title はデスクトップ専用です。
  • macOS: ダイアログの中で新しいフォルダを作れます(既定で有効)。作らせたくなければ canCreateDirectories: false にします。
  • 既存のファイル: 既にあるファイルが選ばれた場合、writeTextFile() はその内容を置き換えます。
  • Rust の例: 返ってきた値をパスとして std::fs で書くデスクトップ向けの書き方です。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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