diff options
| author | 2026-01-13 15:56:20 +0800 | |
|---|---|---|
| committer | 2026-01-13 15:56:20 +0800 | |
| commit | 66f7825ed9638606665b9e61c6f8132de013da14 (patch) | |
| tree | 764c30309a1c5a77f3b0d92d5131b1b50ae50402 | |
| parent | 6fbb5f19cab02f3a0f18cdeda3da02e717b69cd6 (diff) | |
| download | DropOut-66f7825ed9638606665b9e61c6f8132de013da14.tar.gz DropOut-66f7825ed9638606665b9e61c6f8132de013da14.zip | |
feat: implement download functionality with progress monitoring and version management
| -rw-r--r-- | src-tauri/Cargo.toml | 3 | ||||
| -rw-r--r-- | src-tauri/src/core/downloader.rs | 133 | ||||
| -rw-r--r-- | src-tauri/src/core/game_version.rs | 66 | ||||
| -rw-r--r-- | src-tauri/src/core/mod.rs | 1 | ||||
| -rw-r--r-- | src-tauri/src/main.rs | 64 | ||||
| -rw-r--r-- | ui/src/App.svelte | 27 | ||||
| -rw-r--r-- | ui/src/lib/DownloadMonitor.svelte | 92 |
7 files changed, 335 insertions, 51 deletions
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0091ed4..1c81ade 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,6 +14,9 @@ serde_json = "1.0.149" tauri = { version = "2.0.0", features = [] } tauri-plugin-shell = "2.0.0" uuid = { version = "1.10.0", features = ["v3", "v4", "serde"] } +futures = "0.3" +sha1 = "0.10" +hex = "0.4" [build-dependencies] tauri-build = { version = "2.0.0", features = [] } diff --git a/src-tauri/src/core/downloader.rs b/src-tauri/src/core/downloader.rs index 8d717be..0ba9aec 100644 --- a/src-tauri/src/core/downloader.rs +++ b/src-tauri/src/core/downloader.rs @@ -1,8 +1,12 @@ use std::path::PathBuf; -use tokio::sync::mpsc; use serde::{Serialize, Deserialize}; +use tauri::{Emitter, Window}; +use futures::StreamExt; +use tokio::io::AsyncWriteExt; +use std::sync::Arc; +use tokio::sync::Semaphore; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct DownloadTask { pub url: String, pub path: PathBuf, @@ -10,42 +14,115 @@ pub struct DownloadTask { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub enum DownloadProgress { - Started(String), - Progress { file: String, downloaded: u64, total: u64 }, - Finished(String), - Error(String, String), -} - -pub struct Downloader { - sender: mpsc::Sender<DownloadProgress>, +pub struct ProgressEvent { + pub file: String, + pub downloaded: u64, + pub total: u64, + pub status: String, // "Downloading", "Verifying", "Finished", "Error" } -impl Downloader { - pub fn new(sender: mpsc::Sender<DownloadProgress>) -> Self { - Self { sender } - } +pub async fn download_files(window: Window, tasks: Vec<DownloadTask>) -> Result<(), String> { + let client = reqwest::Client::new(); + let semaphore = Arc::new(Semaphore::new(10)); // Max 10 concurrent downloads + + // Notify start (total files) + let _ = window.emit("download-start", tasks.len()); - pub async fn download(&self, tasks: Vec<DownloadTask>) { - // TODO: Implement parallel download with limits - // Use futures::stream::StreamExt::buffer_unordered + let tasks_stream = futures::stream::iter(tasks).map(|task| { + let client = client.clone(); + let window = window.clone(); + let semaphore = semaphore.clone(); - for task in tasks { - if let Err(_) = self.sender.send(DownloadProgress::Started(task.url.clone())).await { - break; + async move { + let _permit = semaphore.acquire().await.unwrap(); + let file_name = task.path.file_name().unwrap().to_string_lossy().to_string(); + + // 1. Check if file exists and verify SHA1 + if task.path.exists() { + let _ = window.emit("download-progress", ProgressEvent { + file: file_name.clone(), + downloaded: 0, + total: 0, + status: "Verifying".into(), + }); + + if let Some(expected_sha1) = &task.sha1 { + if let Ok(data) = tokio::fs::read(&task.path).await { + let mut hasher = sha1::Sha1::new(); + use sha1::Digest; + hasher.update(&data); + let result = hex::encode(hasher.finalize()); + if &result == expected_sha1 { + // Already valid + let _ = window.emit("download-progress", ProgressEvent { + file: file_name.clone(), + downloaded: 0, + total: 0, + status: "Skipped".into(), + }); + return Ok(()); + } + } + } } - // Simulate download for now or implement basic - // Ensure directory exists + // 2. Download if let Some(parent) = task.path.parent() { let _ = tokio::fs::create_dir_all(parent).await; } - // Real implementation would use reqwest here - - if let Err(_) = self.sender.send(DownloadProgress::Finished(task.url)).await { - break; + match client.get(&task.url).send().await { + Ok(resp) => { + let total_size = resp.content_length().unwrap_or(0); + let mut file = match tokio::fs::File::create(&task.path).await { + Ok(f) => f, + Err(e) => return Err(format!("Create file error: {}", e)), + }; + + // reqwest::Response::bytes_stream() is only available if the 'stream' feature is enabled + // But we used 'blocking' and 'json'. We should add 'stream' feature to Cargo.toml? + // Or just use chunk(). + // Actually, let's just create a loop if stream feature is missing or use chunk() manually if blocking is used? + // Wait, we are in async context. 'reqwest' dependency in Cargo.toml has 'json', 'blocking' + // We need 'stream' feature for .bytes_stream() + + // Let's use loop with chunk() + loop { + match resp.chunk().await { + Ok(Some(chunk)) => { + if let Err(e) = file.write_all(&chunk).await { + return Err(format!("Write error: {}", e)); + } + downloaded += chunk.len() as u64; + let _ = window.emit("download-progress", ProgressEvent { + file: file_name.clone(), + downloaded, + total: total_size, + status: "Downloading".into(), + }); + } + Ok(None) => break, + Err(e) => return Err(format!("Download error: {}", e)), + } + } + }, + Err(e) => return Err(format!("Request error: {}", e)), } + + let _ = window.emit("download-progress", ProgressEvent { + file: file_name.clone(), + downloaded: 0, + total: 0, + status: "Finished".into(), + }); + + Ok(()) } - } + }); + + // Buffer unordered to run concurrently + tasks_stream.buffer_unordered(10).collect::<Vec<Result<(), String>>>().await; + + let _ = window.emit("download-complete", ()); + Ok(()) } diff --git a/src-tauri/src/core/game_version.rs b/src-tauri/src/core/game_version.rs new file mode 100644 index 0000000..c33f99c --- /dev/null +++ b/src-tauri/src/core/game_version.rs @@ -0,0 +1,66 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct GameVersion { + pub id: String, + pub downloads: Downloads, + #[serde(rename = "assetIndex")] + pub asset_index: AssetIndex, + pub libraries: Vec<Library>, + #[serde(rename = "mainClass")] + pub main_class: String, + #[serde(rename = "minecraftArguments")] + pub minecraft_arguments: Option<String>, + pub arguments: Option<Arguments>, + #[serde(rename = "javaVersion")] + pub java_version: Option<JavaVersion>, +} + +#[derive(Debug, Deserialize)] +pub struct Downloads { + pub client: DownloadArtifact, + pub server: Option<DownloadArtifact>, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct DownloadArtifact { + pub sha1: String, + pub size: u64, + pub url: String, + pub path: Option<String>, +} + +#[derive(Debug, Deserialize)] +pub struct AssetIndex { + pub id: String, + pub sha1: String, + pub size: u64, + pub url: String, + #[serde(rename = "totalSize")] + pub total_size: u64, +} + +#[derive(Debug, Deserialize)] +pub struct Library { + pub downloads: Option<LibraryDownloads>, + pub name: String, +} + +#[derive(Debug, Deserialize)] +pub struct LibraryDownloads { + pub artifact: Option<DownloadArtifact>, + pub classifiers: Option<serde_json::Value>, // Complex, simplifying for now +} + +#[derive(Debug, Deserialize)] +pub struct Arguments { + pub game: Option<serde_json::Value>, + pub jvm: Option<serde_json::Value>, +} + +#[derive(Debug, Deserialize)] +pub struct JavaVersion { + pub component: String, + #[serde(rename = "majorVersion")] + pub major_version: u64, +} diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index 320ab82..aaa14a6 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -1,3 +1,4 @@ pub mod manifest; pub mod auth; pub mod downloader; +pub mod game_version; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 402f58f..6623802 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,28 +1,50 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use tauri::State; +use tauri::{State, Window}; // Added Window mod core; mod launcher; #[tauri::command] -async fn start_game() -> Result<String, String> { - println!("Backend received StartGame"); - match core::manifest::fetch_version_manifest().await { - Ok(manifest) => { - let msg = format!( - "Fetched manifest.\nLatest release: {}\nLatest snapshot: {}", - manifest.latest.release, manifest.latest.snapshot - ); - println!("{}", msg); - Ok(msg) - } - Err(e) => { - eprintln!("Error fetching manifest: {}", e); - Err(e.to_string()) - } - } +async fn start_game( + window: Window, + version_id: String +) -> Result<String, String> { + println!("Backend received StartGame for {}", version_id); + + // 1. Fetch manifest to find the version URL + let manifest = core::manifest::fetch_version_manifest().await.map_err(|e| e.to_string())?; + + // Find the version info + let version_info = manifest.versions.iter().find(|v| v.id == version_id) + .ok_or_else(|| format!("Version {} not found in manifest", version_id))?; + + // 2. Fetch specific version JSON (client.jar info) + let version_url = &version_info.url; + let version_details: core::game_version::GameVersion = reqwest::get(version_url) + .await.map_err(|e| e.to_string())? + .json().await.map_err(|e| e.to_string())?; + + // 3. Prepare download task for Client Jar + let client_jar = version_details.downloads.client; + // Where to save? Let's use ./versions/{version_id}/{version_id}.jar + let mut path = std::path::PathBuf::from("versions"); + path.push(&version_id); + path.push(format!("{}.jar", version_id)); + + let task = core::downloader::DownloadTask { + url: client_jar.url, + path, + sha1: Some(client_jar.sha1), + }; + + println!("Starting download of client jar..."); + + // 4. Start Download + core::downloader::download_files(window, vec![task]).await.map_err(|e| e.to_string())?; + + Ok(format!("Download complete for {}", version_id)) } #[tauri::command] @@ -55,11 +77,17 @@ async fn get_active_account( Ok(state.active_account.lock().unwrap().clone()) } +#[tauri::command] +async fn logout(state: State<'_, core::auth::AccountState>) -> Result<(), String> { + *state.active_account.lock().unwrap() = None; + Ok(()) +} + fn main() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .manage(core::auth::AccountState::new()) - .invoke_handler(tauri::generate_handler![start_game, get_versions, login_offline, get_active_account]) + .invoke_handler(tauri::generate_handler![start_game, get_versions, login_offline, get_active_account, logout]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 18ef5e5..0e14cb0 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -1,6 +1,7 @@ <script lang="ts"> import { invoke } from "@tauri-apps/api/core"; import { onMount } from "svelte"; + import DownloadMonitor from "./lib/DownloadMonitor.svelte"; let status = "Ready"; @@ -48,8 +49,12 @@ async function login() { if (currentAccount) { if (confirm("Logout " + currentAccount.username + "?")) { - currentAccount = null; - // Note: Backend state persists until restarted or overwritten. + try { + await invoke("logout"); + currentAccount = null; + } catch(e) { + console.error("Logout failed:", e); + } } return; } @@ -64,10 +69,21 @@ } async function startGame() { - status = "Launching (Simulated)..."; - console.log("Invoking start_game..."); + if (!currentAccount) { + alert("Please login first!"); + login(); + return; + } + + if (!selectedVersion) { + alert("Please select a version!"); + return; + } + + status = "Preparing to launch " + selectedVersion + "..."; + console.log("Invoking start_game for version:", selectedVersion); try { - const msg = await invoke("start_game"); + const msg = await invoke("start_game", { versionId: selectedVersion }); console.log("Response:", msg); status = msg as string; } catch (e) { @@ -110,6 +126,7 @@ <!-- Main Content --> <main class="flex-1 flex flex-col relative min-w-0"> + <DownloadMonitor /> <!-- Top Bar (Window Controls Placeholder) --> <div class="h-8 w-full bg-zinc-900/50 absolute top-0 left-0 z-50 drag-region" data-tauri-drag-region> <!-- Windows/macOS controls would go here or be handled by OS --> diff --git a/ui/src/lib/DownloadMonitor.svelte b/ui/src/lib/DownloadMonitor.svelte new file mode 100644 index 0000000..b2751fe --- /dev/null +++ b/ui/src/lib/DownloadMonitor.svelte @@ -0,0 +1,92 @@ +<script lang="ts"> + import { listen } from "@tauri-apps/api/event"; + import { onMount, onDestroy } from "svelte"; + + export let visible = false; + + interface DownloadEvent { + file: string; + downloaded: number; // in bytes + total: number; // in bytes + status: string; + } + + let currentFile = ""; + let progress = 0; // percentage 0-100 + let totalFiles = 0; + let statusText = "Preparing..."; + let unlistenProgress: () => void; + let unlistenStart: () => void; + let unlistenComplete: () => void; + let downloadedBytes = 0; + let totalBytes = 0; + + onMount(async () => { + unlistenStart = await listen<number>("download-start", (event) => { + visible = true; + totalFiles = event.payload; + progress = 0; + statusText = "Starting download..."; + currentFile = ""; + }); + + unlistenProgress = await listen<DownloadEvent>("download-progress", (event) => { + const payload = event.payload; + currentFile = payload.file; + + // Simple file progress for now. Global progress would require tracking all files. + // For single file (Client jar), this is accurate. + downloadedBytes = payload.downloaded; + totalBytes = payload.total; + + statusText = payload.status; + + if (payload.total > 0) { + progress = (payload.downloaded / payload.total) * 100; + } + }); + + unlistenComplete = await listen("download-complete", () => { + statusText = "Done!"; + progress = 100; + setTimeout(() => { visible = false; }, 2000); + }); + }); + + onDestroy(() => { + if (unlistenProgress) unlistenProgress(); + if (unlistenStart) unlistenStart(); + if (unlistenComplete) unlistenComplete(); + }); + + function formatBytes(bytes: number) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } +</script> + +{#if visible} +<div class="fixed bottom-28 right-8 z-50 w-80 bg-zinc-900/90 backdrop-blur-md border border-zinc-700 rounded-lg shadow-2xl p-4 animate-in slide-in-from-right-10 fade-in duration-300"> + <div class="flex items-center justify-between mb-2"> + <h3 class="text-white font-bold text-sm">Downloads</h3> + <span class="text-xs text-zinc-400">{statusText}</span> + </div> + + <div class="text-xs text-zinc-300 truncate mb-1" title={currentFile}> + {currentFile || "Waiting..."} + </div> + + <!-- Progress Bar --> + <div class="w-full bg-zinc-800 rounded-full h-2 mb-2 overflow-hidden"> + <div class="bg-gradient-to-r from-green-500 to-emerald-400 h-2 rounded-full transition-all duration-200" style="width: {progress}%"></div> + </div> + + <div class="flex justify-between text-[10px] text-zinc-500 font-mono"> + <span>{formatBytes(downloadedBytes)} / {formatBytes(totalBytes)}</span> + <span>{Math.round(progress)}%</span> + </div> +</div> +{/if} |