画像・音声・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-filepermission 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 プロトコルか 巨大なファイルを少しずつ読み込む の方法にします。
