ユーザーが選んだ画像やアプリのログをサーバーへ送る方法は、サーバーが受け付ける形式とファイルの大きさで選びます。HTML フォームと同じ multipart/form-data を受けるサーバーなら、http プラグインの fetch() に FormData を渡します。ファイルの中身をそのまま本文として受ける(PUT で送る署名付き URL など)なら、Upload プラグインの upload() がパスから直接読んで、進捗つきで送ります。見落としやすいのは、upload() の送信は multipart ではないことと、fetch() は送る前にデータ全体をメモリに用意することの 2 点です。
| 方法 | 本文の形式 | ファイルの渡し方 | 進捗 | 大きなファイル |
|---|---|---|---|---|
fetch() + FormData | multipart | File か読み込んだバイト列 | × | 不向き |
Upload プラグインの upload() | ファイルの中身そのまま | 絶対パス | ○ | 向く |
| Rust の reqwest | どちらも可 | Rust で決めたパス | 自作 | 向く |
前提条件
npm run tauri add http
npm run tauri add upload
# パスでファイルを選ばせて読む場合
npm run tauri add dialog
npm run tauri add fs
fetch() の送り先は http:default の allow に書きます(書き方は HTTP GET リクエストを送る(Rust経由))。upload() の権限 upload:allow-upload は upload:default にも含まれますが、送り先の URL も読み取るパスも capability では絞れません。与えたウィンドウの JS は、ユーザーが読めるどのファイルでも、どの URL へでも送れます。外部のページを表示するウィンドウには与えず、送り先を固定したいなら 2 章のように Rust で送ります。
{
"$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://upload.example.com/*" }]
},
"upload:allow-upload",
"dialog:allow-open",
"fs:allow-read-file"
]
}
ダイアログで選ばれたファイルは起動中だけ自動で fs のスコープに入るので、fs:allow-read-file に path を書く必要はありません(ファイルを開くダイアログを表示する)。決まったフォルダーのファイルを読む場合の範囲の書き方は ファイルやディレクトリを削除する を参照してください。
1. フロントエンドから実装する (TypeScript)
<input type="file"> のファイルを multipart で送る
import { fetch } from '@tauri-apps/plugin-http';
export async function uploadPicked(input: HTMLInputElement): Promise<string> {
const file = input.files?.[0];
if (!file) throw new Error('ファイルが選ばれていません');
const form = new FormData();
form.append('file', file); // ファイル名と種類(file.type)も一緒に送られる
form.append('comment', 'Tauri から送信');
const res = await fetch('https://upload.example.com/api/files', {
method: 'POST',
body: form, // Content-Type は書かない。boundary 付きの値が自動で付く
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
return res.text();
}
Content-Type: multipart/form-data を自分で書くと、区切り文字(boundary)の無いヘッダーが優先され、サーバーが本文を分解できなくなります。fetch() は 404 などでも例外にならないので res.ok を確かめます。
ダイアログで選んだパスのファイルを送る
<input type="file"> からはファイルのパスが分かりません。upload() を使う、同じファイルを後で送り直す、といった場合は dialog プラグインの open() で選ばせます。multipart で送るなら fs の readFile() で読んで Blob にします。
import { fetch } from '@tauri-apps/plugin-http';
import { open } from '@tauri-apps/plugin-dialog';
import { readFile } from '@tauri-apps/plugin-fs';
import { basename } from '@tauri-apps/api/path';
export async function pickAndPost(): Promise<string | null> {
const path = await open({ multiple: false, directory: false });
if (!path) return null; // キャンセル
const bytes = await readFile(path); // ファイル全体をメモリに読む
const form = new FormData();
form.append('file', new Blob([new Uint8Array(bytes)]), await basename(path)); // 第 3 引数がファイル名
const res = await fetch('https://upload.example.com/api/files', { method: 'POST', body: form });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}
fetch() は本文全体を用意してから Rust 側へまとめて渡すので、ファイルの大きさの何倍ものメモリを使うことがあります。数十 MB を超えるファイルは次の upload() で送ります。
Upload プラグインでパスから直接送る(進捗つき)
import { upload, HttpMethod } from '@tauri-apps/plugin-upload';
import { open } from '@tauri-apps/plugin-dialog';
import { basename } from '@tauri-apps/api/path';
export async function putWithProgress(bar: HTMLProgressElement): Promise<string | null> {
const path = await open({ multiple: false, directory: false });
if (!path) return null;
const name = encodeURIComponent(await basename(path));
const headers = new Map([['Content-Type', 'application/octet-stream']]); // multipart/form-data にしない
// 戻り値はサーバーが返した本文。2xx 以外は例外になる
return upload(
`https://upload.example.com/api/raw/${name}`, // 本文にファイル名は入らないので URL などで伝える
path, // 絶対パス。Rust 側で読みながら送る
({ progressTotal, total }) => {
bar.value = progressTotal / total; // total はファイルの大きさ
},
headers,
HttpMethod.Put, // 省略すると POST
);
}
upload() はファイルを少しずつ読みながら送るので、大きなファイルでもメモリをほとんど使いません。本文はファイルの中身そのもので、Content-Length にファイルの大きさが入ります。そのため multipart を前提にしたフォームの受け口(PHP の $_FILES など)では「ファイルが無い」扱いになり、Content-Type に multipart/form-data を書いても multipart にはなりません。
進捗の progress は今回送った分、progressTotal が累計、transferSpeed はおよその毎秒バイト数です。中断する API とタイムアウトはありません。パスは絶対パスで渡すので、アプリのフォルダーのファイルなら join(await appLogDir(), 'app.log') のように組み立てます。
2. バックエンドから実装する (Rust)
送り先や送ってよいファイルを JS に任せたくない場合は、Rust のコマンドで送ります。reqwest の stream 機能を有効にすると tokio::fs::File をそのまま本文にでき、読みながら送れます。Rust から multipart で送る reqwest::multipart::Form は、reqwest に multipart 機能を足すと使えます。
[dependencies]
reqwest = { version = "0.12", features = ["stream"] }
tokio = { version = "1", features = ["fs"] }
use std::path::{Component, Path};
use tauri::Manager;
/// アプリのログフォルダーにあるファイルを 1 つ、決まったサーバーに PUT する
#[tauri::command]
async fn upload_log(app: tauri::AppHandle, file_name: String) -> 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 path = app.path().app_log_dir().map_err(|e| e.to_string())?.join(&file_name);
let file = tokio::fs::File::open(&path)
.await
.map_err(|e| format!("{}: {e}", path.display()))?;
let len = file.metadata().await.map_err(|e| e.to_string())?.len();
// 送り先は Rust で固定する。ファイル名は URL のパスとして符号化される
let mut url = reqwest::Url::parse("https://upload.example.com/api/logs/").map_err(|e| e.to_string())?;
url.path_segments_mut()
.map_err(|_| "invalid base url".to_string())?
.pop_if_empty()
.push(&file_name);
let res = reqwest::Client::new()
.put(url)
.header(reqwest::header::CONTENT_TYPE, "text/plain; charset=utf-8")
.header(reqwest::header::CONTENT_LENGTH, len)
.body(reqwest::Body::from(file)) // 読みながら送る(stream 機能)
.send()
.await
.map_err(|e| e.to_string())?;
let status = res.status();
let body = res.text().await.map_err(|e| e.to_string())?;
if !status.is_success() {
return Err(format!("HTTP {status}: {body}"));
}
Ok(body)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_upload::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.invoke_handler(tauri::generate_handler![upload_log])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';
const reply = await invoke<string>('upload_log', { fileName: 'app.log' });
console.log(reply);
動作確認
送った内容をそのまま返してくれる https://httpbin.org/post と https://httpbin.org/put を送り先にすると、形式の違いが見えます。fetch() 用に allow へ https://httpbin.org/* を足します(upload() には不要です)。npm run tauri dev で起動して送ると、応答の JSON が次のようになります。
fetch() + FormData → "files": { "file": "..." }, "form": { "comment": "Tauri から送信" }
upload() → "files": {}, "data": "..."
multipart で送ったファイルは files に、upload() で送ったファイルは本文として data に入ります。
よくあるエラーと対処法
upload()で送るとサーバーが「ファイルが無い」と返す: 本文が multipart ではありません。サーバー側で本文をそのまま受けるか、fetch()+FormDataに切り替えます。- multipart なのにサーバーが本文を解析できない:
Content-Typeを手で書いて boundary が消えています。FormDataのときはヘッダーを書きません。 - 「url not allowed on the configured scope: …」:
fetch()の送り先がallowにありません。upload()ではこのエラーは出ません。 - 「upload.upload not allowed. Permissions associated with this command: upload:allow-upload, upload:default」: 権限の追加漏れです。リリースビルドでは「Command plugin:upload|upload not allowed by ACL」だけになります。
- 「request failed with status code 413: …」:
upload()で 2xx 以外が返りました。413 はサーバーの上限を超えた大きさという意味で、後ろにサーバーの本文が続きます。
OS ごとの違いと注意点
- iOS:
open()で選んだファイルは既定(fileAccessMode: 'copy')でアプリ専用の領域にコピーされ、そのパスが返ります。送り終えて不要になったコピーを消すのはアプリの役目です。 - 送り直し: 時間切れのあとに自動で送り直すなら、同じ内容を何度送っても結果が変わらない PUT にするか、サーバー側で重複を弾きます。POST の送り直しは二重登録になることがあります。時間の決め方は 通信のタイムアウト時間を設定する を参照してください。
- ダウンロード: 逆向きの受信と保存は ファイルをダウンロードして保存する で扱います。
