pull/2838/merge
Matthew Richardson 1 day ago committed by GitHub
commit 64f189766a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

1
Cargo.lock generated

@ -233,6 +233,7 @@ dependencies = [
"tauri-plugin-shell",
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-upload",
"tauri-plugin-window-state",
"time",
"tiny_http",

@ -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. | ✅ | ✅ | ✅ | ❌ | ❌ |

@ -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": {

@ -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

@ -95,6 +95,7 @@
{
"identifier": "opener:allow-open-path",
"allow": [{ "path": "$APPDATA" }, { "path": "$APPDATA/**" }]
}
},
"upload:default"
]
}

@ -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)]
{

@ -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,

@ -0,0 +1,376 @@
<script>
import { download, upload } from '@tauri-apps/plugin-upload'
import { open } from '@tauri-apps/plugin-dialog'
import { JsonView } from '@zerodevx/svelte-json-view'
import { appDataDir } from '@tauri-apps/api/path'
import { onMount } from 'svelte'
export let onMessage
let downloadUrl = 'https://httpbin.org/json'
let downloadFolder = ''
let downloadPath = ''
let downloadProgress = null
let downloadResult = null
let isDownloading = false
let uploadUrl = 'https://httpbin.org/post'
let uploadFilePath = ''
let uploadProgress = null
let uploadResult = null
let isUploading = false
onMount(async () => {
try {
const defaultDir = await appDataDir()
if (!downloadFolder) {
downloadFolder = defaultDir
updateDownloadPath()
}
} catch (error) {
onMessage({ error: `Failed to get default directory: ${error.toString()}` })
}
})
async function selectDownloadFolder() {
try {
const selected = await open({
directory: true,
multiple: false,
defaultPath: downloadFolder || undefined
})
if (selected) {
downloadFolder = selected
updateDownloadPath()
}
} catch (error) {
onMessage({ error: error.toString() })
}
}
function getFilenameFromUrl(url) {
try {
const urlObj = new URL(url)
let pathname = urlObj.pathname
// Remove leading slash
if (pathname.startsWith('/')) {
pathname = pathname.substring(1)
}
// If pathname is empty or ends with slash, use a default name
if (!pathname || pathname.endsWith('/')) {
return 'downloaded-file.json'
}
// Extract filename from pathname
const segments = pathname.split('/')
let filename = segments[segments.length - 1]
// If no extension, try to infer from URL or use default
if (!filename.includes('.')) {
// Check if URL suggests a file type
if (url.includes('json') || urlObj.searchParams.has('format') && urlObj.searchParams.get('format') === 'json') {
filename += '.json'
} else if (url.includes('xml')) {
filename += '.xml'
} else if (url.includes('csv')) {
filename += '.csv'
} else {
filename += '.txt'
}
}
return filename
} catch (error) {
return 'downloaded-file.json'
}
}
function updateDownloadPath() {
if (downloadFolder && downloadUrl) {
const filename = getFilenameFromUrl(downloadUrl)
downloadPath = `${downloadFolder}/${filename}`
} else {
downloadPath = ''
}
}
// Update download path when URL changes
$: if (downloadUrl) {
updateDownloadPath()
}
async function selectUploadFile() {
try {
const selected = await open({
directory: false,
multiple: false
})
if (selected) {
uploadFilePath = selected
}
} catch (error) {
onMessage({ error: error.toString() })
}
}
async function startDownload() {
if (!downloadUrl || !downloadFolder) {
onMessage({ error: 'Please provide both URL and download folder' })
return
}
// Ensure download path is updated
updateDownloadPath()
if (!downloadPath) {
onMessage({ error: 'Could not generate download path' })
return
}
isDownloading = true
downloadProgress = null
downloadResult = null
try {
await download(
downloadUrl,
downloadPath,
(progress) => {
downloadProgress = {
progress: progress.progress,
progressTotal: progress.progressTotal,
total: progress.total,
transferSpeed: progress.transferSpeed,
percentage: progress.total > 0 ? Math.round((progress.progressTotal / progress.total) * 100) : 0
}
},
new Map([
['User-Agent', 'Tauri Upload Plugin Demo']
])
)
downloadResult = {
success: true,
message: `File downloaded successfully to: ${downloadPath}`,
finalProgress: downloadProgress
}
onMessage({
type: 'download',
result: downloadResult
})
} catch (error) {
downloadResult = {
success: false,
error: error.toString()
}
onMessage({ error: error.toString() })
} finally {
isDownloading = false
}
}
async function startUpload() {
if (!uploadUrl || !uploadFilePath) {
onMessage({ error: 'Please provide both URL and file path' })
return
}
isUploading = true
uploadProgress = null
uploadResult = null
try {
const response = await upload(
uploadUrl,
uploadFilePath,
(progress) => {
uploadProgress = {
progress: progress.progress,
progressTotal: progress.progressTotal,
total: progress.total,
transferSpeed: progress.transferSpeed,
percentage: progress.total > 0 ? Math.round((progress.progressTotal / progress.total) * 100) : 0
}
},
new Map([
['User-Agent', 'Tauri Upload Plugin Demo']
])
)
uploadResult = {
success: true,
response: response,
finalProgress: uploadProgress
}
onMessage({
type: 'upload',
result: uploadResult
})
} catch (error) {
uploadResult = {
success: false,
error: error.toString()
}
onMessage({ error: error.toString() })
} finally {
isUploading = false
}
}
</script>
<div class="space-y-6">
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-semibold mb-4 text-gray-800">File Download</h3>
<div class="space-y-3">
<div>
<label for="download-url" class="block text-sm font-medium text-gray-700 mb-1">Download URL:</label>
<input
id="download-url"
bind:value={downloadUrl}
type="url"
placeholder="https://example.com/file.json"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isDownloading}
/>
</div>
<div>
<label for="download-folder" class="block text-sm font-medium text-gray-700 mb-1">Download folder:</label>
<div class="flex gap-2">
<input
id="download-folder"
bind:value={downloadFolder}
type="text"
placeholder="Select download folder..."
class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isDownloading}
/>
<button
on:click={selectDownloadFolder}
class="px-4 py-2 bg-gray-500 text-white rounded-md hover:bg-gray-600 disabled:opacity-50"
disabled={isDownloading}
>
Browse
</button>
</div>
</div>
{#if downloadPath}
<div class="bg-blue-50 border border-blue-200 p-3 rounded-md">
<div class="text-sm text-blue-800">
<strong>File will be saved as:</strong>
<div class="font-mono text-xs mt-1 break-all">{downloadPath}</div>
</div>
</div>
{/if}
<button
on:click={startDownload}
class="w-full px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isDownloading || !downloadUrl || !downloadFolder}
>
{isDownloading ? 'Downloading...' : 'Download File'}
</button>
{#if downloadProgress}
<div class="bg-white p-3 rounded border">
<div class="flex justify-between text-sm text-gray-600 mb-1">
<span>Progress: {downloadProgress.percentage}%</span>
<span>Speed: {Math.round(downloadProgress.transferSpeed / 1024)} KB/s</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="bg-blue-500 h-2 rounded-full transition-all duration-300"
style="width: {downloadProgress.percentage}%"
></div>
</div>
<div class="text-xs text-gray-500 mt-1">
{Math.round(downloadProgress.progressTotal / 1024)} KB / {Math.round(downloadProgress.total / 1024)} KB
</div>
</div>
{/if}
{#if downloadResult}
<div class="bg-white p-3 rounded border">
<JsonView json={downloadResult} />
</div>
{/if}
</div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-semibold mb-4 text-gray-800">File Upload</h3>
<div class="space-y-3">
<div>
<label for="upload-url" class="block text-sm font-medium text-gray-700 mb-1">Upload URL:</label>
<input
id="upload-url"
bind:value={uploadUrl}
type="url"
placeholder="https://httpbin.org/post"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500"
disabled={isUploading}
/>
</div>
<div>
<label for="upload-file" class="block text-sm font-medium text-gray-700 mb-1">File to upload:</label>
<div class="flex gap-2">
<input
id="upload-file"
bind:value={uploadFilePath}
type="text"
placeholder="Select file to upload..."
class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500"
disabled={isUploading}
/>
<button
on:click={selectUploadFile}
class="px-4 py-2 bg-gray-500 text-white rounded-md hover:bg-gray-600 disabled:opacity-50"
disabled={isUploading}
>
Browse
</button>
</div>
</div>
<button
on:click={startUpload}
class="w-full px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isUploading || !uploadUrl || !uploadFilePath}
>
{isUploading ? 'Uploading...' : 'Upload File'}
</button>
{#if uploadProgress}
<div class="bg-white p-3 rounded border">
<div class="flex justify-between text-sm text-gray-600 mb-1">
<span>Progress: {uploadProgress.percentage}%</span>
<span>Speed: {Math.round(uploadProgress.transferSpeed / 1024)} KB/s</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="bg-green-500 h-2 rounded-full transition-all duration-300"
style="width: {uploadProgress.percentage}%"
></div>
</div>
<div class="text-xs text-gray-500 mt-1">
{Math.round(uploadProgress.progressTotal / 1024)} KB / {Math.round(uploadProgress.total / 1024)} KB
</div>
</div>
{/if}
{#if uploadResult}
<div class="bg-white p-3 rounded border">
<JsonView json={uploadResult} />
</div>
{/if}
</div>
</div>
</div>

@ -72,44 +72,50 @@ async fn download(
body: Option<String>,
on_progress: Channel<ProgressPayload>,
) -> 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 url = url.to_string();
let file_path = file_path.to_string();
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);
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::other(e.to_string())))?
}
#[command]
@ -119,46 +125,53 @@ async fn upload(
headers: HashMap<String, String>,
on_progress: Channel<ProgressPayload>,
) -> Result<String> {
// 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();
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(),
))
}
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, file_len));
// 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(),
))
}
})
.await
.map_err(|e| Error::Io(std::io::Error::other(e.to_string())))?
}
fn file_to_body(channel: Channel<ProgressPayload>, file: File) -> reqwest::Body {
fn file_to_body(channel: Channel<ProgressPayload>, 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,
});
}),
@ -183,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();
@ -202,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<ProgressPayload> =
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();
@ -213,6 +271,17 @@ mod tests {
download(url, file_path, headers, None, sender).await
}
async fn upload_file(url: &str) -> Result<String> {
let file_path = concat!(env!("CARGO_MANIFEST_DIR"), "/test/test.txt");
let headers = HashMap::new();
let sender: Channel<ProgressPayload> =
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";
@ -230,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,
}
}
}

@ -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

Loading…
Cancel
Save