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");
|
||||
|
||||
@@ -270,6 +270,17 @@ function showToast(message: string, type = "info") {
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
function prependTransferTask(task: { id: string; name: string; speed: string; progress: number; status: string }) {
|
||||
transferTasks.value = [task, ...transferTasks.value.slice(0, 49)];
|
||||
}
|
||||
|
||||
function updateTransferTask(
|
||||
id: string,
|
||||
patch: Partial<{ name: string; speed: string; progress: number; status: string }>,
|
||||
) {
|
||||
transferTasks.value = transferTasks.value.map((task) => (task.id === id ? { ...task, ...patch } : task));
|
||||
}
|
||||
|
||||
async function invokeBridge(command: string, payload: Record<string, any>) {
|
||||
try {
|
||||
return await invoke<BridgeResponse>(command, payload);
|
||||
@@ -328,6 +339,20 @@ async function loadShares(silent = false) {
|
||||
if (!silent) sharesLoading.value = false;
|
||||
}
|
||||
|
||||
async function getSignedUrlForItem(item: FileItem, mode: "download" | "preview") {
|
||||
const targetPath = buildItemPath(item);
|
||||
const response = await invokeBridge("api_get_download_url", {
|
||||
baseUrl: appConfig.baseUrl,
|
||||
path: targetPath,
|
||||
mode,
|
||||
});
|
||||
if (response.ok && response.data?.success && response.data?.downloadUrl) {
|
||||
return String(response.data.downloadUrl);
|
||||
}
|
||||
showToast(response.data?.message || "获取链接失败", "error");
|
||||
return "";
|
||||
}
|
||||
|
||||
async function createShareForItem(current: FileItem) {
|
||||
const response = await invokeBridge("api_create_share", {
|
||||
baseUrl: appConfig.baseUrl,
|
||||
@@ -571,28 +596,32 @@ async function downloadSelected(target?: FileItem | null) {
|
||||
showToast("当前仅支持下载文件", "info");
|
||||
return;
|
||||
}
|
||||
const targetPath = buildItemPath(current);
|
||||
const response = await invokeBridge("api_get_download_url", {
|
||||
baseUrl: appConfig.baseUrl,
|
||||
path: targetPath,
|
||||
mode: "download",
|
||||
});
|
||||
if (response.ok && response.data?.success && response.data?.downloadUrl) {
|
||||
await openUrl(response.data.downloadUrl);
|
||||
showToast("已调用系统浏览器开始下载", "success");
|
||||
transferTasks.value = [
|
||||
{
|
||||
id: `D-${Date.now()}`,
|
||||
const signedUrl = await getSignedUrlForItem(current, "download");
|
||||
if (!signedUrl) return;
|
||||
|
||||
const taskId = `D-${Date.now()}`;
|
||||
prependTransferTask({
|
||||
id: taskId,
|
||||
name: current.displayName || current.name,
|
||||
speed: "直连",
|
||||
progress: 100,
|
||||
status: "dispatched",
|
||||
},
|
||||
...transferTasks.value.slice(0, 29),
|
||||
];
|
||||
speed: "原生下载",
|
||||
progress: 1,
|
||||
status: "downloading",
|
||||
});
|
||||
|
||||
const nativeResponse = await invokeBridge("api_native_download", {
|
||||
url: signedUrl,
|
||||
fileName: current.displayName || current.name,
|
||||
});
|
||||
|
||||
if (nativeResponse.ok && nativeResponse.data?.success) {
|
||||
updateTransferTask(taskId, { speed: "-", progress: 100, status: "done" });
|
||||
const savedPath = nativeResponse.data?.savePath ? `\n${nativeResponse.data.savePath}` : "";
|
||||
showToast(`下载完成${savedPath}`, "success");
|
||||
return;
|
||||
}
|
||||
showToast(response.data?.message || "获取下载链接失败", "error");
|
||||
|
||||
updateTransferTask(taskId, { speed: "-", progress: 0, status: "failed" });
|
||||
showToast(nativeResponse.data?.message || "原生下载失败", "error");
|
||||
}
|
||||
|
||||
function selectFile(item: FileItem) {
|
||||
@@ -604,6 +633,11 @@ async function openItem(item: FileItem) {
|
||||
if (item.isDirectory || item.type === "directory") {
|
||||
const nextPath = buildItemPath(item);
|
||||
await loadFiles(nextPath);
|
||||
return;
|
||||
}
|
||||
const previewUrl = await getSignedUrlForItem(item, "preview");
|
||||
if (previewUrl) {
|
||||
await openUrl(previewUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -921,7 +955,7 @@ onBeforeUnmount(() => {
|
||||
@click.stop
|
||||
>
|
||||
<button class="context-item" @click="executeContextAction('open')">
|
||||
{{ contextMenu.item.isDirectory ? "打开文件夹" : "打开/下载" }}
|
||||
{{ contextMenu.item.isDirectory ? "打开文件夹" : "打开预览" }}
|
||||
</button>
|
||||
<button v-if="!contextMenu.item.isDirectory" class="context-item" @click="executeContextAction('download')">下载文件</button>
|
||||
<button class="context-item" @click="executeContextAction('rename')">重命名</button>
|
||||
|
||||
Reference in New Issue
Block a user