設定の保存、メモの保存、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-filepermission 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 ごとの違いと注意点
- Windows / macOS:
$APPCONFIGと$APPDATAは同じフォルダーを指します。両方に同じ名前のファイルを置く設計にすると、Linux 以外では 1 つのファイルを取り合います(アプリ専用のデータ保存フォルダパスを取得する・アプリの設定保存フォルダパスを取得する)。 - macOS / Linux:
modeオプションで、新しく作るファイルのアクセス権(0o600なら本人だけが読み書き)を指定できます。Windows では無視されます。 - 共通:
writeTextFile()は文字列全体を一度に Rust 側へ送ります。数十 MB を超える書き出しは、Rust のコマンドの中で内容を作って書く方が無駄がありません。画像などのバイト列は バイナリデータをファイルに保存する で扱います。
