ファイルの末尾にデータを追記する

writeTextFile() に append: true を渡して末尾に足す(権限は上書きと同じ fs:allow-write-text-file)。改行の付け方、BOM と見出しを最初の 1 回だけ書く方法、順番の守り方も示す。

ファイルシステム 対象: Tauri 2.x 更新日: 読了目安: 約9分 fs-005
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 1 件 1 行で追記する
  4. 順番を守り、BOM と見出しは最初の 1 回だけ書く
  5. 2. バックエンドから実装する (Rust)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

操作の履歴、計測値の記録、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 では無視されます。
  • 共通: ダイアログで選ばせたファイルに追記するなら、書けるのはその起動中だけです(ファイルを保存する場所を選ばせる)。毎回同じファイルに書き足すログは、アプリ用フォルダーに置きます。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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