zpaper-draft

Type to search...

to open search from anywhere

Tauri v2 WebViewでファイルダウンロードが動かない問題と対処法

概要

Tauri v2でラッパーアプリをいくつか作っている中で、CSVエクスポート機能がTauriアプリ上では何も起きないという問題に遭遇した。ブラウザでは普通に動くBlobダウンロードが、TauriのWebViewでは無言で失敗する。調べてみるとこれはTauriのWebViewの既知の制限で、対処法もいくつかある。そのまとめ。

背景

自分のモジュラーシンセショップでは、内部ツールをいくつかTauri v2でラップして使っている。Viteベースのdev serverをTauriで包む薄いラッパーアプリという構成。その中の一つに、CSVデータを閲覧・編集するビューアがある。

このビューアには「CSVエクスポート」ボタンがある。ブラウザ上では問題なく動く。Blobを作って、URL.createObjectURL()でURLを生成して、<a>要素にセットしてクリックする、よくあるパターン。

const blob = new Blob([csvText], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "export.csv";
a.click();
URL.revokeObjectURL(url);

ChromeやSafariでは問題なく動作する。ところがTauriアプリ上でこのボタンを押すと、何も起きない。ダイアログも出ない、ダウンロードもされない、エラーも出ない。完全に無反応。

WebViewはフルブラウザではない

TauriのWebViewは、macOSの場合WKWebView(Safariのエンジン)を使っている。ただしSafariそのものではない。フルブラウザが持っているダウンロードマネージャやファイルピッカーのような機能は、WebViewには含まれていない。

Blob URLを使った<a>クリックによるダウンロードは、ブラウザの内部メカニズムに依存している。WebViewにはそのメカニズムがないため、クリックイベントは発火するが、ダウンロードは開始されない。エラーも発生しない。

この問題はTauri v1の頃から知られている。

TauriのWebView抽象化レイヤーであるwryが、v0.22.0でdownload_started_handlerdownload_completed_handlerを追加した。Tauri v2ではこれがWebviewWindowBuilder::on_download()として公開されている。

対処法A: on_downloadハンドラ

最も手軽な方法。RustサイドでWebviewWindowBuilderにon_downloadハンドラを追加する。

.on_download(|webview, event| {
    match event {
        DownloadEvent::Requested { url, destination } => {
            // ダウンロード先のパスを指定
            *destination = "/Users/takazudo/Downloads/export.csv".into();
        }
        DownloadEvent::Finished { url, path, success } => {
            println!("downloaded to {:?}", path);
        }
        _ => (),
    }
    true
})

これを追加すると、既存のブラウザスタイルのBlobダウンロードコードがそのまま動く。フロントエンド側の変更が不要というのが利点。

ただし、保存先のパスをRustサイドでハードコードすることになるので、ユーザーに保存場所を選ばせたい場合には向かない。

対処法B: Tauriコマンド + dialogプラグイン

ネイティブの保存ダイアログを使う方法。これが一番「Tauriらしい」やり方。

まずtauri-plugin-dialogをインストールする。

cargo add tauri-plugin-dialog

JS側にもパッケージを追加する。

pnpm add @tauri-apps/plugin-dialog

tauri.conf.jsonのpluginsにdialogを追加して、Rustサイドでtauri_plugin_dialogを初期化する。

JS側ではsave()でネイティブ保存ダイアログを出して、ユーザーが選んだパスにRustのコマンド経由でファイルを書き込む。

import { save } from "@tauri-apps/plugin-dialog";
import { invoke } from "@tauri-apps/api/core";

const path = await save({
  filters: [{ name: "CSV", extensions: ["csv"] }],
});
if (path) {
  await invoke("save_file", { path, content: csvText });
}

Rust側のコマンドはこうなる。

#[tauri::command]
fn save_file(path: String, content: String) -> Result<(), String> {
    std::fs::write(&path, &content).map_err(|e| e.to_string())
}

ネイティブの保存ダイアログが出るので、UXとしてはこれが一番まとも。セットアップの手間はあるが、ファイルI/O全般で使い回せるので、最初にやっておくと後が楽になる。

対処法C: サーバーサイドで書き出す

Tauri側は何もいじらず、Vite dev serverのAPI経由でファイルを書き出す方法。

TauriでViteベースのアプリをラップしている場合、dev serverが動いている。そのサーバーにエンドポイントを追加して、サーバー側でfs.writeFile()するだけ。

// クライアント側
const res = await fetch("/api/export-csv", {
  method: "POST",
  body: JSON.stringify({ data: csvData }),
});
const { filePath } = await res.json();
// サーバー側
app.post("/api/export-csv", async (req, res) => {
  const outputPath = path.join(os.homedir(), "Downloads", "export.csv");
  await fs.writeFile(outputPath, generateCsv(req.body.data));
  res.json({ filePath: outputPath });
});

動くが、ユーザーが保存先を選べない。パスはサーバー側で決め打ちになる。また、クライアント側にダウンロード完了の通知UIを別途実装する必要がある。

自分たちの状況

自分の場合はVite dev serverのラッパーアプリなので、対処法Cで実際にファイルの書き出し自体は動いた。ただ、クライアント側の通知表示がうまくいかず、ファイルが書き出されたことに気づけないという状態だった。

ちゃんとやるなら対処法B(dialogプラグイン)で、ネイティブの保存ダイアログを出すのが正攻法だろう。

まとめ

Tauriアプリでは、ブラウザで当たり前に動くダウンロードパターンがそのままでは動かない。WebViewはフルブラウザではないので、ブラウザ固有の機能に依存しているコードは対処が必要になる。

3つのアプローチを並べると以下。

  • on_downloadハンドラ — フロントエンド変更不要、ただし保存先が固定
  • dialogプラグイン + Tauriコマンド — ネイティブ保存ダイアログ、UXが一番良い
  • サーバーサイド書き出し — Tauri側の変更不要、ただしUXが微妙

Tauriでアプリを作るなら、ファイルI/Oが必要になる想定で最初からdialogプラグインを入れておくのが無難。後からBlobダウンロードが動かないと気づいて対処するより、最初から計画しておくほうが楽。

参考