aboutsummaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
authorHsiangNianian <i@jyunko.cn>2026-01-13 15:56:20 +0800
committerHsiangNianian <i@jyunko.cn>2026-01-13 15:56:20 +0800
commit66f7825ed9638606665b9e61c6f8132de013da14 (patch)
tree764c30309a1c5a77f3b0d92d5131b1b50ae50402
parent6fbb5f19cab02f3a0f18cdeda3da02e717b69cd6 (diff)
downloadDropOut-66f7825ed9638606665b9e61c6f8132de013da14.tar.gz
DropOut-66f7825ed9638606665b9e61c6f8132de013da14.zip
feat: implement download functionality with progress monitoring and version management
-rw-r--r--src-tauri/Cargo.toml3
-rw-r--r--src-tauri/src/core/downloader.rs133
-rw-r--r--src-tauri/src/core/game_version.rs66
-rw-r--r--src-tauri/src/core/mod.rs1
-rw-r--r--src-tauri/src/main.rs64
-rw-r--r--ui/src/App.svelte27
-rw-r--r--ui/src/lib/DownloadMonitor.svelte92
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}