操作の履歴、計測値の記録、CSV への 1 行追加のように、今の内容を残したまま末尾に書き足すには、writeTextFile()(バイト列なら writeFile())に append: true を渡します。Rust では OpenOptions の append(true) です。仕組みは単純ですが、改行の付け方で行がつながる、見出しや BOM が何度も入る、続けて呼ぶと順番が入れ替わる、といった落とし穴があります。アプリ自身の動作ログなら、レベル分けや古いログの整理までできる Log プラグインでログファイルを出力する の方が向いています。
前提条件
fs プラグインを追加します。
npm run tauri add fs
追記専用の権限はなく、上書きと同じ fs:allow-write-text-file(writeFile() なら fs:allow-write-file)を使います。どちらも fs:default に含まれないので追加します。「追記だけ許して上書きは禁止」という分け方はできません。書ける範囲(スコープ)も上書きと同じで、fs:default があればアプリ用フォルダーの中に書けます。それ以外の場所に書くときの allow の書き方は ファイルやディレクトリを削除する の「範囲(スコープ)の決まり方」を参照してください。
{
"$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"
]
}
上書き(既定)との違いは次のとおりです。上書きのオプションは テキストファイルに書き込む にまとめています。
| 上書き(既定) | append: true | |
|---|---|---|
| 今の内容 | 消える | 残る |
| ファイルが無いとき | 作る | 作る(create: false ならエラー) |
| 書き込みの途中で落ちたとき | 空や途中までの内容になる | それまでの内容は残り、最後の 1 行だけが途中で切れる |
1. フロントエンドから実装する (TypeScript)
1 件 1 行で追記する
改行は自動では付きません。1 件ごとに末尾へ \n を付けて書き、先頭には付けません。こうするとファイルは常に改行で終わり、次の追記が前の行につながりません。先頭に付ける書き方だと、1 行目が空行になります。値の中の改行は 1 件が 2 行に割れる原因になるので、JSON にする(改行が \n という 2 文字になる)か空白に置き換えます。
親フォルダーは自動では作られないので、最初の 1 回だけ mkdir() で作ります。
import { BaseDirectory, mkdir, writeTextFile } from '@tauri-apps/plugin-fs';
import { appDataDir } from '@tauri-apps/api/path';
let dirReady: Promise<void> | null = null;
// 1 行 1 件の JSON(JSON Lines)で履歴を足していく
export async function appendHistory(action: string, detail: Record<string, unknown> = {}): Promise<void> {
dirReady ??= appDataDir().then((dir) => mkdir(dir, { recursive: true })); // 初回だけフォルダーを作る
await dirReady;
const line = JSON.stringify({ at: new Date().toISOString(), action, ...detail }) + '\n'; // 改行は末尾に 1 つ
await writeTextFile('history.jsonl', line, { baseDir: BaseDirectory.AppData, append: true });
}
書いた履歴は テキストファイルを読み込む の readHistory() で 1 行ずつ読めます。
順番を守り、BOM と見出しは最初の 1 回だけ書く
writeTextFile() は呼ぶたびに Rust 側で別々に処理されるので、await せずに続けて呼ぶと、書き込まれる順番は保証されません。ボタンの連打やイベントのたびに追記するなら、前の書き込みが終わってから次を書く列(キュー)を通します。
Excel で開く CSV では、BOM(\uFEFF)と見出し行はファイルを新しく作るときに 1 回だけ書きます。追記のたびに付けると、2 行目以降の先頭に見えない文字が入り、読み込んだときに値が一致しなくなります。改行コードも、同じファイルの中では揃えます(ここでは CSV の決まりに合わせて \r\n)。
import { BaseDirectory, exists, mkdir, writeTextFile } from '@tauri-apps/plugin-fs';
const baseDir = BaseDirectory.AppData;
const FILE = 'records/measure.csv';
let queue: Promise<void> = Promise.resolve();
const cell = (v: string | number) => {
const s = String(v);
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
// 1 行ずつ順番に追記する。ファイルが無いときだけ BOM と見出しを書く
export function appendRecord(item: string, value: number): Promise<void> {
const task = queue.then(async () => {
if (!(await exists(FILE, { baseDir }))) {
await mkdir('records', { baseDir, recursive: true });
await writeTextFile(FILE, '\uFEFF日時,項目,値\r\n', { baseDir }); // BOM は先頭に 1 回だけ
}
const line = [new Date().toLocaleString('ja-JP'), item, value].map(cell).join(',') + '\r\n';
await writeTextFile(FILE, line, { baseDir, append: true });
});
queue = task.catch(() => undefined); // 1 回失敗しても後ろの書き込みは続ける
return task;
}
// await しなくても、この順に書かれる
void appendRecord('温度', 21.5);
void appendRecord('湿度', 48);
writeTextFile() は呼ぶたびにファイルを開いて閉じます。1 秒に何十回も追記するなら、配列にためて数秒ごとに 1 回でまとめて書くか、次の Rust のコマンドにまとめます。ためている間にアプリが終了すると、その分は失われます。
2. バックエンドから実装する (Rust)
OpenOptions::new().create(true).append(true) で開いて write_all() で書きます。writeln! が足す改行は、どの OS でも \n です(Windows でも \r\n にはなりません)。メインスレッドを止めないよう async のコマンドにすると、呼び出しが同時に動くことがあるので、Mutex で 1 件ずつ書きます。
次の例は、履歴が 1 MB を超えたら history.1.jsonl に名前を変えて新しいファイルに切り替える、2 世代だけ残す簡単なローテーションも入れています。追記はファイルの大きさに関係なく速いままですが、そのままでは際限なく大きくなるためです。
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::{Manager, State};
const MAX_BYTES: u64 = 1024 * 1024; // 1 MB を超えたら切り替える
struct HistoryFile {
path: PathBuf,
lock: Mutex<()>, // 追記と切り替えを 1 件ずつ行う
}
#[tauri::command]
async fn append_history_rs(state: State<'_, HistoryFile>, entry: serde_json::Value) -> Result<(), String> {
let _guard = state.lock.lock().map_err(|e| e.to_string())?;
let size = std::fs::metadata(&state.path).map(|m| m.len()).unwrap_or(0);
if size > MAX_BYTES {
// 古い方へ回す(前の history.1.jsonl は置き換わる)
std::fs::rename(&state.path, state.path.with_extension("1.jsonl")).map_err(|e| e.to_string())?;
}
let mut line = entry.to_string(); // 1 行の JSON。値の中の改行は \n にエスケープされる
line.push('\n');
let mut file = OpenOptions::new()
.create(true) // 無ければ作る
.append(true) // 常に末尾に書く
.open(&state.path)
.map_err(|e| format!("{}: {e}", state.path.display()))?;
file.write_all(line.as_bytes()).map_err(|e| e.to_string())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.setup(|app| {
let dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&dir)?; // 初回はフォルダーが無い
app.manage(HistoryFile { path: dir.join("history.jsonl"), lock: Mutex::new(()) });
Ok(())
})
.invoke_handler(tauri::generate_handler![append_history_rs])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';
await invoke('append_history_rs', { entry: { action: 'open', file: 'memo.txt' } });
同じファイルに JS と Rust の両方から追記すると、キューと Mutex が別々なので順番が守られません。書く側はどちらか一方にします。
動作確認
npm run tauri dev で起動し、appendRecord() を何度か呼んでから $APPDATA/records/measure.csv を開くと、見出しの下に呼んだ順で行が並びます(BOM は表示されません)。Excel で開いても文字化けしません。
日時,項目,値
2026/9/12 10:15:02,温度,21.5
2026/9/12 10:15:02,湿度,48
appendHistory('open', { file: 'memo.txt' }) を 2 回呼ぶと、history.jsonl に 2 行増えます。
よくあるエラーと対処法
- 「fs.write_text_file not allowed. Permissions associated with this command: fs:allow-app-write, …」:
fs:allow-write-text-fileの追加漏れです。appendを付けても権限は同じです。リリースビルドでは「Command plugin:fs|write_text_file not allowed by ACL」だけになります。 - 「failed to open file at path: 」で始まるエラー: 親フォルダーが無い、
create: falseでファイルが無い、ほかのアプリが使用中(Excel で開いている CSV など)のいずれかです。 - 前の行とつながる・空行が入る: 改行を先頭に付けているか、付け忘れています。1 件ごとに末尾に 1 つ付けます。
- 見出しが何度も入る・値の先頭に見えない文字が付く: BOM と見出しを追記のたびに書いています。ファイルが無いときだけ書きます。
- 行の順番が入れ替わる:
awaitせずに続けて呼んでいます。キューを通すか、awaitしてから次を呼びます。
OS ごとの違いと注意点
- Windows / macOS:
$APPDATAは$APPCONFIGと同じフォルダーです。設定ファイルと同じ名前を付けないようにします。場所は アプリ専用のデータ保存フォルダパスを取得する で確かめられます。 - macOS / Linux:
modeオプションは新しく作るファイルのアクセス権にだけ使われます。既にあるファイルに追記するときは変わりません。Windows では無視されます。 - 共通: ダイアログで選ばせたファイルに追記するなら、書けるのはその起動中だけです(ファイルを保存する場所を選ばせる)。毎回同じファイルに書き足すログは、アプリ用フォルダーに置きます。
