ファイルをダウンロードして保存する

http プラグインの fetch() で受けた本文を fs プラグインで少しずつ書き込み、進捗を出す。大きなファイル向けの Upload プラグイン download() と Rust の reqwest、保存先の権限も示す。

通信 対象: Tauri 2.x 更新日: 読了目安: 約12分 net-005
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 本文を少しずつ書き込み、進捗を出す
  4. 大きなファイルは Upload プラグインの download() に任せる
  5. 2. バックエンドから実装する (Rust)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

画像や追加データ、サーバーで書き出した ZIP などを取ってきて保存する処理は、「受信」と「書き込み」の組み合わせです。Tauri では受信を http プラグインの fetch()、書き込みを fs プラグインで行うのが基本で、本文をチャンク(届いた断片)ごとに書き込めば、ファイル全体をメモリに載せずに進捗も出せます。数百 MB を超えるような大きなファイルは、受信から保存までを Rust 側で済ませる Upload プラグインの download() か、自作の Rust コマンドが向いています。

方法向いている場面進捗中断保存先の制限
fetch() + fs の open() / write()小〜中くらいのファイル○signal で可fs のスコープ
Upload プラグインの download()大きなファイルを手軽に○不可無し
Rust の reqwest大きなファイル、細かい制御○(Channel)自作コマンドで決める

前提条件

npm run tauri add http
npm run tauri add fs
# download() を使う場合
npm run tauri add upload

http プラグインの fetch() は、capability に書いた URL にしか送れません。許可リストの書き方と WebView 標準の fetch との違いは HTTP GET リクエストを送る(Rust経由) にまとめています。

書き込みに使う fs:allow-write-file は fs:default に含まれず、open()・write()・writeFile() をまとめて許可します。どこに書けるかは allow の path(スコープ)で決まり、下の例ではダウンロードフォルダー($DOWNLOAD)の下を許しています。失敗時に書きかけを消す fs:allow-remove も同じ範囲にします。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    {
      "identifier": "http:default",
      "allow": [{ "url": "https://example.com/files/*" }]
    },
    {
      "identifier": "fs:allow-write-file",
      "allow": [{ "path": "$DOWNLOAD/**" }]
    },
    {
      "identifier": "fs:allow-remove",
      "allow": [{ "path": "$DOWNLOAD/**" }]
    },
    "upload:allow-download"
  ]
}

保存先をユーザーに選ばせるなら、ファイルを保存する場所を選ばせる の save() が返したパスをそのまま使います。選ばれたファイル 1 つは起動中だけ自動で書き込めるので、path の追加は要りません(再起動すると許可は消えます)。* と ** の違いや deny の使い方は ファイルやディレクトリを削除する を参照してください。

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

本文を少しずつ書き込み、進捗を出す

response.arrayBuffer() で全体を受けてから writeFile() する書き方は、ファイル全体がメモリに載ります。response.body から読んだチャンクをそのまま FileHandle.write() に渡せば、メモリに置くのは 1 チャンク分だけです。

import { fetch } from '@tauri-apps/plugin-http';
import { open, remove } from '@tauri-apps/plugin-fs';

export type Progress = { received: number; total: number | null };

/** url の内容を path に保存し、書き込んだバイト数を返す */
export async function downloadToFile(
  url: string,
  path: string,
  onProgress?: (p: Progress) => void,
  signal?: AbortSignal,
): Promise<number> {
  const res = await fetch(url, { signal });
  // 404 や 500 は例外にならない。確かめないとエラーページを保存してしまう
  if (!res.ok || !res.body) throw new Error(`HTTP ${res.status} ${res.statusText}`);
  const length = Number(res.headers.get('content-length'));
  const total = length > 0 ? length : null; // ヘッダーが無ければ全体の大きさは不明

  // truncate: true で、同じ名前の古いファイルを空にしてから書く
  const file = await open(path, { write: true, create: true, truncate: true });
  const reader = res.body.getReader();
  let received = 0;
  try {
    for (;;) {
      const { done, value } = await reader.read();
      if (done) break;
      await file.write(value); // 届いた分だけ書く。全体をメモリにためない
      received += value.byteLength;
      onProgress?.({ received, total });
    }
  } catch (e) {
    await file.close();
    await remove(path).catch(() => {}); // 中断・切断のときは書きかけを消す
    throw e;
  }
  await file.close();
  return received;
}

open() に truncate: true を付けているのは、同じ名前の古いファイルを確実に空にするためです。writeFile() に response.body(ストリーム)を直接渡すこともできますが、その場合は既存のファイルが空にされず、古いファイルより短い内容を書くと後ろに古い内容が残ります。

進捗バーと中止ボタンにつなぐ例です。中止すると downloadToFile() は例外を投げます。中止による失敗かは、エラーの中身ではなく signal.aborted で見分けます(通信のタイムアウト時間を設定する を参照)。

// (続き)
import { downloadDir, join } from '@tauri-apps/api/path';

// <progress id="bar" max="1"></progress> <span id="label"></span> <button id="cancel">中止</button>
const bar = document.querySelector<HTMLProgressElement>('#bar')!;
const label = document.querySelector<HTMLSpanElement>('#label')!;
const controller = new AbortController();
document.querySelector('#cancel')?.addEventListener('click', () => controller.abort());

const path = await join(await downloadDir(), 'sample.zip');
try {
  const size = await downloadToFile('https://example.com/files/sample.zip', path, ({ received, total }) => {
    if (total) {
      bar.value = received / total;
      label.textContent = `${Math.floor((received / total) * 100)}%`;
    } else {
      bar.removeAttribute('value'); // 大きさが不明なら「不定」の表示にする
      label.textContent = `${(received / 1_048_576).toFixed(1)} MB`;
    }
  }, controller.signal);
  console.log(`saved ${size} bytes to ${path}`);
} catch (e) {
  console.error(controller.signal.aborted ? '中止しました' : e);
}

この方法はチャンクごとに受信と書き込みのプロセス間通信が発生するので、大きなファイルほど時間と CPU を使います。

大きなファイルは Upload プラグインの download() に任せる

download() は、アップロード にも使う Upload プラグインの機能です。受信とファイルへの書き込みを Rust 側で行い、JS には進捗だけを送ります。保存先はファイル名まで含めた絶対パスで渡します。

import { download } from '@tauri-apps/plugin-upload';
import { downloadDir, join } from '@tauri-apps/api/path';

export async function downloadLarge(url: string, fileName: string, bar: HTMLProgressElement): Promise<string> {
  const path = await join(await downloadDir(), fileName); // フォルダーは存在している必要がある
  await download(url, path, ({ progressTotal, total }) => {
    // progress は「今回届いた分」。累計は progressTotal、total は Content-Length(無ければ 0)
    if (total > 0) bar.value = progressTotal / total;
  });
  return path;
}

コールバックの progress は累計ではなく今回届いた分なので、progress / total を進捗にするとバーが 0 付近で揺れるだけになります。transferSpeed はおよその毎秒バイト数です。手軽な反面、次の制約があります。

  • capability で URL も保存先も絞れません。upload:allow-download を与えたウィンドウの JS は、どの URL からでも、ユーザーが書き込めるどの場所にでも保存できます。外部のページを表示するウィンドウには与えないでください。
  • 中断する API もタイムアウトもありません。通信が途中で切れると書きかけのファイルが残ります(サーバーがエラーを返したときはファイルを作りません)。

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

保存先や失敗時の扱いを自分で決めたいときは、reqwest でコマンドを書き、進捗を Channel で JS に送ります。この例では次の 3 点を入れています。

  • 受け取るのはファイル名だけにし、保存先を固定する。Rust のファイル操作には capability のスコープが効かず、パスをそのまま受け取ると JS からどこにでも書けてしまいます。
  • 名前.part に書き終えてから本来の名前に変え、壊れたファイルが完成品に見えないようにする。
  • 進捗の通知を 256 KB ごとに間引く。
[dependencies]
reqwest = "0.12"
tokio = { version = "1", features = ["fs", "io-util"] }
use serde::Serialize;
use std::path::{Component, Path};
use std::time::Duration;
use tauri::{ipc::Channel, Manager};
use tokio::io::AsyncWriteExt;

#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct DownloadProgress {
    received: u64,
    total: Option<u64>,
}

/// url の内容をダウンロードフォルダーの MyApp に保存し、保存先のパスを返す
#[tauri::command]
async fn download_file(
    app: tauri::AppHandle,
    url: String,
    file_name: String,
    on_progress: Channel<DownloadProgress>,
) -> Result<String, String> {
    // ファイル名 1 つだけを受け付ける("../" などで別の場所に書かせない)
    let mut parts = Path::new(&file_name).components();
    if !matches!((parts.next(), parts.next()), (Some(Component::Normal(_)), None)) {
        return Err(format!("invalid file name: {file_name}"));
    }
    let dir = app.path().download_dir().map_err(|e| e.to_string())?.join("MyApp");
    tokio::fs::create_dir_all(&dir).await.map_err(|e| e.to_string())?;
    let dest = dir.join(&file_name);
    let part = dir.join(format!("{file_name}.part"));

    let client = reqwest::Client::builder()
        .connect_timeout(Duration::from_secs(10))
        .read_timeout(Duration::from_secs(30)) // 30 秒データが届かなければ失敗にする
        .build()
        .map_err(|e| e.to_string())?;
    let mut res = client
        .get(&url)
        .send()
        .await
        .and_then(|r| r.error_for_status()) // 404 などをエラーにする
        .map_err(|e| e.to_string())?;
    let total = res.content_length();

    let written: std::io::Result<()> = async {
        let mut file = tokio::fs::File::create(&part).await?;
        let (mut received, mut reported) = (0u64, 0u64);
        while let Some(chunk) = res.chunk().await.map_err(std::io::Error::other)? {
            file.write_all(&chunk).await?;
            received += chunk.len() as u64;
            if received - reported >= 256 * 1024 {
                reported = received;
                let _ = on_progress.send(DownloadProgress { received, total });
            }
        }
        file.flush().await?;
        let _ = on_progress.send(DownloadProgress { received, total }); // 最後に 100% を送る
        Ok(())
    }
    .await;

    if let Err(e) = written {
        let _ = tokio::fs::remove_file(&part).await; // 書きかけを残さない
        return Err(e.to_string());
    }
    tokio::fs::rename(&part, &dest).await.map_err(|e| e.to_string())?; // 完成してから本来の名前にする
    Ok(dest.display().to_string())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_http::init())
        .plugin(tauri_plugin_fs::init())
        .plugin(tauri_plugin_upload::init())
        .invoke_handler(tauri::generate_handler![download_file])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

JS 側では Channel を作って引数で渡します。Rust の on_progress は JS では onProgress です。

import { Channel, invoke } from '@tauri-apps/api/core';

type DownloadProgress = { received: number; total: number | null };

const bar = document.querySelector<HTMLProgressElement>('#bar')!;
const onProgress = new Channel<DownloadProgress>(({ received, total }) => {
  if (total) bar.value = received / total; // total が null ならサーバーが大きさを送っていない
});
const saved = await invoke<string>('download_file', {
  url: 'https://example.com/files/big.zip',
  fileName: 'big.zip',
  onProgress,
});
console.log(`saved to ${saved}`);

動作確認

npm run tauri dev で起動し、1 章のコードを実行すると進捗バーが伸び、終わるとダウンロードフォルダーに sample.zip ができて次のように出ます。途中で中止ボタンを押すと「中止しました」と出て、書きかけのファイルは消えます。

saved 10485760 bytes to C:\Users\me\Downloads\sample.zip

Rust 版では、受信中は MyApp/big.zip.part が大きくなっていき、完了した時点で big.zip に名前が変わります。

よくあるエラーと対処法

  • 「url not allowed on the configured scope: https://…」: URL が http:default の allow に入っていません。判定されるのは最初に呼んだ URL だけで、リダイレクト先(配布用の CDN など)を足す必要はありません。
  • 「forbidden path: 」で始まるエラー: 保存先がスコープの外です。$DOWNLOAD/** の範囲内か、save() が返したパスそのものかを確かめます。
  • 「fs.open not allowed.」で始まるエラー: fs:allow-write-file がありません(リリースビルドでは「Command plugin:fs|open not allowed by ACL」)。
  • 「upload.download not allowed. Permissions associated with this command: upload:allow-download, upload:default」: Upload プラグインの権限の追加漏れです。
  • 「request failed with status code 404: …」: download() がサーバーのエラー応答を受けました。後ろにサーバーが返した本文が続きます。

OS ごとの違いと注意点

  • 保存先: downloadDir()($DOWNLOAD)は、Windows ではユーザーの「ダウンロード」フォルダー、macOS では ~/Downloads、Linux では xdg-user-dirs の XDG_DOWNLOAD_DIR です。
  • タイムアウト: どの方法も既定では時間の制限がなく、回線が止まると待ち続けます。大きなファイルに全体の制限時間を付けると遅い回線では途中で切れるので、「データが届かない時間」で判定します(通信のタイムアウト時間を設定する)。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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