画像などのバイナリファイルを読み込む

readFile()(権限 fs:allow-read-file)で画像や PDF を Uint8Array として読み、Blob URL で表示する。Rust から ArrayBuffer で返す方法と asset プロトコルも示す。

ファイルシステム 対象: Tauri 2.x 更新日: 読了目安: 約10分 fs-003
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 読み込んで <img> に表示する
  4. バイト列を数値として読む(DataView)
  5. 表示するだけなら asset プロトコル
  6. 2. バックエンドから実装する (Rust)
  7. 動作確認
  8. よくあるエラーと対処法
  9. OS ごとの違いと注意点
  10. 関連レシピ

画像・音声・PDF や独自形式のデータファイルは、File System プラグインの readFile() で Uint8Array(バイト列)として読み込みます。画面に出すなら Blob にして Object URL を作り、中身を調べるなら DataView で数値として取り出します。つまずきやすいのは受け渡しの形で、Rust のコマンドから Vec<u8> を返すと JS には数値の配列が届き、そのままでは画像になりません。文字列として読むなら テキストファイルを読み込む、数百 MB を超えるファイルは 巨大なファイルを少しずつ読み込む の方法を使います。

前提条件

npm run tauri add fs

readFile() の権限 fs:allow-read-file は fs:default に含まれ、fs:default はアプリ用のフォルダー($APPDATA など)を読める範囲(スコープ)として持っています。アプリに同梱したファイルがある $RESOURCE は範囲外なので、読み取り系の権限とこの範囲をまとめた fs:allow-resource-read-recursive を足します。ほかの場所を許可する書き方は ファイルやディレクトリを削除する の「範囲(スコープ)の決まり方」にまとめています。ダイアログで選ばれたファイルは、その起動中は範囲を書かなくても読めます(ファイルを開くダイアログを表示する)。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "fs:default",
    "fs:allow-resource-read-recursive"
  ]
}

同梱する画像は tauri.conf.json の bundle.resources に登録します。src-tauri/assets/logo.png は $RESOURCE/assets/logo.png に置かれ、tauri dev でも同じ相対パスで読めます。

{
  "bundle": {
    "resources": ["assets/*"]
  }
}

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

読み込んで <img> に表示する

Blob の type(MIME タイプ)は、拡張子ではなく先頭のバイト列(マジックナンバー)で決めると、拡張子だけ付け替えたファイルを弾けます。Object URL は URL.revokeObjectURL() を呼ぶまで解放されないので、画像を差し替えるたびに前の URL を解放します。

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

// 先頭のバイト列(マジックナンバー)から画像の種類を判定する
function sniffImageType(b: Uint8Array): string | null {
  const starts = (...sig: number[]) => sig.every((v, i) => b[i] === v);
  if (starts(0x89, 0x50, 0x4e, 0x47)) return 'image/png';
  if (starts(0xff, 0xd8, 0xff)) return 'image/jpeg';
  if (starts(0x47, 0x49, 0x46, 0x38)) return 'image/gif';
  if (starts(0x52, 0x49, 0x46, 0x46) && String.fromCharCode(...b.subarray(8, 12)) === 'WEBP') return 'image/webp';
  return null;
}

let currentUrl: string | null = null;

export async function showImage(img: HTMLImageElement, path: string, baseDir?: BaseDirectory) {
  const bytes = await readFile(path, { baseDir }); // Uint8Array<ArrayBuffer>
  const type = sniffImageType(bytes);
  if (type === null) throw new Error(`対応していない形式です: ${path}`);
  if (currentUrl) URL.revokeObjectURL(currentUrl); // 前の画像の URL を解放する
  currentUrl = URL.createObjectURL(new Blob([bytes], { type }));
  img.src = currentUrl;
}

同梱画像なら showImage(img, 'assets/logo.png', BaseDirectory.Resource)、アプリが保存した画像なら BaseDirectory.AppData を渡します。

バイト列を数値として読む(DataView)

PNG の幅と高さは、16 バイト目から 4 バイトずつ、上位のバイトが先の順(ビッグエンディアン)で入っています。DataView を作るときは byteOffset と byteLength を必ず渡します。subarray() で切り出した Uint8Array の .buffer は元の配列全体を指すため、省くと別の位置を読んでしまいます。

// PNG の幅と高さを読む
export function pngSize(bytes: Uint8Array): { width: number; height: number } {
  if (bytes.byteLength < 24) throw new Error('PNG としては短すぎます');
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); // 位置と長さも渡す
  return { width: view.getUint32(16), height: view.getUint32(20) }; // 既定がビッグエンディアン
}

// Blob に渡す自作の関数は、引数を Uint8Array<ArrayBuffer> 型にする
export function toObjectUrl(bytes: Uint8Array<ArrayBuffer>, type: string): string {
  return URL.createObjectURL(new Blob([bytes], { type }));
}

最近の TypeScript では、引数を単に Uint8Array 型にして new Blob([bytes]) に渡すと「Type 'Uint8Array<ArrayBufferLike>' is not assignable to type 'BlobPart'」という型エラーになります。readFile() の戻り値は Uint8Array<ArrayBuffer> 型なのでそのまま渡せますが、自作の関数を挟むときは上のように型を合わせます。

表示するだけなら asset プロトコル

readFile() はファイル全体を JS まで運ぶので、大きな画像や動画を表示するだけなら、ファイルを URL で直接読み込ませる asset プロトコルの方が軽く済みます。tauri.conf.json で有効にし、読ませる範囲を scope に書きます。この範囲は fs プラグインの権限とは別に判定されます(ダイアログの open() で選ばれたファイルは、こちらにも自動で加わります)。有効にすると、tauri dev / tauri build が Cargo.toml の tauri に protocol-asset 機能を足します。

{
  "app": {
    "security": {
      "assetProtocol": {
        "enable": true,
        "scope": ["$APPDATA/images/**"]
      }
    }
  }
}
import { convertFileSrc } from '@tauri-apps/api/core';
import { appDataDir, join } from '@tauri-apps/api/path';

// $APPDATA/images/<name> を URL で表示する(バイト列は JS を通らない)
export async function showByAssetUrl(img: HTMLImageElement, name: string) {
  const path = await join(await appDataDir(), 'images', name); // 絶対パスを渡す
  img.src = convertFileSrc(path);
}

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

コマンドの戻り値を Vec<u8> にすると JSON の数値の配列として送られ、JS では number[] になります。文字数にするとファイルの 3〜4 倍に膨らみ、new Blob([値]) に渡しても画像になりません。tauri::ipc::Response で包むとバイト列のまま送られ、JS では ArrayBuffer として受け取れます。

Rust の std::fs には fs プラグインの範囲が効かないので、JS からはファイル名だけを受け取り、読んでよいフォルダーの下でパスを組み立てます。同期のコマンドはメインスレッドで動き、大きなファイルを読む間ウィンドウが固まるので async にします。読み込みに app.fs().read() を使うと、同梱ファイルが通常のパスに無い Android でも同じコードで読めます。

use std::path::{Component, Path};
use tauri::ipc::Response;
use tauri::path::BaseDirectory;
use tauri::Manager;
use tauri_plugin_fs::FsExt;

/// 同梱した assets フォルダーの画像を読み、ArrayBuffer として返す
#[tauri::command]
async fn read_bundled_image(app: tauri::AppHandle, name: String) -> Result<Response, String> {
    // 「logo.png」のようなファイル名 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 path = app
        .path()
        .resolve(format!("assets/{name}"), BaseDirectory::Resource)
        .map_err(|e| e.to_string())?;
    let bytes = app.fs().read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
    Ok(Response::new(bytes)) // Vec<u8> のまま返すと JS では number[] になる
}

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

const buf = await invoke<ArrayBuffer>('read_bundled_image', { name: 'logo.png' });
const img = document.querySelector<HTMLImageElement>('#logo');
if (img) img.src = URL.createObjectURL(new Blob([buf], { type: 'image/png' }));

逆向き(JS から Rust へバイト列を送る)の注意は バイナリデータをファイルに保存する で扱います。

動作確認

src-tauri/assets/logo.png を置いて npm run tauri dev で起動し、DevTools のコンソールで次を実行します。

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

const bytes = await readFile('assets/logo.png', { baseDir: BaseDirectory.Resource });
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
console.log(bytes.byteLength, bytes[1] === 0x50 ? 'PNG' : '?', view.getUint32(16), view.getUint32(20));

512 x 512 の PNG なら、バイト数(ファイルによって変わります)に続いて次のように出ます。showImage() を呼ぶと <img> に同じ画像が表示されます。

20480 PNG 512 512

よくあるエラーと対処法

  • 「forbidden path: 」で始まるエラー: 読める範囲の外です。デバッグビルドでは後ろに「maybe it is not allowed on the scope for allow-read-file permission in your capability file」が付きます。同梱ファイルなら fs:allow-resource-read-recursive の追加漏れか baseDir の指定漏れです。
  • 「failed to open file at path: 」で始まるエラー: 範囲には入っていますが、ファイルが無いか OS に拒否されました。同梱ファイルなら bundle.resources への登録漏れか、assets/ を含めたパスの誤りです。
  • 「fs.read_file not allowed. Permissions associated with this command:」の後に候補が並ぶ: fs:default(または fs:allow-read-file)がありません。リリースビルドでは「Command plugin:fs|read_file not allowed by ACL」だけになります。
  • 画像が壊れたアイコンになる: Rust から Vec<u8> のまま返しているか、Blob の type が中身と合っていません。asset プロトコルなら scope と CSP を見直します。

OS ごとの違いと注意点

  • asset プロトコルの URL: Windows と Android では http://asset.localhost/…、macOS・Linux・iOS では asset://localhost/… になります。app.security.csp を設定しているなら、両方を img-src に加えます。文字列を自分で組み立てず convertFileSrc() を使います。
  • Android: 同梱ファイルは APK の中にあり、通常のパスとしては開けません。Rust で読むなら例のように std::fs ではなく app.fs().read() を使います。ダイアログで選ばれたファイルは content:// で始まる URI で返り、readFile() にはそのまま渡せます。
  • メモリ: readFile() は全体を読み終えるまで何も返さず、同じ大きさのデータが Rust 側と JS 側に一時的に並びます。動画のような大きなファイルは、asset プロトコルか 巨大なファイルを少しずつ読み込む の方法にします。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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