テキストファイルに書き込む

fs プラグインの writeTextFile()(権限 fs:allow-write-text-file)で文字列を保存する。既定で上書きになる点、初回に無いフォルダーの作り方、BOM・改行コード、壊れにくい保存の手順も示す。

ファイルシステム 対象: Tauri 2.x 更新日: 読了目安: 約10分 fs-002
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. フォルダーを作ってから書く
  4. 途中で失敗しても壊れない保存
  5. 文字コードと改行コード
  6. 2. バックエンドから実装する (Rust)
  7. 動作確認
  8. よくあるエラーと対処法
  9. OS ごとの違いと注意点
  10. 関連レシピ

設定の保存、メモの保存、CSV の書き出しのように文字列をファイルに保存するには、File System プラグインの writeTextFile() を使います。ファイルが無ければ作り、あれば中身をすべて置き換えます。初回起動で保存先のフォルダーがまだ無い問題、BOM と改行コードの扱い、書き込み中に落ちてもファイルを壊さない保存の手順を説明します。

前提条件

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

npm run tauri add fs

書き込みの fs:allow-write-text-file は fs:default に含まれないので追加します。fs:default がアプリ用フォルダー($APPCONFIG・$APPDATA など)とその中を範囲(スコープ)として持つので、この 2 つでアプリ用フォルダーの中に書けます。フォルダーを作る mkdir() の権限は fs:default に含まれ、後述の置き換え保存で使う rename() には fs:allow-rename が要ります。

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

ドキュメントフォルダーなどアプリ用フォルダー以外に書くなら、fs:allow-write-text-file をオブジェクトの形にして allow にパスを書きます。書き方は ファイルやディレクトリを削除する の「範囲(スコープ)の決まり方」を参照してください。保存先をユーザーに選ばせるなら ファイルを保存する場所を選ばせる の方法を使い、allow は足しません。

上書きするかどうかはオプションで変わります(追記は ファイルの末尾にデータを追記する で扱います)。

オプションファイルがあるとき無いとき
指定なし中身を消して書き直す作る
createNew: trueエラーにする作る
create: false中身を消して書き直すエラーにする
append: true末尾に足す作る

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

フォルダーを作ってから書く

writeTextFile() は親フォルダーを作りません。アプリ用フォルダー自体もインストール直後には無いことが多く、そのまま書くと「failed to open file at path: 」で始まるエラーになります。mkdir() に recursive: true を付けると途中のフォルダーもまとめて作られ、既にあってもエラーになりません。

import { BaseDirectory, mkdir, writeTextFile } from '@tauri-apps/plugin-fs';

const baseDir = BaseDirectory.AppData;

// $APPDATA/notes/<name>.txt に保存する。同名があれば置き換わる
export async function saveNote(name: string, text: string): Promise<void> {
  await mkdir('notes', { baseDir, recursive: true }); // $APPDATA ごと作る。既にあれば何もしない
  await writeTextFile(`notes/${name}.txt`, text, { baseDir });
}

// 同名があれば上書きせずに失敗させる
export async function createNote(name: string, text: string): Promise<void> {
  await mkdir('notes', { baseDir, recursive: true });
  await writeTextFile(`notes/${name}.txt`, text, { baseDir, createNew: true });
}

name にユーザーの入力を使うなら、/ や .. を含まないか確かめます(パスを操作する)。

途中で失敗しても壊れない保存

writeTextFile() は先にファイルを空にしてから書きます。書いている途中でアプリが落ちると、設定ファイルが空や途中までの状態で残り、次の起動で読めなくなります。同じフォルダーの一時ファイルに書き終えてから rename() で置き換えれば、ファイルは前の内容か新しい内容のどちらかになります。rename() は置き換え先が既にあれば置き換えます(ファイル名を変更する・別の場所へ移動する)。

一時ファイルは OS の一時フォルダー(一時ファイル用フォルダパスを取得する)ではなく、保存先と同じフォルダーに置きます。ドライブが違うと rename() で置き換えられないためです。

import { BaseDirectory, mkdir, rename, writeTextFile } from '@tauri-apps/plugin-fs';
import { appConfigDir } from '@tauri-apps/api/path';

type Settings = { theme: 'light' | 'dark'; fontSize: number };

export async function saveSettings(s: Settings): Promise<void> {
  const baseDir = BaseDirectory.AppConfig;
  await mkdir(await appConfigDir(), { recursive: true }); // 初回は $APPCONFIG が無い
  await writeTextFile('settings.json.tmp', JSON.stringify(s, null, 2), { baseDir });
  // 書き終えてから置き換える(fs:allow-rename が必要)
  await rename('settings.json.tmp', 'settings.json', { oldPathBaseDir: baseDir, newPathBaseDir: baseDir });
}

読み込む側は テキストファイルを読み込む の loadSettings() です。

文字コードと改行コード

writeTextFile() は常に BOM なしの UTF-8 で書き、改行コードも変換しません。\n で組み立てた文字列は Windows でも \n のまま保存されます。

  • Excel で開く CSV: BOM なしの UTF-8 だと、日本語が文字化けして表示されることがあります。先頭に \uFEFF を付けると BOM として書かれます。
  • CRLF が必要な場合: \r\n を前提にしたツールに渡すなら、書く前に置き換えます。
  • Shift_JIS: writeTextFile() では書けません。Shift_JIS が必須なら、Rust で文字コードを変換するクレートを使います。
import { writeTextFile } from '@tauri-apps/plugin-fs';

// Excel で文字化けしない CSV(BOM 付き UTF-8・CRLF)。path は save() で選ばれたパスなど
export async function writeCsvForExcel(path: string, rows: string[][]): Promise<void> {
  const cell = (v: string) => (/[",\r\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v);
  const body = rows.map((r) => r.map(cell).join(',')).join('\r\n') + '\r\n';
  await writeTextFile(path, '\uFEFF' + body);
}

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

Rust の std::fs::write() も親フォルダーは作らず、既存のファイルは置き換えます。create_dir_all() で先に作ります。std::fs にはスコープが効かないので、JS から受け取るのはファイル名だけにして、保存先のフォルダーはコマンドの中で決めます。

次の例は、名前を確かめたうえで $APPDATA/exports に一時ファイル経由で書き、Excel 向けなら BOM と CRLF にします。相対パスのまま書くと、開発中はカレントディレクトリの src-tauri にファイルができ、tauri dev が変更を検知して再ビルドすることがあるので、保存先は必ず app.path() から作ります。

use std::path::{Component, Path};
use tauri::Manager;

/// $APPDATA/exports/<name> に保存し、保存したパスを返す
#[tauri::command]
async fn export_text(app: tauri::AppHandle, name: String, text: String, for_excel: bool) -> Result<String, String> {
    // ファイル名 1 つだけを受け付ける(`..` や絶対パスを拒否)
    let mut parts = Path::new(&name).components();
    if !matches!((parts.next(), parts.next()), (Some(Component::Normal(_)), None)) {
        return Err(format!("invalid file name: {name}"));
    }
    let dir = app.path().app_data_dir().map_err(|e| e.to_string())?.join("exports");
    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;

    let data = if for_excel {
        let mut bytes = b"\xEF\xBB\xBF".to_vec(); // UTF-8 の BOM
        bytes.extend_from_slice(text.replace("\r\n", "\n").replace('\n', "\r\n").as_bytes());
        bytes
    } else {
        text.into_bytes()
    };

    let path = dir.join(&name);
    let tmp = dir.join(format!("{name}.tmp"));
    std::fs::write(&tmp, &data).map_err(|e| format!("{}: {e}", tmp.display()))?;
    std::fs::rename(&tmp, &path).map_err(|e| format!("{}: {e}", path.display()))?; // 書き終えてから置き換える
    Ok(path.display().to_string())
}

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

const saved = await invoke<string>('export_text', {
  name: 'report.csv',
  text: '名前,点数\n佐藤,80\n',
  forExcel: true, // Rust の for_excel に渡る
});
console.log(saved);
await invoke('export_text', { name: '../x.csv', text: '', forExcel: false }).catch((e) => console.error(e));

動作確認

npm run tauri dev で起動して saveNote('memo', 'こんにちは') を呼ぶと、Windows では C:\Users\<ユーザー名>\AppData\Roaming\<identifier>\notes\memo.txt ができます。続けて createNote('memo', '別の内容') を呼ぶと失敗し、ファイルの中身は「こんにちは」のまま残ります。export_text の 1 回目は保存先のパスを出し、2 回目は「invalid file name: ../x.csv」で拒否されます。forExcel: true で書いた CSV は Excel で開いても文字化けせず、バイナリエディターで見ると先頭が EF BB BF、改行が 0D 0A です。

よくあるエラーと対処法

  • 「fs.write_text_file not allowed. Permissions associated with this command: fs:allow-app-write, …」: fs:allow-write-text-file の追加漏れです。fs:default だけでは書けません。リリースビルドでは「Command plugin:fs|write_text_file not allowed by ACL」だけになります。
  • 「fs.rename not allowed. Permissions associated with this command: fs:allow-app-write, …」: saveSettings() の置き換えに要る fs:allow-rename の追加漏れです。この場合、書き終えた settings.json.tmp が残ります。
  • 「forbidden path: <パス>, maybe it is not allowed on the scope for allow-write-text-file permission in your capability file」: 範囲の外です。baseDir を付け忘れると notes/memo.txt のような相対パスのまま判定されて、必ずこうなります。
  • 「failed to open file at path: 」で始まるエラー: 親フォルダーが無い(Windows では os error 3)、createNew: true で既にある(同 os error 80)、ほかのアプリが使用中(Excel で開いている CSV など)のいずれかです。

OS ごとの違いと注意点

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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