From 426627a591cabd81d50d27e2930e6fcfd2ce95d6 Mon Sep 17 00:00:00 2001 From: Matthew Richardson Date: Fri, 11 Jul 2025 17:01:31 +0100 Subject: [PATCH 1/6] feat: Add Upload demo view --- Cargo.lock | 1 + examples/api/package.json | 1 + examples/api/src-tauri/Cargo.toml | 1 + examples/api/src-tauri/capabilities/base.json | 3 +- examples/api/src-tauri/src/lib.rs | 1 + examples/api/src/App.svelte | 6 + examples/api/src/views/Upload.svelte | 376 ++++++++++++++++++ pnpm-lock.yaml | 22 +- 8 files changed, 400 insertions(+), 11 deletions(-) create mode 100644 examples/api/src/views/Upload.svelte diff --git a/Cargo.lock b/Cargo.lock index 23f4bb0b..62acfde8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -233,6 +233,7 @@ dependencies = [ "tauri-plugin-shell", "tauri-plugin-store", "tauri-plugin-updater", + "tauri-plugin-upload", "tauri-plugin-window-state", "time", "tiny_http", diff --git a/examples/api/package.json b/examples/api/package.json index c81db111..7cd411cc 100644 --- a/examples/api/package.json +++ b/examples/api/package.json @@ -29,6 +29,7 @@ "@tauri-apps/plugin-shell": "^2.3.0", "@tauri-apps/plugin-store": "^2.3.0", "@tauri-apps/plugin-updater": "^2.9.0", + "@tauri-apps/plugin-upload": "^2.3.0", "@zerodevx/svelte-json-view": "1.0.11" }, "devDependencies": { diff --git a/examples/api/src-tauri/Cargo.toml b/examples/api/src-tauri/Cargo.toml index d9966254..9a2d349c 100644 --- a/examples/api/src-tauri/Cargo.toml +++ b/examples/api/src-tauri/Cargo.toml @@ -38,6 +38,7 @@ tauri-plugin-process = { path = "../../../plugins/process", version = "2.3.0" } tauri-plugin-opener = { path = "../../../plugins/opener", version = "2.4.0" } tauri-plugin-shell = { path = "../../../plugins/shell", version = "2.3.0" } tauri-plugin-store = { path = "../../../plugins/store", version = "2.3.0" } +tauri-plugin-upload = { path = "../../../plugins/upload", version = "2.3.0" } [dependencies.tauri] workspace = true diff --git a/examples/api/src-tauri/capabilities/base.json b/examples/api/src-tauri/capabilities/base.json index 1fb9f244..8508bb6b 100644 --- a/examples/api/src-tauri/capabilities/base.json +++ b/examples/api/src-tauri/capabilities/base.json @@ -95,6 +95,7 @@ { "identifier": "opener:allow-open-path", "allow": [{ "path": "$APPDATA" }, { "path": "$APPDATA/**" }] - } + }, + "upload:default" ] } diff --git a/examples/api/src-tauri/src/lib.rs b/examples/api/src-tauri/src/lib.rs index a19992b0..3c58f2c8 100644 --- a/examples/api/src-tauri/src/lib.rs +++ b/examples/api/src-tauri/src/lib.rs @@ -39,6 +39,7 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_store::Builder::default().build()) + .plugin(tauri_plugin_upload::init()) .setup(move |app| { #[cfg(desktop)] { diff --git a/examples/api/src/App.svelte b/examples/api/src/App.svelte index 9396f6f9..8e114c4b 100644 --- a/examples/api/src/App.svelte +++ b/examples/api/src/App.svelte @@ -16,6 +16,7 @@ import Opener from './views/Opener.svelte' import Store from './views/Store.svelte' import Updater from './views/Updater.svelte' + import Upload from './views/Upload.svelte' import Clipboard from './views/Clipboard.svelte' import WebRTC from './views/WebRTC.svelte' import Scanner from './views/Scanner.svelte' @@ -107,6 +108,11 @@ component: Updater, icon: 'i-codicon-cloud-download' }, + { + label: 'Upload', + component: Upload, + icon: 'i-codicon-cloud-upload' + }, { label: 'Clipboard', component: Clipboard, diff --git a/examples/api/src/views/Upload.svelte b/examples/api/src/views/Upload.svelte new file mode 100644 index 00000000..6c1c3852 --- /dev/null +++ b/examples/api/src/views/Upload.svelte @@ -0,0 +1,376 @@ + + +
+
+

File Download

+ +
+
+ + +
+ +
+ +
+ + +
+
+ + {#if downloadPath} +
+
+ File will be saved as: +
{downloadPath}
+
+
+ {/if} + + + + {#if downloadProgress} +
+
+ Progress: {downloadProgress.percentage}% + Speed: {Math.round(downloadProgress.transferSpeed / 1024)} KB/s +
+
+
+
+
+ {Math.round(downloadProgress.progressTotal / 1024)} KB / {Math.round(downloadProgress.total / 1024)} KB +
+
+ {/if} + + {#if downloadResult} +
+ +
+ {/if} +
+
+ +
+

File Upload

+ +
+
+ + +
+ +
+ +
+ + +
+
+ + + + {#if uploadProgress} +
+
+ Progress: {uploadProgress.percentage}% + Speed: {Math.round(uploadProgress.transferSpeed / 1024)} KB/s +
+
+
+
+
+ {Math.round(uploadProgress.progressTotal / 1024)} KB / {Math.round(uploadProgress.total / 1024)} KB +
+
+ {/if} + + {#if uploadResult} +
+ +
+ {/if} +
+
+
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46a684e3..aa0ba090 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,9 @@ importers: '@tauri-apps/plugin-updater': specifier: ^2.9.0 version: link:../../plugins/updater + '@tauri-apps/plugin-upload': + specifier: ^2.3.0 + version: link:../../plugins/upload '@zerodevx/svelte-json-view': specifier: 1.0.11 version: 1.0.11(svelte@5.28.2) @@ -2339,9 +2342,9 @@ snapshots: - encoding - mocha - '@covector/assemble@0.12.0(mocha@10.8.2)': + '@covector/assemble@0.12.0': dependencies: - '@covector/command': 0.8.0(mocha@10.8.2) + '@covector/command': 0.8.0 '@covector/files': 0.8.0 effection: 2.0.8(mocha@10.8.2) js-yaml: 4.1.0 @@ -2352,10 +2355,9 @@ snapshots: unified: 9.2.2 transitivePeerDependencies: - encoding - - mocha - supports-color - '@covector/changelog@0.12.0(mocha@10.8.2)': + '@covector/changelog@0.12.0': dependencies: '@covector/files': 0.8.0 effection: 2.0.8(mocha@10.8.2) @@ -2365,16 +2367,14 @@ snapshots: unified: 9.2.2 transitivePeerDependencies: - encoding - - mocha - supports-color - '@covector/command@0.8.0(mocha@10.8.2)': + '@covector/command@0.8.0': dependencies: '@effection/process': 2.1.4 effection: 2.0.8(mocha@10.8.2) transitivePeerDependencies: - encoding - - mocha '@covector/files@0.8.0': dependencies: @@ -2421,6 +2421,8 @@ snapshots: dependencies: effection: 2.0.8(mocha@10.8.2) mocha: 10.8.2 + transitivePeerDependencies: + - encoding '@effection/process@2.1.4': dependencies: @@ -3258,9 +3260,9 @@ snapshots: dependencies: '@clack/prompts': 0.7.0 '@covector/apply': 0.10.0(mocha@10.8.2) - '@covector/assemble': 0.12.0(mocha@10.8.2) - '@covector/changelog': 0.12.0(mocha@10.8.2) - '@covector/command': 0.8.0(mocha@10.8.2) + '@covector/assemble': 0.12.0 + '@covector/changelog': 0.12.0 + '@covector/command': 0.8.0 '@covector/files': 0.8.0 effection: 2.0.8(mocha@10.8.2) globby: 11.1.0 From 79f7242cf9a427ba69e12137204e1982c4350632 Mon Sep 17 00:00:00 2001 From: Matthew Richardson Date: Fri, 11 Jul 2025 17:19:35 +0100 Subject: [PATCH 2/6] feat: Spawn threads using Tokio --- plugins/upload/src/lib.rs | 133 +++++++++++++++++++++----------------- 1 file changed, 73 insertions(+), 60 deletions(-) diff --git a/plugins/upload/src/lib.rs b/plugins/upload/src/lib.rs index 23c33b11..68be069d 100644 --- a/plugins/upload/src/lib.rs +++ b/plugins/upload/src/lib.rs @@ -72,44 +72,50 @@ async fn download( body: Option, on_progress: Channel, ) -> Result<()> { - let client = reqwest::Client::new(); - let mut request = if let Some(body) = body { - client.post(url).body(body) - } else { - client.get(url) - }; - // Loop trought the headers keys and values - // and add them to the request object. - for (key, value) in headers { - request = request.header(&key, value); - } - - let response = request.send().await?; - if !response.status().is_success() { - return Err(Error::HttpErrorCode( - response.status().as_u16(), - response.text().await.unwrap_or_default(), - )); - } - let total = response.content_length().unwrap_or(0); + let url = url.to_string(); + let file_path = file_path.to_string(); + + tokio::spawn(async move { + let client = reqwest::Client::new(); + let mut request = if let Some(body) = body { + client.post(&url).body(body) + } else { + client.get(&url) + }; + // Loop trought the headers keys and values + // and add them to the request object. + for (key, value) in headers { + request = request.header(&key, value); + } - let mut file = BufWriter::new(File::create(file_path).await?); - let mut stream = response.bytes_stream(); + let response = request.send().await?; + if !response.status().is_success() { + return Err(Error::HttpErrorCode( + response.status().as_u16(), + response.text().await.unwrap_or_default(), + )); + } + let total = response.content_length().unwrap_or(0); - let mut stats = TransferStats::default(); - while let Some(chunk) = stream.try_next().await? { - file.write_all(&chunk).await?; - stats.record_chunk_transfer(chunk.len()); - let _ = on_progress.send(ProgressPayload { - progress: chunk.len() as u64, - progress_total: stats.total_transferred, - total, - transfer_speed: stats.transfer_speed, - }); - } - file.flush().await?; + let mut file = BufWriter::new(File::create(&file_path).await?); + let mut stream = response.bytes_stream(); - Ok(()) + let mut stats = TransferStats::default(); + while let Some(chunk) = stream.try_next().await? { + file.write_all(&chunk).await?; + stats.record_chunk_transfer(chunk.len()); + let _ = on_progress.send(ProgressPayload { + progress: chunk.len() as u64, + progress_total: stats.total_transferred, + total, + transfer_speed: stats.transfer_speed, + }); + } + file.flush().await?; + Ok(()) + }) + .await + .map_err(|e| Error::Io(std::io::Error::new(std::io::ErrorKind::Other, e.to_string())))? } #[command] @@ -119,32 +125,39 @@ async fn upload( headers: HashMap, on_progress: Channel, ) -> Result { - // Read the file - let file = File::open(file_path).await?; - let file_len = file.metadata().await.unwrap().len(); - - // Create the request and attach the file to the body - let client = reqwest::Client::new(); - let mut request = client - .post(url) - .header(reqwest::header::CONTENT_LENGTH, file_len) - .body(file_to_body(on_progress, file)); - - // Loop through the headers keys and values - // and add them to the request object. - for (key, value) in headers { - request = request.header(&key, value); - } + let url = url.to_string(); + let file_path = file_path.to_string(); + + tokio::spawn(async move { + // Read the file + let file = File::open(&file_path).await?; + let file_len = file.metadata().await.unwrap().len(); + + // Create the request and attach the file to the body + let client = reqwest::Client::new(); + let mut request = client + .post(&url) + .header(reqwest::header::CONTENT_LENGTH, file_len) + .body(file_to_body(on_progress, file)); + + // Loop through the headers keys and values + // and add them to the request object. + for (key, value) in headers { + request = request.header(&key, value); + } - let response = request.send().await?; - if response.status().is_success() { - response.text().await.map_err(Into::into) - } else { - Err(Error::HttpErrorCode( - response.status().as_u16(), - response.text().await.unwrap_or_default(), - )) - } + let response = request.send().await?; + if response.status().is_success() { + response.text().await.map_err(Into::into) + } else { + Err(Error::HttpErrorCode( + response.status().as_u16(), + response.text().await.unwrap_or_default(), + )) + } + }) + .await + .map_err(|e| Error::Io(std::io::Error::new(std::io::ErrorKind::Other, e.to_string())))? } fn file_to_body(channel: Channel, file: File) -> reqwest::Body { From 24b75a9ee902f21286b6beba8854d9f1e5a392c3 Mon Sep 17 00:00:00 2001 From: Matthew Richardson Date: Fri, 11 Jul 2025 17:21:54 +0100 Subject: [PATCH 3/6] fix: Fix reported upload total bytes --- plugins/upload/src/lib.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/upload/src/lib.rs b/plugins/upload/src/lib.rs index 68be069d..36f3e715 100644 --- a/plugins/upload/src/lib.rs +++ b/plugins/upload/src/lib.rs @@ -138,7 +138,7 @@ async fn upload( let mut request = client .post(&url) .header(reqwest::header::CONTENT_LENGTH, file_len) - .body(file_to_body(on_progress, file)); + .body(file_to_body(on_progress, file, file_len)); // Loop through the headers keys and values // and add them to the request object. @@ -160,18 +160,18 @@ async fn upload( .map_err(|e| Error::Io(std::io::Error::new(std::io::ErrorKind::Other, e.to_string())))? } -fn file_to_body(channel: Channel, file: File) -> reqwest::Body { +fn file_to_body(channel: Channel, file: File, file_len: u64) -> reqwest::Body { let stream = FramedRead::new(file, BytesCodec::new()).map_ok(|r| r.freeze()); let mut stats = TransferStats::default(); reqwest::Body::wrap_stream(ReadProgressStream::new( stream, - Box::new(move |progress, total| { + Box::new(move |progress, _total| { stats.record_chunk_transfer(progress as usize); let _ = channel.send(ProgressPayload { progress, progress_total: stats.total_transferred, - total, + total: file_len, transfer_speed: stats.transfer_speed, }); }), From 5645abe9e4a78b8296d9f94df9bd343aaebdee65 Mon Sep 17 00:00:00 2001 From: Matthew Richardson Date: Fri, 11 Jul 2025 17:47:46 +0100 Subject: [PATCH 4/6] fix: Fix upload command not returning --- plugins/upload/src/lib.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/upload/src/lib.rs b/plugins/upload/src/lib.rs index 36f3e715..fd9eb026 100644 --- a/plugins/upload/src/lib.rs +++ b/plugins/upload/src/lib.rs @@ -74,7 +74,7 @@ async fn download( ) -> Result<()> { let url = url.to_string(); let file_path = file_path.to_string(); - + tokio::spawn(async move { let client = reqwest::Client::new(); let mut request = if let Some(body) = body { @@ -115,7 +115,7 @@ async fn download( Ok(()) }) .await - .map_err(|e| Error::Io(std::io::Error::new(std::io::ErrorKind::Other, e.to_string())))? + .map_err(|e| Error::Io(std::io::Error::other(e.to_string())))? } #[command] @@ -127,7 +127,7 @@ async fn upload( ) -> Result { let url = url.to_string(); let file_path = file_path.to_string(); - + tokio::spawn(async move { // Read the file let file = File::open(&file_path).await?; @@ -157,7 +157,7 @@ async fn upload( } }) .await - .map_err(|e| Error::Io(std::io::Error::new(std::io::ErrorKind::Other, e.to_string())))? + .map_err(|e| Error::Io(std::io::Error::other(e.to_string())))? } fn file_to_body(channel: Channel, file: File, file_len: u64) -> reqwest::Body { From 87c344a803aafb470b6ed7644b9a9f6623244172 Mon Sep 17 00:00:00 2001 From: Matthew Richardson Date: Wed, 16 Jul 2025 10:53:29 +0100 Subject: [PATCH 5/6] chore: Add upload tests --- plugins/upload/src/lib.rs | 77 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/plugins/upload/src/lib.rs b/plugins/upload/src/lib.rs index fd9eb026..cf5815b2 100644 --- a/plugins/upload/src/lib.rs +++ b/plugins/upload/src/lib.rs @@ -196,7 +196,7 @@ mod tests { } #[tokio::test] - async fn should_error_if_status_not_success() { + async fn should_error_on_download_if_status_not_success() { let mocked_server = spawn_server_mocked(400).await; let result = download_file(&mocked_server.url).await; mocked_server.mocked_endpoint.assert(); @@ -215,6 +215,51 @@ mod tests { ); } + #[tokio::test] + async fn should_error_on_upload_if_status_not_success() { + let mocked_server = spawn_upload_server_mocked(500).await; + let result = upload_file(&mocked_server.url).await; + mocked_server.mocked_endpoint.assert(); + assert!(result.is_err()); + match result.unwrap_err() { + Error::HttpErrorCode(status, _) => assert_eq!(status, 500), + _ => panic!("Expected HttpErrorCode error"), + } + } + + #[tokio::test] + async fn should_error_on_upload_if_file_not_found() { + let mocked_server = spawn_upload_server_mocked(200).await; + let file_path = "/nonexistent/file.txt"; + let headers = HashMap::new(); + let sender: Channel = + Channel::new(|msg: InvokeResponseBody| -> tauri::Result<()> { + let _ = msg; + Ok(()) + }); + + let result = upload(&mocked_server.url, file_path, headers, sender).await; + assert!(result.is_err()); + match result.unwrap_err() { + Error::Io(_) => {} + _ => panic!("Expected IO error for missing file"), + } + } + + #[tokio::test] + async fn should_upload_file_successfully() { + let mocked_server = spawn_upload_server_mocked(200).await; + let result = upload_file(&mocked_server.url).await; + mocked_server.mocked_endpoint.assert(); + assert!( + result.is_ok(), + "failed to upload file: {}", + result.unwrap_err() + ); + let response_body = result.unwrap(); + assert_eq!(response_body, "upload successful"); + } + async fn download_file(url: &str) -> Result<()> { let file_path = concat!(env!("CARGO_MANIFEST_DIR"), "/test/test.txt"); let headers = HashMap::new(); @@ -226,6 +271,17 @@ mod tests { download(url, file_path, headers, None, sender).await } + async fn upload_file(url: &str) -> Result { + let file_path = concat!(env!("CARGO_MANIFEST_DIR"), "/test/test.txt"); + let headers = HashMap::new(); + let sender: Channel = + Channel::new(|msg: InvokeResponseBody| -> tauri::Result<()> { + let _ = msg; + Ok(()) + }); + upload(url, file_path, headers, sender).await + } + async fn spawn_server_mocked(return_status: usize) -> MockedServer { let mut _server = Server::new_async().await; let path = "/mock_test"; @@ -243,4 +299,23 @@ mod tests { mocked_endpoint: mock, } } + + async fn spawn_upload_server_mocked(return_status: usize) -> MockedServer { + let mut _server = Server::new_async().await; + let path = "/upload_test"; + let mock = _server + .mock("POST", path) + .with_status(return_status) + .with_body("upload successful") + .match_header("content-length", "20") + .create_async() + .await; + + let url = _server.url() + path; + MockedServer { + _server, + url, + mocked_endpoint: mock, + } + } } From 344bc599a5c9a3b3c49823c6ca2ec898d5ef0ffa Mon Sep 17 00:00:00 2001 From: Matthew Richardson Date: Wed, 16 Jul 2025 12:57:28 +0100 Subject: [PATCH 6/6] chore: Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 33039295..e5e1623a 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,8 @@ This repo and all plugins require a Rust version of at least **1.77.2** | [sql](plugins/sql) | Interface with SQL databases. | ✅ | ✅ | ✅ | ✅ | ✅ | | [store](plugins/store) | Persistent key value storage. | ✅ | ✅ | ✅ | ✅ | ✅ | | [stronghold](plugins/stronghold) | Encrypted, secure database. | ✅ | ✅ | ✅ | ? | ? | -| [updater](plugins/updater) | In-app updates for Tauri applications. | ✅ | ✅ | ✅ | ❌ | ❌ | -| [upload](plugins/upload) | Tauri plugin for file uploads through HTTP. | ✅ | ✅ | ✅ | ? | ? | +| [updater](plugins/updater) | In-app updates for Tauri applications. | ✅ | ✅ | ✅ | ✅ | ✅ | +| [upload](plugins/upload) | Tauri plugin for file downloads and uploads via HTTP. | ✅ | ✅ | ✅ | ? | ? | | [websocket](plugins/websocket) | Open a WebSocket connection using a Rust client in JS. | ✅ | ✅ | ✅ | ? | ? | | [window-state](plugins/window-state) | Persist window sizes and positions. | ✅ | ✅ | ✅ | ❌ | ❌ |