テキストファイルを読み込む

fs プラグインの readTextFile()(権限は fs:default に含まれる)で文字列を読む。Shift_JIS を読む encoding、BOM と改行コードの扱い、初回起動でファイルが無いときの書き方も示す。

ファイルシステム 対象: Tauri 2.x 更新日: 読了目安: 約9分 fs-001
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 設定ファイルを読む(無ければ既定値)
  4. 文字コードを指定する(Shift_JIS など)
  5. BOM と改行コード
  6. 2. バックエンドから実装する (Rust)
  7. 動作確認
  8. よくあるエラーと対処法
  9. OS ごとの違いと注意点
  10. 関連レシピ

設定の JSON、ユーザーが選んだメモや CSV など、テキストファイルの中身を文字列で受け取るには File System プラグインの readTextFile() を使います。自分のアプリ用フォルダーの中なら fs:default だけで読め、1 行ずつ読む readTextFileLines() もあります。つまずきやすいのは文字コードで、既定は UTF-8 のため Shift_JIS のファイルは文字化けします。BOM と改行コードの扱い、初回起動でファイルがまだ無いときの書き方もあわせて説明します。

前提条件

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

npm run tauri add fs

このレシピで使う readTextFile()・readTextFileLines()・readFile()・exists() の権限は、どれも fs:default に含まれます。fs:default はアプリ用の 5 つのフォルダー($APPCONFIG・$APPDATA など)とその中を読める範囲(スコープ)として持つので、アプリの設定やデータを読むだけなら追加は要りません。

それ以外の場所は、次のように allow でパスを足します(例はドキュメントの MyApp フォルダー)。この allow は readTextFile() にだけ効くので、exists() なども使うならその権限にも書きます。変数、* と ** の違い、deny の書き方は ファイルやディレクトリを削除する の「範囲(スコープ)の決まり方」にまとめています。

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

ユーザーがダイアログで選んだファイルは、その起動中だけ自動で範囲に加わるので allow は不要です(ファイルを開くダイアログを表示する)。アプリに同梱したテンプレートなどは $RESOURCE で読みます(画像やDBファイルを配布物に同梱する)。

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

設定ファイルを読む(無ければ既定値)

baseDir に BaseDirectory を渡すと、パスはそのフォルダーからの相対パスになります。初回起動ではファイルどころかアプリ用フォルダーもまだ無いので、exists() で確かめてから読みます。確かめずに読むと「failed to open file at path: 」で始まるエラーになります。

import { BaseDirectory, exists, readTextFile } from '@tauri-apps/plugin-fs';

type Settings = { theme: 'light' | 'dark'; fontSize: number };
const DEFAULTS: Settings = { theme: 'light', fontSize: 14 };

export async function loadSettings(): Promise<Settings> {
  const opts = { baseDir: BaseDirectory.AppConfig };
  if (!(await exists('settings.json', opts))) return { ...DEFAULTS }; // 初回起動
  const text = await readTextFile('settings.json', opts);
  try {
    // 足りない項目は既定値で埋める(古い版で保存したファイルにも対応)
    return { ...DEFAULTS, ...(JSON.parse(text) as Partial<Settings>) };
  } catch {
    console.warn('settings.json が壊れているため既定値を使います');
    return { ...DEFAULTS };
  }
}

保存する側は テキストファイルに書き込む の saveSettings() です。

文字コードを指定する(Shift_JIS など)

既定では UTF-8 として読みます。fs プラグイン 2.5.0 以降は encoding に TextDecoder と同じ名前('shift_jis'・'euc-jp'・'utf-16le' など)を渡せます。Excel で「CSV(コンマ区切り)」として保存したファイルや、古いアプリが書き出したファイルは Shift_JIS のことがあります。

UTF-8 として正しくないバイトはエラーにならず、「�」に置き換わって返ってきます。文字コードが分からないファイルは readFile() でバイト列のまま読み、fatal: true の TextDecoder で UTF-8 として試してから Shift_JIS に切り替えます。2.5.0 より前のプラグインでもこの方法で読めます。

import { readFile, readTextFile } from '@tauri-apps/plugin-fs';

// 文字コードが決まっているなら encoding を渡すだけ(fs プラグイン 2.5.0 以降)
export async function readSjis(path: string): Promise<string> {
  return readTextFile(path, { encoding: 'shift_jis' });
}

// UTF-8 として読めなければ Shift_JIS とみなす
export async function readAuto(path: string): Promise<{ text: string; encoding: string }> {
  const bytes = await readFile(path); // fs:allow-read-file(fs:default に含まれる)
  try {
    return { text: new TextDecoder('utf-8', { fatal: true }).decode(bytes), encoding: 'utf-8' };
  } catch {
    return { text: new TextDecoder('shift_jis').decode(bytes), encoding: 'shift_jis' };
  }
}

BOM と改行コード

  • BOM: ファイル先頭の BOM(UTF-8 なら EF BB BF)は取り除かれるので、そのまま JSON.parse() できます(Rust では残ります。2 章を参照)。
  • 改行コード: 変換されません。Windows で作られたファイルは \r\n のまま届くので、行に分けるときは split(/\r?\n/) にします。split('\n') だと各行の末尾に \r が残り、文字列の比較や CSV の最後の列が合わなくなります。
  • 1 行ずつ読む: readTextFileLines() は行末の \n と \r\n をどちらも取り除いて 1 行ずつ返します。ログや JSON Lines(1 行 1 件の JSON)に向いていて、ファイルの末尾にデータを追記する で書いた履歴は次のように読めます。
import { BaseDirectory, readTextFileLines } from '@tauri-apps/plugin-fs';

// 1 行 1 件の JSON を読む。壊れた行は飛ばす
export async function readHistory(): Promise<unknown[]> {
  const lines = await readTextFileLines('history.jsonl', { baseDir: BaseDirectory.AppData });
  const items: unknown[] = [];
  for await (const line of lines) {
    if (!line.trim()) continue;
    try {
      items.push(JSON.parse(line));
    } catch {
      // 書き込みの途中で終了した最後の行などは読み飛ばす
    }
  }
  return items;
}

readTextFile() はファイル全体を一度に受け取ります。数百 MB のファイルは 巨大なファイルを少しずつ読み込む の方法で読みます。

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

Rust の std::fs::read_to_string() は、UTF-8 として正しくないバイトがあると「stream did not contain valid UTF-8」で失敗します(JS のように置き換えません)。BOM は文字 \u{feff} として残り、そのまま serde_json に渡すと「expected value at line 1 column 1」になるので、先頭から取り除きます。

std::fs にはスコープが効きません。JS からパスを受け取らず、読むファイルはコマンドの中で決めます(受け取る場合の確かめ方は パスを操作する)。また async を付けないコマンドはメインスレッドで動くので、ファイルを読むコマンドは async fn にしておきます。

use std::io::ErrorKind;
use tauri::Manager;

/// $APPCONFIG/settings.json を JSON として読む。無ければ None(初回起動)
#[tauri::command]
async fn load_settings(app: tauri::AppHandle) -> Result<Option<serde_json::Value>, String> {
    let path = app.path().app_config_dir().map_err(|e| e.to_string())?.join("settings.json");
    let text = match std::fs::read_to_string(&path) {
        Ok(text) => text,
        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
        // UTF-8 として正しくないファイルもここに来る
        Err(e) => return Err(format!("{}: {e}", path.display())),
    };
    let body = text.strip_prefix('\u{feff}').unwrap_or(&text); // BOM を取り除く
    serde_json::from_str(body).map(Some).map_err(|e| format!("{}: {e}", path.display()))
}

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

const settings = await invoke<Record<string, unknown> | null>('load_settings');
console.log(settings ?? '設定ファイルはまだありません');

動作確認

npm run tauri dev で起動し、loadSettings() を呼ぶと初回は既定値が返ります。Shift_JIS の CSV を readTextFile() だけで読むと日本語が「�」だらけになり、readAuto() では正しく読めます。JSON.stringify() で出すと次のようになります。

{"theme":"light","fontSize":14}
{"text":"名前,点数\r\n佐藤,80\r\n","encoding":"shift_jis"}

よくあるエラーと対処法

  • 「fs.read_text_file not allowed. Permissions associated with this command: fs:allow-app-read, …」: fs:default も fs:allow-read-text-file もありません。リリースビルドでは「Command plugin:fs|read_text_file not allowed by ACL」だけになります。
  • 「forbidden path: <パス>, maybe it is not allowed on the scope for allow-read-text-file permission in your capability file」: 範囲の外です。baseDir を付け忘れると相対パスのまま判定され、必ずこうなります。以前ダイアログで選んだパスを再起動後に読んだ場合も同じです。リリースビルドでは「forbidden path: <パス>」だけになります。
  • 「failed to open file at path: 」で始まるエラー: ファイルがありません(Windows ならファイルが無いと os error 2、フォルダーごと無いと os error 3)。exists() で分岐します。
  • 「cannot traverse directory, rewrite the path without the use of ../」を含むエラー: fs プラグインは .. を含むパスを受け付けません。
  • 日本語が「�」になる: 文字コードが違います。encoding を指定するか readAuto() で読みます。encoding に知らない名前を渡すと RangeError になります。

OS ごとの違いと注意点

  • Windows / macOS: $APPCONFIG と $APPDATA は同じフォルダーです。Linux だけは ~/.config/<identifier> と ~/.local/share/<identifier> に分かれるので、Linux でだけ見つからないときは保存と読み込みの baseDir の取り違えを疑います(アプリの設定保存フォルダパスを取得する)。
  • macOS / Linux: 範囲の * と ** は . で始まる名前に一致しません。.env のようなファイルを読むなら範囲に . から書きます。
  • Android: ダイアログで選んだファイルは content:// で始まる URI で返ります。readTextFile() にはそのまま渡せます。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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