feat(desktop): stream download progress and auto-launch installer
This commit is contained in:
@@ -7,7 +7,9 @@ use std::fs;
|
|||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::io::{Read, Seek, SeekFrom};
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{Duration, UNIX_EPOCH};
|
use std::process::Command;
|
||||||
|
use std::time::{Duration, Instant, UNIX_EPOCH};
|
||||||
|
use tauri::Emitter;
|
||||||
|
|
||||||
struct ApiState {
|
struct ApiState {
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
@@ -21,6 +23,47 @@ struct BridgeResponse {
|
|||||||
data: Value,
|
data: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct NativeDownloadProgressPayload {
|
||||||
|
task_id: String,
|
||||||
|
downloaded_bytes: u64,
|
||||||
|
total_bytes: Option<u64>,
|
||||||
|
progress: Option<f64>,
|
||||||
|
resumed_bytes: u64,
|
||||||
|
done: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_native_download_progress(
|
||||||
|
window: &tauri::WebviewWindow,
|
||||||
|
task_id: &str,
|
||||||
|
downloaded_bytes: u64,
|
||||||
|
total_bytes: Option<u64>,
|
||||||
|
resumed_bytes: u64,
|
||||||
|
done: bool,
|
||||||
|
) {
|
||||||
|
if task_id.trim().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let progress = total_bytes
|
||||||
|
.filter(|total| *total > 0)
|
||||||
|
.map(|total| (downloaded_bytes as f64 / total as f64) * 100.0);
|
||||||
|
|
||||||
|
let payload = NativeDownloadProgressPayload {
|
||||||
|
task_id: task_id.to_string(),
|
||||||
|
downloaded_bytes,
|
||||||
|
total_bytes,
|
||||||
|
progress,
|
||||||
|
resumed_bytes,
|
||||||
|
done,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = window.emit("native-download-progress", payload) {
|
||||||
|
eprintln!("emit native-download-progress failed: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_base_url(base_url: &str) -> String {
|
fn normalize_base_url(base_url: &str) -> String {
|
||||||
let trimmed = base_url.trim();
|
let trimmed = base_url.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -550,8 +593,10 @@ async fn api_create_direct_link(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn api_native_download(
|
async fn api_native_download(
|
||||||
state: tauri::State<'_, ApiState>,
|
state: tauri::State<'_, ApiState>,
|
||||||
|
window: tauri::WebviewWindow,
|
||||||
url: String,
|
url: String,
|
||||||
file_name: Option<String>,
|
file_name: Option<String>,
|
||||||
|
task_id: Option<String>,
|
||||||
) -> Result<BridgeResponse, String> {
|
) -> Result<BridgeResponse, String> {
|
||||||
let trimmed_url = url.trim().to_string();
|
let trimmed_url = url.trim().to_string();
|
||||||
if trimmed_url.is_empty() {
|
if trimmed_url.is_empty() {
|
||||||
@@ -595,6 +640,16 @@ async fn api_native_download(
|
|||||||
let save_path = alloc_download_path(&download_dir, preferred_name);
|
let save_path = alloc_download_path(&download_dir, preferred_name);
|
||||||
fs::rename(&resume_temp_path, &save_path)
|
fs::rename(&resume_temp_path, &save_path)
|
||||||
.map_err(|err| format!("完成断点下载失败: {}", err))?;
|
.map_err(|err| format!("完成断点下载失败: {}", err))?;
|
||||||
|
if let Some(ref id) = task_id {
|
||||||
|
emit_native_download_progress(
|
||||||
|
&window,
|
||||||
|
id,
|
||||||
|
existing_size,
|
||||||
|
Some(existing_size),
|
||||||
|
existing_size,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
let mut data = Map::new();
|
let mut data = Map::new();
|
||||||
data.insert("success".to_string(), Value::Bool(true));
|
data.insert("success".to_string(), Value::Bool(true));
|
||||||
data.insert(
|
data.insert(
|
||||||
@@ -625,6 +680,26 @@ async fn api_native_download(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let append_mode = existing_size > 0 && status == reqwest::StatusCode::PARTIAL_CONTENT;
|
let append_mode = existing_size > 0 && status == reqwest::StatusCode::PARTIAL_CONTENT;
|
||||||
|
let total_bytes = if append_mode {
|
||||||
|
response
|
||||||
|
.content_length()
|
||||||
|
.map(|remaining| remaining.saturating_add(existing_size))
|
||||||
|
} else {
|
||||||
|
response.content_length()
|
||||||
|
};
|
||||||
|
let resumed_bytes = if append_mode { existing_size } else { 0 };
|
||||||
|
|
||||||
|
if let Some(ref id) = task_id {
|
||||||
|
emit_native_download_progress(
|
||||||
|
&window,
|
||||||
|
id,
|
||||||
|
if append_mode { existing_size } else { 0 },
|
||||||
|
total_bytes,
|
||||||
|
resumed_bytes,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if !append_mode && resume_temp_path.exists() {
|
if !append_mode && resume_temp_path.exists() {
|
||||||
fs::remove_file(&resume_temp_path)
|
fs::remove_file(&resume_temp_path)
|
||||||
.map_err(|err| format!("重置断点下载文件失败: {}", err))?;
|
.map_err(|err| format!("重置断点下载文件失败: {}", err))?;
|
||||||
@@ -640,6 +715,7 @@ async fn api_native_download(
|
|||||||
|
|
||||||
let mut downloaded_bytes: u64 = if append_mode { existing_size } else { 0 };
|
let mut downloaded_bytes: u64 = if append_mode { existing_size } else { 0 };
|
||||||
let mut stream = response;
|
let mut stream = response;
|
||||||
|
let mut last_emit = Instant::now();
|
||||||
while let Some(chunk) = stream
|
while let Some(chunk) = stream
|
||||||
.chunk()
|
.chunk()
|
||||||
.await
|
.await
|
||||||
@@ -649,6 +725,20 @@ async fn api_native_download(
|
|||||||
.write_all(&chunk)
|
.write_all(&chunk)
|
||||||
.map_err(|err| format!("写入文件失败: {}", err))?;
|
.map_err(|err| format!("写入文件失败: {}", err))?;
|
||||||
downloaded_bytes += chunk.len() as u64;
|
downloaded_bytes += chunk.len() as u64;
|
||||||
|
|
||||||
|
if let Some(ref id) = task_id {
|
||||||
|
if last_emit.elapsed() >= Duration::from_millis(120) {
|
||||||
|
emit_native_download_progress(
|
||||||
|
&window,
|
||||||
|
id,
|
||||||
|
downloaded_bytes,
|
||||||
|
total_bytes,
|
||||||
|
resumed_bytes,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
last_emit = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
target_file
|
target_file
|
||||||
@@ -659,6 +749,17 @@ async fn api_native_download(
|
|||||||
fs::rename(&resume_temp_path, &save_path)
|
fs::rename(&resume_temp_path, &save_path)
|
||||||
.map_err(|err| format!("保存下载文件失败: {}", err))?;
|
.map_err(|err| format!("保存下载文件失败: {}", err))?;
|
||||||
|
|
||||||
|
if let Some(ref id) = task_id {
|
||||||
|
emit_native_download_progress(
|
||||||
|
&window,
|
||||||
|
id,
|
||||||
|
downloaded_bytes,
|
||||||
|
total_bytes.or(Some(downloaded_bytes)),
|
||||||
|
resumed_bytes,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let mut data = Map::new();
|
let mut data = Map::new();
|
||||||
data.insert("success".to_string(), Value::Bool(true));
|
data.insert("success".to_string(), Value::Bool(true));
|
||||||
data.insert(
|
data.insert(
|
||||||
@@ -681,6 +782,44 @@ async fn api_native_download(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn api_launch_installer(installer_path: String) -> Result<BridgeResponse, String> {
|
||||||
|
let path_text = installer_path.trim().to_string();
|
||||||
|
if path_text.is_empty() {
|
||||||
|
return Err("安装包路径不能为空".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let installer = PathBuf::from(&path_text);
|
||||||
|
if !installer.exists() {
|
||||||
|
return Err("安装包不存在,请重新下载".to_string());
|
||||||
|
}
|
||||||
|
if !installer.is_file() {
|
||||||
|
return Err("安装包路径无效".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let spawn_result = Command::new(&installer).spawn();
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let spawn_result = Command::new("open").arg(&installer).spawn();
|
||||||
|
|
||||||
|
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
|
||||||
|
let spawn_result = Command::new("xdg-open").arg(&installer).spawn();
|
||||||
|
|
||||||
|
spawn_result.map_err(|err| format!("启动安装程序失败: {}", err))?;
|
||||||
|
|
||||||
|
let mut data = Map::new();
|
||||||
|
data.insert("success".to_string(), Value::Bool(true));
|
||||||
|
data.insert("message".to_string(), Value::String("安装程序已启动".to_string()));
|
||||||
|
data.insert("installerPath".to_string(), Value::String(path_text));
|
||||||
|
|
||||||
|
Ok(BridgeResponse {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: Value::Object(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn api_check_client_update(
|
async fn api_check_client_update(
|
||||||
state: tauri::State<'_, ApiState>,
|
state: tauri::State<'_, ApiState>,
|
||||||
@@ -1060,6 +1199,7 @@ pub fn run() {
|
|||||||
api_delete_share,
|
api_delete_share,
|
||||||
api_create_direct_link,
|
api_create_direct_link,
|
||||||
api_native_download,
|
api_native_download,
|
||||||
|
api_launch_installer,
|
||||||
api_check_client_update,
|
api_check_client_update,
|
||||||
api_list_local_files,
|
api_list_local_files,
|
||||||
api_upload_file_resumable,
|
api_upload_file_resumable,
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { invoke } from "@tauri-apps/api/core";
|
|||||||
import { openPath, openUrl } from "@tauri-apps/plugin-opener";
|
import { openPath, openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||||
|
|
||||||
type NavKey = "files" | "transfers" | "shares" | "sync" | "updates";
|
type NavKey = "files" | "transfers" | "shares" | "sync" | "updates";
|
||||||
@@ -40,6 +41,15 @@ type BridgeResponse = {
|
|||||||
data: Record<string, any>;
|
data: Record<string, any>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NativeDownloadProgressEvent = {
|
||||||
|
taskId?: string;
|
||||||
|
downloadedBytes?: number;
|
||||||
|
totalBytes?: number | null;
|
||||||
|
progress?: number | null;
|
||||||
|
resumedBytes?: number;
|
||||||
|
done?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type LocalSyncFileItem = {
|
type LocalSyncFileItem = {
|
||||||
path: string;
|
path: string;
|
||||||
relativePath: string;
|
relativePath: string;
|
||||||
@@ -149,6 +159,7 @@ const dropState = reactive({
|
|||||||
failed: 0,
|
failed: 0,
|
||||||
});
|
});
|
||||||
let unlistenDragDrop: UnlistenFn | null = null;
|
let unlistenDragDrop: UnlistenFn | null = null;
|
||||||
|
let unlistenNativeDownloadProgress: UnlistenFn | null = null;
|
||||||
let syncTimer: ReturnType<typeof setInterval> | null = null;
|
let syncTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
const toast = reactive({
|
const toast = reactive({
|
||||||
@@ -347,6 +358,35 @@ function fileTypeLabel(item: FileItem) {
|
|||||||
return "文件";
|
return "文件";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyNativeDownloadProgress(payload: NativeDownloadProgressEvent) {
|
||||||
|
const taskId = String(payload?.taskId || "").trim();
|
||||||
|
if (!taskId) return;
|
||||||
|
|
||||||
|
const downloadedBytes = Number(payload?.downloadedBytes || 0);
|
||||||
|
const totalBytesRaw = payload?.totalBytes;
|
||||||
|
const totalBytes = totalBytesRaw === null || totalBytesRaw === undefined ? NaN : Number(totalBytesRaw);
|
||||||
|
const eventProgress = Number(payload?.progress);
|
||||||
|
const calculatedProgress = Number.isFinite(eventProgress)
|
||||||
|
? eventProgress
|
||||||
|
: (Number.isFinite(totalBytes) && totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : NaN);
|
||||||
|
const boundedProgress = Number.isFinite(calculatedProgress)
|
||||||
|
? Math.max(1, Math.min(payload?.done ? 100 : 99.8, calculatedProgress))
|
||||||
|
: NaN;
|
||||||
|
|
||||||
|
const patch: Partial<TransferTask> = {
|
||||||
|
speed: "下载中",
|
||||||
|
status: "downloading",
|
||||||
|
note: Number.isFinite(totalBytes) && totalBytes > 0
|
||||||
|
? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}`
|
||||||
|
: `已下载 ${formatBytes(downloadedBytes)}`,
|
||||||
|
};
|
||||||
|
if (Number.isFinite(boundedProgress)) {
|
||||||
|
patch.progress = Number(boundedProgress.toFixed(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTransferTask(taskId, patch);
|
||||||
|
}
|
||||||
|
|
||||||
function fileIcon(item: FileItem) {
|
function fileIcon(item: FileItem) {
|
||||||
if (item.isDirectory || item.type === "directory") return "📁";
|
if (item.isDirectory || item.type === "directory") return "📁";
|
||||||
const name = String(item.name || "").toLowerCase();
|
const name = String(item.name || "").toLowerCase();
|
||||||
@@ -674,6 +714,7 @@ async function installLatestUpdate(): Promise<boolean> {
|
|||||||
const response = await invokeBridge("api_native_download", {
|
const response = await invokeBridge("api_native_download", {
|
||||||
url: updateState.downloadUrl,
|
url: updateState.downloadUrl,
|
||||||
fileName: installerName,
|
fileName: installerName,
|
||||||
|
taskId,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -686,13 +727,30 @@ async function installLatestUpdate(): Promise<boolean> {
|
|||||||
});
|
});
|
||||||
const savePath = String(response.data?.savePath || "").trim();
|
const savePath = String(response.data?.savePath || "").trim();
|
||||||
if (savePath) {
|
if (savePath) {
|
||||||
|
const launchResponse = await invokeBridge("api_launch_installer", {
|
||||||
|
installerPath: savePath,
|
||||||
|
});
|
||||||
|
if (launchResponse.ok && launchResponse.data?.success) {
|
||||||
|
updateTransferTask(taskId, {
|
||||||
|
speed: "-",
|
||||||
|
progress: 100,
|
||||||
|
status: "done",
|
||||||
|
note: "安装程序已启动,客户端即将退出",
|
||||||
|
});
|
||||||
|
showToast("安装程序已启动,客户端即将退出", "success");
|
||||||
|
setTimeout(() => {
|
||||||
|
void getCurrentWindow().close();
|
||||||
|
}, 400);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await openPath(savePath);
|
await openPath(savePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("open installer failed", error);
|
console.error("open installer fallback failed", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
showToast("更新包已下载,已尝试启动安装程序", "success");
|
showToast("更新包已下载,请手动运行安装程序", "info");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1258,6 +1316,7 @@ async function downloadSelected(target?: FileItem | null) {
|
|||||||
const nativeResponse = await invokeBridge("api_native_download", {
|
const nativeResponse = await invokeBridge("api_native_download", {
|
||||||
url: signedUrl,
|
url: signedUrl,
|
||||||
fileName: current.displayName || current.name,
|
fileName: current.displayName || current.name,
|
||||||
|
taskId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (nativeResponse.ok && nativeResponse.data?.success) {
|
if (nativeResponse.ok && nativeResponse.data?.success) {
|
||||||
@@ -1387,6 +1446,7 @@ async function retryTransferTask(taskId: string) {
|
|||||||
const response = await invokeBridge("api_native_download", {
|
const response = await invokeBridge("api_native_download", {
|
||||||
url: task.downloadUrl,
|
url: task.downloadUrl,
|
||||||
fileName: task.fileName || task.name,
|
fileName: task.fileName || task.name,
|
||||||
|
taskId,
|
||||||
});
|
});
|
||||||
if (response.ok && response.data?.success) {
|
if (response.ok && response.data?.success) {
|
||||||
const resumedBytes = Number(response.data?.resumedBytes || 0);
|
const resumedBytes = Number(response.data?.resumedBytes || 0);
|
||||||
@@ -1595,6 +1655,16 @@ async function registerDragDropListener() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function registerNativeDownloadProgressListener() {
|
||||||
|
try {
|
||||||
|
unlistenNativeDownloadProgress = await listen<NativeDownloadProgressEvent>("native-download-progress", (event) => {
|
||||||
|
applyNativeDownloadProgress(event.payload || {});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("register native download progress listener failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(nav, async (next) => {
|
watch(nav, async (next) => {
|
||||||
if (next === "shares" && authenticated.value) {
|
if (next === "shares" && authenticated.value) {
|
||||||
await loadShares();
|
await loadShares();
|
||||||
@@ -1622,6 +1692,7 @@ onMounted(async () => {
|
|||||||
window.addEventListener("click", handleGlobalClick);
|
window.addEventListener("click", handleGlobalClick);
|
||||||
window.addEventListener("keydown", handleGlobalKey);
|
window.addEventListener("keydown", handleGlobalKey);
|
||||||
await registerDragDropListener();
|
await registerDragDropListener();
|
||||||
|
await registerNativeDownloadProgressListener();
|
||||||
await initClientVersion();
|
await initClientVersion();
|
||||||
await restoreSession();
|
await restoreSession();
|
||||||
});
|
});
|
||||||
@@ -1634,6 +1705,10 @@ onBeforeUnmount(() => {
|
|||||||
unlistenDragDrop();
|
unlistenDragDrop();
|
||||||
unlistenDragDrop = null;
|
unlistenDragDrop = null;
|
||||||
}
|
}
|
||||||
|
if (unlistenNativeDownloadProgress) {
|
||||||
|
unlistenNativeDownloadProgress();
|
||||||
|
unlistenNativeDownloadProgress = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1843,6 +1918,7 @@ onBeforeUnmount(() => {
|
|||||||
<div class="progress">
|
<div class="progress">
|
||||||
<div class="bar" :style="{ width: `${task.progress}%` }" />
|
<div class="bar" :style="{ width: `${task.progress}%` }" />
|
||||||
</div>
|
</div>
|
||||||
|
<small class="task-percent">{{ Math.max(0, Math.min(100, Math.round(task.progress))) }}%</small>
|
||||||
<div class="task-actions">
|
<div class="task-actions">
|
||||||
<button v-if="task.status === 'failed'" class="mini-btn" @click="retryTransferTask(task.id)">重试</button>
|
<button v-if="task.status === 'failed'" class="mini-btn" @click="retryTransferTask(task.id)">重试</button>
|
||||||
<button v-if="task.status === 'done' || task.status === 'failed'" class="mini-btn ghost" @click="removeTransferTask(task.id)">移除</button>
|
<button v-if="task.status === 'done' || task.status === 'failed'" class="mini-btn ghost" @click="removeTransferTask(task.id)">移除</button>
|
||||||
@@ -2793,8 +2869,9 @@ select:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress {
|
.progress {
|
||||||
height: 8px;
|
height: 10px;
|
||||||
background: #e4edf9;
|
background: #dbe7f8;
|
||||||
|
border: 1px solid #cadef6;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -2802,6 +2879,14 @@ select:focus {
|
|||||||
.bar {
|
.bar {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, #1d6fff, #57a2ff);
|
background: linear-gradient(90deg, #1d6fff, #57a2ff);
|
||||||
|
transition: width 0.14s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-percent {
|
||||||
|
justify-self: end;
|
||||||
|
color: #3f5f86;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-list {
|
.share-list {
|
||||||
|
|||||||
Reference in New Issue
Block a user