バイナリデータをファイルに保存する

writeFile()(権限 fs:allow-write-file)で Uint8Array を保存する。Canvas や Blob の変換、親フォルダーの作成、上書きを防ぐ createNew、Rust へバイト列で送る方法も示す。

ファイルシステム 対象: Tauri 2.x 更新日: 読了目安: 約9分 fs-004
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. フォルダーを作ってから書き込む
  4. Blob や Canvas を Uint8Array にして、上書きせずに保存する
  5. 2. バックエンドから実装する (Rust)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

描いた画像の書き出し、受け取ったファイルの保存、独自形式のデータファイルなど、文字列でないデータは File System プラグインの writeFile() に Uint8Array を渡して保存します。Blob や Canvas の内容は先に Uint8Array へ変換します。つまずきやすいのは、保存先のフォルダーが無いと失敗すること、既存のファイルを黙って上書きすること、Rust のコマンドに渡すと数値の配列に変換されて重くなることの 3 つです。文字列の保存は テキストファイルに書き込む、保存先をユーザーに選ばせるなら ファイルを保存する場所を選ばせる を使います。

前提条件

npm run tauri add fs

writeFile() の権限 fs:allow-write-file は fs:default に含まれないので追加します。テキスト用の fs:allow-write-text-file とは別の権限です。fs:default はアプリ用のフォルダー($APPDATA など)とその中身を範囲(スコープ)として持ち、フォルダーを作る mkdir() と存在を確かめる exists() も許可しているので、アプリ用フォルダーへの保存なら fs:default と fs:allow-write-file で足ります。$DOWNLOAD などほかの場所に書くときは、ファイルやディレクトリを削除する で説明している書き方で fs:allow-write-file に 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-file"
  ]
}

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

フォルダーを作ってから書き込む

アプリ用のフォルダーは最初から存在するとは限らず、無いまま書くと「failed to open file at path:」で始まるエラーになります。mkdir() に recursive: true を付けると途中のフォルダーもまとめて作られ、既にあってもエラーになりません。writeFile() は既存のファイルの中身を消してから書きます。

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

// $APPDATA/exports/<name> に保存する。フォルダーが無ければ作る
export async function saveBytes(name: string, bytes: Uint8Array): Promise<void> {
  const baseDir = BaseDirectory.AppData;
  await mkdir('exports', { baseDir, recursive: true }); // 既にあっても成功する
  await writeFile(`exports/${name}`, bytes, { baseDir }); // 同名のファイルは置き換わる
}

Blob や Canvas を Uint8Array にして、上書きせずに保存する

Blob(<input type="file"> の File も含む)は await blob.arrayBuffer() を new Uint8Array() に渡して変換し、Canvas は toBlob() を経由します。toDataURL() の文字列を writeTextFile() で書いても、Base64 の文字が並んだテキストファイルになるだけで画像にはなりません。

createNew: true を付けると、同じ名前のファイルがあるときは書き込まずに失敗します。下の例では空いている名前を探し、exists() と書き込みの間に同名のファイルが作られた場合も上書きしないようにしています。末尾に書き足すなら append: true です。

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

// canvas を PNG で保存する。同名があれば「名前 (2).png」のように番号を付ける
export async function saveCanvasPng(canvas: HTMLCanvasElement, base: string): Promise<string> {
  const blob = await new Promise<Blob>((resolve, reject) =>
    canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob に失敗しました'))), 'image/png'),
  );
  const bytes = new Uint8Array(await blob.arrayBuffer()); // Blob → Uint8Array
  const baseDir = BaseDirectory.AppData;
  await mkdir('images', { baseDir, recursive: true });
  for (let i = 1; i <= 99; i++) {
    const path = `images/${base}${i === 1 ? '' : ` (${i})`}.png`;
    if (await exists(path, { baseDir })) continue;
    await writeFile(path, bytes, { baseDir, createNew: true }); // 同名があれば失敗させる
    return path;
  }
  throw new Error('空いているファイル名がありません');
}

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

Rust で保存したいとき、コマンドの引数を data: Vec<u8> にすると、JS の Uint8Array は数値の配列(JSON)に変換されて送られます。1 バイトが「255,」のような最大 4 文字になるので、10 MB の画像なら 30 MB を超える文字列を作って解析することになり、目に見えて遅くなります。invoke() の第 2 引数に Uint8Array そのものを渡すと、変換されずにバイト列のまま届きます。Rust では tauri::ipc::Request で受け取ります。

このときほかの引数は渡せないので、ファイル名などはヘッダーで送ります。ヘッダーの値に使えるのは ASCII の文字だけで、日本語を入れると invoke() が TypeError で失敗します。例では英数字と . _ - だけを許し、../ のようなパスの混入も同時に防いでいます。

use std::borrow::Cow;
use tauri::ipc::{InvokeBody, Request};
use tauri::Manager;

/// バイト列を受け取り、$APPDATA/images/<x-file-name> に保存して保存先を返す
#[tauri::command]
async fn save_image(app: tauri::AppHandle, request: Request<'_>) -> Result<String, String> {
    let name = request
        .headers()
        .get("x-file-name")
        .and_then(|v| v.to_str().ok())
        .filter(|n| !n.starts_with('.') && n.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)))
        .filter(|n| !n.is_empty())
        .ok_or("invalid x-file-name header")?
        .to_string();
    let bytes: Cow<[u8]> = match request.body() {
        InvokeBody::Raw(bytes) => Cow::Borrowed(bytes.as_slice()),
        // Android ではバイト列のまま送れず、数値の配列として届く
        InvokeBody::Json(serde_json::Value::Array(items)) => {
            Cow::Owned(items.iter().filter_map(|v| v.as_u64()).map(|v| v as u8).collect())
        }
        _ => return Err("expected a binary body".into()),
    };
    let dir = app.path().app_data_dir().map_err(|e| e.to_string())?.join("images");
    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
    let path = dir.join(&name);
    std::fs::write(&path, &bytes).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![save_image])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';

export async function saveImageViaRust(bytes: Uint8Array, name: string): Promise<string> {
  // 第 2 引数に Uint8Array を直接渡すと、数値の配列に変換されずに送られる
  return invoke<string>('save_image', bytes, { headers: { 'x-file-name': name } });
}

std::fs には fs プラグインの範囲が効かないので、保存先はコマンドの側で決めます。JS から fs プラグインの writeFile() で書く場合は、もともとバイト列のまま送られるので、この変換の問題は起きません。Rust から JS へ返す向きの注意は 画像などのバイナリファイルを読み込む で扱います。

動作確認

npm run tauri dev で起動し、saveBytes('hello.bin', new Uint8Array([0x48, 0x69])) を呼ぶと、Windows なら C:\Users\<ユーザー名>\AppData\Roaming\<identifier>\exports\hello.bin に 2 バイトのファイルができます。saveCanvasPng(canvas, 'drawing') を 2 回呼ぶと、戻り値は次のようになります。

images/drawing.png
images/drawing (2).png

saveImageViaRust(bytes, '../x.png') は「invalid x-file-name header」で拒否されます。

よくあるエラーと対処法

  • 「fs.write_file not allowed. Permissions associated with this command:」の後に候補が並ぶ: fs:allow-write-file の追加漏れです。リリースビルドでは「Command plugin:fs|write_file not allowed by ACL」だけになります。追加して tauri dev を再起動します。
  • 「failed to open file at path: 」で始まるエラー: 保存先のフォルダーが無い、createNew で同名のファイルがある、書き込みが禁止されている、のいずれかです。続く OS のメッセージで見分けます。
  • 「forbidden path: 」で始まるエラー: 書ける範囲の外です。デバッグビルドでは後ろに「maybe it is not allowed on the scope for allow-write-file permission in your capability file」が付きます。
  • 「invalid args name for command save_image: command save_image expected a value for key name but the IPC call used a bytes payload」: バイト列で呼ぶコマンドに、Request 以外の引数(ここでは name)を持たせています。ヘッダーで渡します。

OS ごとの違いと注意点

  • Android: invoke() でバイト列のまま送る方法は使えず、数値の配列として届きます。例の save_image は両方を受け付けています。
  • Windows: writeFile() の mode(作成時のパーミッション)は無視されます。macOS・Linux では mode: 0o600 のように指定できます。
  • 書き込み中の失敗: writeFile() は中身を消してから書くので、途中で失敗すると中途半端なファイルが残ります。大事なファイルは ファイルを保存する場所を選ばせる の Rust の例のように、一時ファイルに書いてから置き換えます。
  • 大きなデータ: writeFile() には ReadableStream<Uint8Array> も渡せて少しずつ書けますが、チャンクごとに数値の配列として送られるので速くはなりません。読む側を少しずつにする方法は 巨大なファイルを少しずつ読み込む を参照してください。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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