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-apps/wry#349 — wryライブラリへのダウンロードサポート要望
- tauri-apps/tauri#8452 — macOSでBlobダウンロードが動かない報告
- tauri-apps/tauri#8157 —
on_downloadのfeature request
TauriのWebView抽象化レイヤーであるwryが、v0.22.0でdownload_started_handlerとdownload_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ダウンロードが動かないと気づいて対処するより、最初から計画しておくほうが楽。