feat(desktop): native download and working context menu actions
This commit is contained in:
@@ -2,6 +2,10 @@ use reqwest::Method;
|
||||
use reqwest::StatusCode;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Map, Value};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
struct ApiState {
|
||||
@@ -42,6 +46,68 @@ fn fallback_json(status: StatusCode, text: &str) -> Value {
|
||||
Value::Object(data)
|
||||
}
|
||||
|
||||
fn sanitize_file_name(name: &str) -> String {
|
||||
let raw = name.trim();
|
||||
let mut cleaned = String::with_capacity(raw.len());
|
||||
for ch in raw.chars() {
|
||||
if matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' | '\0') {
|
||||
cleaned.push('_');
|
||||
} else {
|
||||
cleaned.push(ch);
|
||||
}
|
||||
}
|
||||
let normalized = cleaned.trim().trim_matches('.').to_string();
|
||||
if normalized.is_empty() {
|
||||
"download.bin".to_string()
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_download_dir() -> PathBuf {
|
||||
if let Some(home) = env::var_os("USERPROFILE") {
|
||||
return PathBuf::from(home).join("Downloads");
|
||||
}
|
||||
if let Some(home) = env::var_os("HOME") {
|
||||
return PathBuf::from(home).join("Downloads");
|
||||
}
|
||||
PathBuf::from(".")
|
||||
}
|
||||
|
||||
fn split_file_name(name: &str) -> (String, String) {
|
||||
if let Some(index) = name.rfind('.') {
|
||||
if index > 0 && index < name.len() - 1 {
|
||||
let stem = name[..index].to_string();
|
||||
let ext = name[index + 1..].to_string();
|
||||
return (stem, ext);
|
||||
}
|
||||
}
|
||||
(name.to_string(), String::new())
|
||||
}
|
||||
|
||||
fn alloc_download_path(download_dir: &Path, preferred_name: &str) -> PathBuf {
|
||||
let safe_name = sanitize_file_name(preferred_name);
|
||||
let first = download_dir.join(&safe_name);
|
||||
if !first.exists() {
|
||||
return first;
|
||||
}
|
||||
|
||||
let (stem, ext) = split_file_name(&safe_name);
|
||||
for index in 1..10000 {
|
||||
let candidate_name = if ext.is_empty() {
|
||||
format!("{} ({})", stem, index)
|
||||
} else {
|
||||
format!("{} ({}).{}", stem, index, ext)
|
||||
};
|
||||
let candidate = download_dir.join(candidate_name);
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
first
|
||||
}
|
||||
|
||||
async fn request_json(
|
||||
client: &reqwest::Client,
|
||||
method: Method,
|
||||
@@ -451,6 +517,84 @@ async fn api_create_direct_link(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn api_native_download(
|
||||
state: tauri::State<'_, ApiState>,
|
||||
url: String,
|
||||
file_name: Option<String>,
|
||||
) -> Result<BridgeResponse, String> {
|
||||
let trimmed_url = url.trim().to_string();
|
||||
if trimmed_url.is_empty() {
|
||||
return Err("下载地址不能为空".to_string());
|
||||
}
|
||||
|
||||
let response = state
|
||||
.client
|
||||
.get(&trimmed_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("下载请求失败: {}", err))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Ok(BridgeResponse {
|
||||
ok: false,
|
||||
status: status.as_u16(),
|
||||
data: fallback_json(status, "下载失败"),
|
||||
});
|
||||
}
|
||||
|
||||
let preferred_name = file_name
|
||||
.as_deref()
|
||||
.map(|name| name.trim())
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or("download.bin");
|
||||
|
||||
let download_dir = resolve_download_dir();
|
||||
if !download_dir.exists() {
|
||||
fs::create_dir_all(&download_dir)
|
||||
.map_err(|err| format!("创建下载目录失败: {}", err))?;
|
||||
}
|
||||
|
||||
let save_path = alloc_download_path(&download_dir, preferred_name);
|
||||
let mut target_file =
|
||||
fs::File::create(&save_path).map_err(|err| format!("创建文件失败: {}", err))?;
|
||||
|
||||
let mut downloaded_bytes: u64 = 0;
|
||||
let mut stream = response;
|
||||
while let Some(chunk) = stream
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|err| format!("读取下载流失败: {}", err))?
|
||||
{
|
||||
target_file
|
||||
.write_all(&chunk)
|
||||
.map_err(|err| format!("写入文件失败: {}", err))?;
|
||||
downloaded_bytes += chunk.len() as u64;
|
||||
}
|
||||
|
||||
target_file
|
||||
.flush()
|
||||
.map_err(|err| format!("刷新文件失败: {}", err))?;
|
||||
|
||||
let mut data = Map::new();
|
||||
data.insert("success".to_string(), Value::Bool(true));
|
||||
data.insert(
|
||||
"savePath".to_string(),
|
||||
Value::String(save_path.to_string_lossy().to_string()),
|
||||
);
|
||||
data.insert(
|
||||
"downloadedBytes".to_string(),
|
||||
Value::Number(serde_json::Number::from(downloaded_bytes)),
|
||||
);
|
||||
|
||||
Ok(BridgeResponse {
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: Value::Object(data),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let client = reqwest::Client::builder()
|
||||
@@ -475,7 +619,8 @@ pub fn run() {
|
||||
api_get_my_shares,
|
||||
api_create_share,
|
||||
api_delete_share,
|
||||
api_create_direct_link
|
||||
api_create_direct_link,
|
||||
api_native_download
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
Reference in New Issue
Block a user