aboutsummaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
author简律纯 <i@jyunko.cn>2026-01-15 20:48:39 +0800
committerGitHub <noreply@github.com>2026-01-15 20:48:39 +0800
commit5931daf9283478f49652098c3e0f6be8de0f52f8 (patch)
treed150c7733d039d71e40a9d3298952a4627fe2584
parent32a9aceee42a2261b64f9e6effda522639576a5e (diff)
parent959d1c54e6b5b101b20c027d547707a40ab0a29b (diff)
downloadDropOut-5931daf9283478f49652098c3e0f6be8de0f52f8.tar.gz
DropOut-5931daf9283478f49652098c3e0f6be8de0f52f8.zip
Merge pull request #32 from HsiangNianian/main
-rw-r--r--src-tauri/Cargo.toml2
-rw-r--r--src-tauri/src/core/fabric.rs27
-rw-r--r--src-tauri/src/core/forge.rs302
-rw-r--r--src-tauri/src/main.rs449
-rw-r--r--src-tauri/tauri.conf.json2
-rw-r--r--ui/src/App.svelte26
-rw-r--r--ui/src/components/BottomBar.svelte172
-rw-r--r--ui/src/components/HomeView.svelte70
-rw-r--r--ui/src/components/ModLoaderSelector.svelte149
-rw-r--r--ui/src/components/ParticleBackground.svelte15
-rw-r--r--ui/src/components/VersionsView.svelte2
-rw-r--r--ui/src/lib/effects/SaturnEffect.ts147
-rw-r--r--ui/src/stores/auth.svelte.ts54
-rw-r--r--ui/src/types/index.ts3
14 files changed, 1282 insertions, 138 deletions
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 0eb143c..97529a1 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "dropout"
-version = "0.1.21"
+version = "0.1.23"
edition = "2021"
authors = ["HsiangNianian"]
description = "The DropOut Minecraft Game Launcher"
diff --git a/src-tauri/src/core/fabric.rs b/src-tauri/src/core/fabric.rs
index fd38f41..3e4d50d 100644
--- a/src-tauri/src/core/fabric.rs
+++ b/src-tauri/src/core/fabric.rs
@@ -63,10 +63,31 @@ pub struct FabricLibrary {
}
/// Main class configuration for Fabric.
+/// Can be either a struct with client/server fields or a simple string.
#[derive(Debug, Deserialize, Serialize, Clone)]
-pub struct FabricMainClass {
- pub client: String,
- pub server: String,
+#[serde(untagged)]
+pub enum FabricMainClass {
+ Structured {
+ client: String,
+ server: String,
+ },
+ Simple(String),
+}
+
+impl FabricMainClass {
+ pub fn client(&self) -> &str {
+ match self {
+ FabricMainClass::Structured { client, .. } => client,
+ FabricMainClass::Simple(s) => s,
+ }
+ }
+
+ pub fn server(&self) -> &str {
+ match self {
+ FabricMainClass::Structured { server, .. } => server,
+ FabricMainClass::Simple(s) => s,
+ }
+ }
}
/// Represents a Minecraft version supported by Fabric.
diff --git a/src-tauri/src/core/forge.rs b/src-tauri/src/core/forge.rs
index 0f17bcc..e69b296 100644
--- a/src-tauri/src/core/forge.rs
+++ b/src-tauri/src/core/forge.rs
@@ -5,8 +5,7 @@
//! - Install Forge loader for a specific Minecraft version
//!
//! Note: Forge installation is more complex than Fabric, especially for versions 1.13+.
-//! This implementation focuses on the basic JSON generation approach.
-//! For full Forge 1.13+ support, processor execution would need to be implemented.
+//! This implementation fetches the installer manifest to get the correct library list.
use serde::{Deserialize, Serialize};
use std::error::Error;
@@ -42,6 +41,46 @@ pub struct InstalledForgeVersion {
pub path: PathBuf,
}
+/// Forge installer manifest structure (from version.json inside installer JAR)
+#[derive(Debug, Deserialize)]
+struct ForgeInstallerManifest {
+ id: Option<String>,
+ #[serde(rename = "inheritsFrom")]
+ inherits_from: Option<String>,
+ #[serde(rename = "mainClass")]
+ main_class: Option<String>,
+ #[serde(default)]
+ libraries: Vec<ForgeLibrary>,
+ arguments: Option<ForgeArguments>,
+}
+
+#[derive(Debug, Deserialize)]
+struct ForgeArguments {
+ game: Option<Vec<serde_json::Value>>,
+ jvm: Option<Vec<serde_json::Value>>,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+struct ForgeLibrary {
+ name: String,
+ #[serde(default)]
+ downloads: Option<ForgeLibraryDownloads>,
+ #[serde(default)]
+ url: Option<String>,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+struct ForgeLibraryDownloads {
+ artifact: Option<ForgeArtifact>,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+struct ForgeArtifact {
+ path: Option<String>,
+ url: Option<String>,
+ sha1: Option<String>,
+}
+
/// Fetch all Minecraft versions supported by Forge.
///
/// # Returns
@@ -138,18 +177,49 @@ pub fn generate_version_id(game_version: &str, forge_version: &str) -> String {
format!("{}-forge-{}", game_version, forge_version)
}
+/// Fetch the Forge installer manifest to get the library list
+async fn fetch_forge_installer_manifest(
+ game_version: &str,
+ forge_version: &str,
+) -> Result<ForgeInstallerManifest, Box<dyn Error + Send + Sync>> {
+ let forge_full = format!("{}-{}", game_version, forge_version);
+
+ // Download the installer JAR to extract version.json
+ let installer_url = format!(
+ "{}net/minecraftforge/forge/{}/forge-{}-installer.jar",
+ FORGE_MAVEN_URL, forge_full, forge_full
+ );
+
+ println!("Fetching Forge installer from: {}", installer_url);
+
+ let response = reqwest::get(&installer_url).await?;
+ if !response.status().is_success() {
+ return Err(format!("Failed to download Forge installer: {}", response.status()).into());
+ }
+
+ let bytes = response.bytes().await?;
+
+ // Extract version.json from the JAR (which is a ZIP file)
+ let cursor = std::io::Cursor::new(bytes.as_ref());
+ let mut archive = zip::ZipArchive::new(cursor)?;
+
+ // Look for version.json in the archive
+ let version_json = archive.by_name("version.json")?;
+ let manifest: ForgeInstallerManifest = serde_json::from_reader(version_json)?;
+
+ Ok(manifest)
+}
+
/// Install Forge for a specific Minecraft version.
///
-/// Note: This creates a basic version JSON. For Forge 1.13+, the full installation
-/// requires running the Forge installer processors, which is not yet implemented.
-/// This basic implementation works for legacy Forge versions (<1.13) and creates
-/// the structure needed for modern Forge (libraries will need to be downloaded
-/// separately).
+/// This function downloads the Forge installer JAR and runs it in headless mode
+/// to properly install Forge with all necessary patches.
///
/// # Arguments
/// * `game_dir` - The .minecraft directory path
/// * `game_version` - The Minecraft version (e.g., "1.20.4")
/// * `forge_version` - The Forge version (e.g., "49.0.38")
+/// * `java_path` - Path to the Java executable
///
/// # Returns
/// Information about the installed version.
@@ -160,10 +230,11 @@ pub async fn install_forge(
) -> Result<InstalledForgeVersion, Box<dyn Error + Send + Sync>> {
let version_id = generate_version_id(game_version, forge_version);
- // Create basic version JSON structure
- // Note: This is a simplified version. Full Forge installation requires
- // downloading the installer and running processors.
- let version_json = create_forge_version_json(game_version, forge_version)?;
+ // Fetch the installer manifest to get the complete version.json
+ let manifest = fetch_forge_installer_manifest(game_version, forge_version).await?;
+
+ // Create version JSON from the manifest
+ let version_json = create_forge_version_json_from_manifest(game_version, forge_version, &manifest)?;
// Create the version directory
let version_dir = game_dir.join("versions").join(&version_id);
@@ -182,55 +253,185 @@ pub async fn install_forge(
})
}
-/// Create a basic Forge version JSON.
+/// Install Forge using the official installer JAR.
+/// This runs the Forge installer in headless mode to properly patch the client.
+///
+/// # Arguments
+/// * `game_dir` - The .minecraft directory path
+/// * `game_version` - The Minecraft version
+/// * `forge_version` - The Forge version
+/// * `java_path` - Path to the Java executable
///
-/// This creates a minimal version JSON that inherits from vanilla and adds
-/// the Forge libraries. For full functionality with Forge 1.13+, the installer
-/// would need to be run to patch the game.
+/// # Returns
+/// Result indicating success or failure
+pub async fn run_forge_installer(
+ game_dir: &PathBuf,
+ game_version: &str,
+ forge_version: &str,
+ java_path: &PathBuf,
+) -> Result<(), Box<dyn Error + Send + Sync>> {
+ // Download the installer JAR
+ let installer_url = format!(
+ "{}net/minecraftforge/forge/{}-{}/forge-{}-{}-installer.jar",
+ FORGE_MAVEN_URL, game_version, forge_version, game_version, forge_version
+ );
+
+ let installer_path = game_dir.join("forge-installer.jar");
+
+ // Download installer
+ let client = reqwest::Client::new();
+ let response = client.get(&installer_url).send().await?;
+
+ if !response.status().is_success() {
+ return Err(format!("Failed to download Forge installer: {}", response.status()).into());
+ }
+
+ let bytes = response.bytes().await?;
+ tokio::fs::write(&installer_path, &bytes).await?;
+
+ // Run the installer in headless mode
+ // The installer accepts --installClient <path> to install to a specific directory
+ let output = tokio::process::Command::new(java_path)
+ .arg("-jar")
+ .arg(&installer_path)
+ .arg("--installClient")
+ .arg(game_dir)
+ .output()
+ .await?;
+
+ // Clean up installer
+ let _ = tokio::fs::remove_file(&installer_path).await;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ return Err(format!(
+ "Forge installer failed:\nstdout: {}\nstderr: {}",
+ stdout, stderr
+ ).into());
+ }
+
+ Ok(())
+}
+
+/// Create a Forge version JSON from the installer manifest.
+fn create_forge_version_json_from_manifest(
+ game_version: &str,
+ forge_version: &str,
+ manifest: &ForgeInstallerManifest,
+) -> Result<serde_json::Value, Box<dyn Error + Send + Sync>> {
+ let version_id = generate_version_id(game_version, forge_version);
+
+ // Use main class from manifest or default
+ let main_class = manifest.main_class.clone().unwrap_or_else(|| {
+ if is_modern_forge(game_version) {
+ "cpw.mods.bootstraplauncher.BootstrapLauncher".to_string()
+ } else {
+ "net.minecraft.launchwrapper.Launch".to_string()
+ }
+ });
+
+ // Convert libraries to JSON format, preserving download info
+ let lib_entries: Vec<serde_json::Value> = manifest.libraries
+ .iter()
+ .map(|lib| {
+ let mut entry = serde_json::json!({
+ "name": lib.name
+ });
+
+ // Add URL if present
+ if let Some(url) = &lib.url {
+ entry["url"] = serde_json::Value::String(url.clone());
+ } else {
+ // Default to Forge Maven for Forge libraries
+ entry["url"] = serde_json::Value::String(FORGE_MAVEN_URL.to_string());
+ }
+
+ // Add downloads if present
+ if let Some(downloads) = &lib.downloads {
+ if let Some(artifact) = &downloads.artifact {
+ let mut artifact_json = serde_json::Map::new();
+ if let Some(path) = &artifact.path {
+ artifact_json.insert("path".to_string(), serde_json::Value::String(path.clone()));
+ }
+ if let Some(url) = &artifact.url {
+ artifact_json.insert("url".to_string(), serde_json::Value::String(url.clone()));
+ }
+ if let Some(sha1) = &artifact.sha1 {
+ artifact_json.insert("sha1".to_string(), serde_json::Value::String(sha1.clone()));
+ }
+ if !artifact_json.is_empty() {
+ entry["downloads"] = serde_json::json!({
+ "artifact": artifact_json
+ });
+ }
+ }
+ }
+
+ entry
+ })
+ .collect();
+
+ // Build arguments
+ let mut arguments = serde_json::json!({
+ "game": [],
+ "jvm": []
+ });
+
+ if let Some(args) = &manifest.arguments {
+ if let Some(game_args) = &args.game {
+ arguments["game"] = serde_json::Value::Array(game_args.clone());
+ }
+ if let Some(jvm_args) = &args.jvm {
+ arguments["jvm"] = serde_json::Value::Array(jvm_args.clone());
+ }
+ }
+
+ let json = serde_json::json!({
+ "id": version_id,
+ "inheritsFrom": manifest.inherits_from.clone().unwrap_or_else(|| game_version.to_string()),
+ "type": "release",
+ "mainClass": main_class,
+ "libraries": lib_entries,
+ "arguments": arguments
+ });
+
+ Ok(json)
+}
+
+/// Create a Forge version JSON with the proper library list (fallback).
+#[allow(dead_code)]
fn create_forge_version_json(
game_version: &str,
forge_version: &str,
+ libraries: &[ForgeLibrary],
) -> Result<serde_json::Value, Box<dyn Error + Send + Sync>> {
let version_id = generate_version_id(game_version, forge_version);
- let forge_maven_coord = format!(
- "net.minecraftforge:forge:{}-{}",
- game_version, forge_version
- );
// Determine main class based on version
- // Forge 1.13+ uses different launchers
- let (main_class, libraries) = if is_modern_forge(game_version) {
- // Modern Forge (1.13+) uses cpw.mods.bootstraplauncher
- (
- "cpw.mods.bootstraplauncher.BootstrapLauncher".to_string(),
- vec![
- create_library_entry(&forge_maven_coord, Some(FORGE_MAVEN_URL)),
- create_library_entry(
- &format!(
- "net.minecraftforge:forge:{}-{}:universal",
- game_version, forge_version
- ),
- Some(FORGE_MAVEN_URL),
- ),
- ],
- )
+ let main_class = if is_modern_forge(game_version) {
+ "cpw.mods.bootstraplauncher.BootstrapLauncher"
} else {
- // Legacy Forge uses LaunchWrapper
- (
- "net.minecraft.launchwrapper.Launch".to_string(),
- vec![
- create_library_entry(&forge_maven_coord, Some(FORGE_MAVEN_URL)),
- create_library_entry("net.minecraft:launchwrapper:1.12", None),
- ],
- )
+ "net.minecraft.launchwrapper.Launch"
};
+ // Convert libraries to JSON format
+ let lib_entries: Vec<serde_json::Value> = libraries
+ .iter()
+ .map(|lib| {
+ serde_json::json!({
+ "name": lib.name,
+ "url": FORGE_MAVEN_URL
+ })
+ })
+ .collect();
+
let json = serde_json::json!({
"id": version_id,
"inheritsFrom": game_version,
"type": "release",
"mainClass": main_class,
- "libraries": libraries,
+ "libraries": lib_entries,
"arguments": {
"game": [],
"jvm": []
@@ -240,19 +441,6 @@ fn create_forge_version_json(
Ok(json)
}
-/// Create a library entry for the version JSON.
-fn create_library_entry(name: &str, maven_url: Option<&str>) -> serde_json::Value {
- let mut entry = serde_json::json!({
- "name": name
- });
-
- if let Some(url) = maven_url {
- entry["url"] = serde_json::Value::String(url.to_string());
- }
-
- entry
-}
-
/// Check if the Minecraft version uses modern Forge (1.13+).
fn is_modern_forge(game_version: &str) -> bool {
let parts: Vec<&str> = game_version.split('.').collect();
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index b69912e..3671166 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -121,6 +121,13 @@ async fn start_game(
format!("Loading version details for {}...", version_id)
);
+ // First, load the local version to get the original inheritsFrom value
+ // (before merge clears it)
+ let original_inherits_from = match core::manifest::load_local_version(&game_dir, &version_id).await {
+ Ok(local_version) => local_version.inherits_from.clone(),
+ Err(_) => None,
+ };
+
let version_details = core::manifest::load_version(&game_dir, &version_id)
.await
.map_err(|e| e.to_string())?;
@@ -135,9 +142,7 @@ async fn start_game(
// Determine the actual minecraft version for client.jar
// (for modded versions, this is the parent vanilla version)
- let minecraft_version = version_details
- .inherits_from
- .clone()
+ let minecraft_version = original_inherits_from
.unwrap_or_else(|| version_id.clone());
// 2. Prepare download tasks
@@ -380,6 +385,7 @@ async fn start_game(
for lib in &version_details.libraries {
if core::rules::is_library_allowed(&lib.rules) {
if let Some(downloads) = &lib.downloads {
+ // Standard library with explicit downloads
if let Some(artifact) = &downloads.artifact {
let path_str = artifact
.path
@@ -388,6 +394,12 @@ async fn start_game(
let lib_path = libraries_dir.join(path_str);
classpath_entries.push(lib_path.to_string_lossy().to_string());
}
+ } else {
+ // Library without explicit downloads (mod loader libraries)
+ // Use Maven coordinate resolution
+ if let Some(lib_path) = core::maven::get_library_path(&lib.name, &libraries_dir) {
+ classpath_entries.push(lib_path.to_string_lossy().to_string());
+ }
}
}
}
@@ -680,6 +692,300 @@ async fn get_versions() -> Result<Vec<core::manifest::Version>, String> {
}
}
+/// Check if a version is installed (has client.jar)
+#[tauri::command]
+async fn check_version_installed(window: Window, version_id: String) -> Result<bool, String> {
+ let app_handle = window.app_handle();
+ let game_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| format!("Failed to get app data dir: {}", e))?;
+
+ // For modded versions, check the parent vanilla version
+ let minecraft_version = if version_id.starts_with("fabric-loader-") {
+ // Format: fabric-loader-X.X.X-1.20.4
+ version_id.split('-').last().unwrap_or(&version_id).to_string()
+ } else if version_id.contains("-forge-") {
+ // Format: 1.20.4-forge-49.0.38
+ version_id.split("-forge-").next().unwrap_or(&version_id).to_string()
+ } else {
+ version_id.clone()
+ };
+
+ let client_jar = game_dir
+ .join("versions")
+ .join(&minecraft_version)
+ .join(format!("{}.jar", minecraft_version));
+
+ Ok(client_jar.exists())
+}
+
+/// Install a version (download client, libraries, assets) without launching
+#[tauri::command]
+async fn install_version(
+ window: Window,
+ config_state: State<'_, core::config::ConfigState>,
+ version_id: String,
+) -> Result<(), String> {
+ emit_log!(
+ window,
+ format!("Starting installation for version: {}", version_id)
+ );
+
+ let config = config_state.config.lock().unwrap().clone();
+ let app_handle = window.app_handle();
+ let game_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| format!("Failed to get app data dir: {}", e))?;
+
+ // Ensure game directory exists
+ tokio::fs::create_dir_all(&game_dir)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ emit_log!(window, format!("Game directory: {:?}", game_dir));
+
+ // Load version (supports both vanilla and modded versions with inheritance)
+ emit_log!(
+ window,
+ format!("Loading version details for {}...", version_id)
+ );
+
+ // First, try to fetch the vanilla version from Mojang and save it locally
+ let version_details = match core::manifest::load_local_version(&game_dir, &version_id).await {
+ Ok(v) => v,
+ Err(_) => {
+ // Not found locally, fetch from Mojang
+ emit_log!(window, format!("Fetching version {} from Mojang...", version_id));
+ let fetched = core::manifest::fetch_vanilla_version(&version_id)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ // Save the version JSON locally
+ emit_log!(window, format!("Saving version JSON..."));
+ core::manifest::save_local_version(&game_dir, &fetched)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ fetched
+ }
+ };
+
+ // Now load the full version with inheritance resolved
+ let version_details = core::manifest::load_version(&game_dir, &version_id)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ emit_log!(
+ window,
+ format!(
+ "Version details loaded: main class = {}",
+ version_details.main_class
+ )
+ );
+
+ // Determine the actual minecraft version for client.jar
+ let minecraft_version = version_details
+ .inherits_from
+ .clone()
+ .unwrap_or_else(|| version_id.clone());
+
+ // Prepare download tasks
+ emit_log!(window, "Preparing download tasks...".to_string());
+ let mut download_tasks = Vec::new();
+
+ // --- Client Jar ---
+ let downloads = version_details
+ .downloads
+ .as_ref()
+ .ok_or("Version has no downloads information")?;
+ let client_jar = &downloads.client;
+ let mut client_path = game_dir.join("versions");
+ client_path.push(&minecraft_version);
+ client_path.push(format!("{}.jar", minecraft_version));
+
+ download_tasks.push(core::downloader::DownloadTask {
+ url: client_jar.url.clone(),
+ path: client_path.clone(),
+ sha1: client_jar.sha1.clone(),
+ sha256: None,
+ });
+
+ // --- Libraries ---
+ let libraries_dir = game_dir.join("libraries");
+
+ for lib in &version_details.libraries {
+ if core::rules::is_library_allowed(&lib.rules) {
+ if let Some(downloads) = &lib.downloads {
+ if let Some(artifact) = &downloads.artifact {
+ let path_str = artifact
+ .path
+ .clone()
+ .unwrap_or_else(|| format!("{}.jar", lib.name));
+
+ let mut lib_path = libraries_dir.clone();
+ lib_path.push(path_str);
+
+ download_tasks.push(core::downloader::DownloadTask {
+ url: artifact.url.clone(),
+ path: lib_path,
+ sha1: artifact.sha1.clone(),
+ sha256: None,
+ });
+ }
+
+ // Native Library (classifiers)
+ if let Some(classifiers) = &downloads.classifiers {
+ let os_key = if cfg!(target_os = "linux") {
+ "natives-linux"
+ } else if cfg!(target_os = "windows") {
+ "natives-windows"
+ } else if cfg!(target_os = "macos") {
+ "natives-osx"
+ } else {
+ ""
+ };
+
+ if let Some(native_artifact_value) = classifiers.get(os_key) {
+ if let Ok(native_artifact) =
+ serde_json::from_value::<core::game_version::DownloadArtifact>(
+ native_artifact_value.clone(),
+ )
+ {
+ let path_str = native_artifact.path.clone().unwrap();
+ let mut native_path = libraries_dir.clone();
+ native_path.push(&path_str);
+
+ download_tasks.push(core::downloader::DownloadTask {
+ url: native_artifact.url,
+ path: native_path.clone(),
+ sha1: native_artifact.sha1,
+ sha256: None,
+ });
+ }
+ }
+ }
+ } else {
+ // Library without explicit downloads (mod loader libraries)
+ if let Some(url) =
+ core::maven::resolve_library_url(&lib.name, None, lib.url.as_deref())
+ {
+ if let Some(lib_path) = core::maven::get_library_path(&lib.name, &libraries_dir)
+ {
+ download_tasks.push(core::downloader::DownloadTask {
+ url,
+ path: lib_path,
+ sha1: None,
+ sha256: None,
+ });
+ }
+ }
+ }
+ }
+ }
+
+ // --- Assets ---
+ let assets_dir = game_dir.join("assets");
+ let objects_dir = assets_dir.join("objects");
+ let indexes_dir = assets_dir.join("indexes");
+
+ let asset_index = version_details
+ .asset_index
+ .as_ref()
+ .ok_or("Version has no asset index information")?;
+
+ let asset_index_path = indexes_dir.join(format!("{}.json", asset_index.id));
+
+ let asset_index_content: String = if asset_index_path.exists() {
+ tokio::fs::read_to_string(&asset_index_path)
+ .await
+ .map_err(|e| e.to_string())?
+ } else {
+ emit_log!(window, format!("Downloading asset index..."));
+ let content = reqwest::get(&asset_index.url)
+ .await
+ .map_err(|e| e.to_string())?
+ .text()
+ .await
+ .map_err(|e| e.to_string())?;
+
+ tokio::fs::create_dir_all(&indexes_dir)
+ .await
+ .map_err(|e| e.to_string())?;
+ tokio::fs::write(&asset_index_path, &content)
+ .await
+ .map_err(|e| e.to_string())?;
+ content
+ };
+
+ #[derive(serde::Deserialize)]
+ struct AssetObject {
+ hash: String,
+ }
+
+ #[derive(serde::Deserialize)]
+ struct AssetIndexJson {
+ objects: std::collections::HashMap<String, AssetObject>,
+ }
+
+ let asset_index_parsed: AssetIndexJson =
+ serde_json::from_str(&asset_index_content).map_err(|e| e.to_string())?;
+
+ emit_log!(
+ window,
+ format!("Processing {} assets...", asset_index_parsed.objects.len())
+ );
+
+ for (_name, object) in asset_index_parsed.objects {
+ let hash = object.hash;
+ let prefix = &hash[0..2];
+ let path = objects_dir.join(prefix).join(&hash);
+ let url = format!(
+ "https://resources.download.minecraft.net/{}/{}",
+ prefix, hash
+ );
+
+ download_tasks.push(core::downloader::DownloadTask {
+ url,
+ path,
+ sha1: Some(hash),
+ sha256: None,
+ });
+ }
+
+ emit_log!(
+ window,
+ format!(
+ "Total download tasks: {} (Client + Libraries + Assets)",
+ download_tasks.len()
+ )
+ );
+
+ // Start Download
+ emit_log!(
+ window,
+ format!(
+ "Starting downloads with {} concurrent threads...",
+ config.download_threads
+ )
+ );
+ core::downloader::download_files(
+ window.clone(),
+ download_tasks,
+ config.download_threads as usize,
+ )
+ .await
+ .map_err(|e| e.to_string())?;
+
+ emit_log!(
+ window,
+ format!("Installation of {} completed successfully!", version_id)
+ );
+
+ Ok(())
+}
+
#[tauri::command]
async fn login_offline(
window: Window,
@@ -765,24 +1071,39 @@ async fn complete_microsoft_login(
ms_refresh_state: State<'_, MsRefreshTokenState>,
device_code: String,
) -> Result<core::auth::Account, String> {
+ // Helper to emit auth progress
+ let emit_progress = |step: &str| {
+ let _ = window.emit("auth-progress", step);
+ };
+
// 1. Poll (once) for token
+ emit_progress("Receiving token from Microsoft...");
let token_resp = core::auth::exchange_code_for_token(&device_code).await?;
+ emit_progress("Token received successfully!");
// Store MS refresh token
let ms_refresh_token = token_resp.refresh_token.clone();
*ms_refresh_state.token.lock().unwrap() = ms_refresh_token.clone();
// 2. Xbox Live Auth
+ emit_progress("Authenticating with Xbox Live...");
let (xbl_token, uhs) = core::auth::method_xbox_live(&token_resp.access_token).await?;
+ emit_progress("Xbox Live authentication successful!");
// 3. XSTS Auth
+ emit_progress("Authenticating with XSTS...");
let xsts_token = core::auth::method_xsts(&xbl_token).await?;
+ emit_progress("XSTS authentication successful!");
// 4. Minecraft Auth
+ emit_progress("Authenticating with Minecraft...");
let mc_token = core::auth::login_minecraft(&xsts_token, &uhs).await?;
+ emit_progress("Minecraft authentication successful!");
// 5. Get Profile
+ emit_progress("Fetching Minecraft profile...");
let profile = core::auth::fetch_profile(&mc_token).await?;
+ emit_progress(&format!("Welcome, {}!", profile.name));
// 6. Create Account
let account = core::auth::Account::Microsoft(core::auth::MicrosoftAccount {
@@ -1008,6 +1329,99 @@ async fn list_installed_fabric_versions(window: Window) -> Result<Vec<String>, S
.map_err(|e| e.to_string())
}
+/// Installed version info
+#[derive(serde::Serialize)]
+struct InstalledVersion {
+ id: String,
+ #[serde(rename = "type")]
+ version_type: String, // "release", "snapshot", "fabric", "forge", "modpack"
+}
+
+/// List all installed versions from the data directory
+/// Simply lists all folders in the versions directory without validation
+#[tauri::command]
+async fn list_installed_versions(window: Window) -> Result<Vec<InstalledVersion>, String> {
+ let app_handle = window.app_handle();
+ let game_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| format!("Failed to get app data dir: {}", e))?;
+
+ let versions_dir = game_dir.join("versions");
+ let mut installed = Vec::new();
+
+ if !versions_dir.exists() {
+ return Ok(installed);
+ }
+
+ let mut entries = tokio::fs::read_dir(&versions_dir)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ while let Some(entry) = entries.next_entry().await.map_err(|e| e.to_string())? {
+ // Only include directories
+ if !entry.file_type().await.map_err(|e| e.to_string())?.is_dir() {
+ continue;
+ }
+
+ let name = entry.file_name().to_string_lossy().to_string();
+ let version_dir = entry.path();
+
+ // Determine version type based on folder name or JSON content
+ let version_type = if name.starts_with("fabric-loader-") {
+ "fabric".to_string()
+ } else if name.contains("-forge") || name.contains("forge-") {
+ "forge".to_string()
+ } else {
+ // Try to read JSON to get type, otherwise guess from name
+ let json_path = version_dir.join(format!("{}.json", name));
+ if json_path.exists() {
+ if let Ok(content) = tokio::fs::read_to_string(&json_path).await {
+ if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
+ json.get("type")
+ .and_then(|t| t.as_str())
+ .unwrap_or("modpack")
+ .to_string()
+ } else {
+ "modpack".to_string()
+ }
+ } else {
+ "modpack".to_string()
+ }
+ } else {
+ // No JSON file - treat as modpack/custom
+ "modpack".to_string()
+ }
+ };
+
+ installed.push(InstalledVersion {
+ id: name,
+ version_type,
+ });
+ }
+
+ // Sort: modded/modpack first, then by version id descending
+ installed.sort_by(|a, b| {
+ let a_priority = match a.version_type.as_str() {
+ "fabric" | "forge" => 0,
+ "modpack" => 1,
+ _ => 2,
+ };
+ let b_priority = match b.version_type.as_str() {
+ "fabric" | "forge" => 0,
+ "modpack" => 1,
+ _ => 2,
+ };
+
+ match a_priority.cmp(&b_priority) {
+ std::cmp::Ordering::Equal => b.id.cmp(&a.id), // Descending order
+ other => other,
+ }
+ });
+
+ Ok(installed)
+}
+
/// Check if Fabric is installed for a specific version
#[tauri::command]
async fn is_fabric_installed(
@@ -1050,6 +1464,7 @@ async fn get_forge_versions_for_game(
#[tauri::command]
async fn install_forge(
window: Window,
+ config_state: State<'_, core::config::ConfigState>,
game_version: String,
forge_version: String,
) -> Result<core::forge::InstalledForgeVersion, String> {
@@ -1067,6 +1482,31 @@ async fn install_forge(
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
+ // Get Java path from config or detect
+ let config = config_state.config.lock().unwrap().clone();
+ let java_path_str = if !config.java_path.is_empty() && config.java_path != "java" {
+ config.java_path.clone()
+ } else {
+ // Try to find a suitable Java installation
+ let javas = core::java::detect_all_java_installations(&app_handle);
+ if let Some(java) = javas.first() {
+ java.path.clone()
+ } else {
+ return Err("No Java installation found. Please configure Java in settings.".to_string());
+ }
+ };
+ let java_path = std::path::PathBuf::from(&java_path_str);
+
+ emit_log!(window, "Running Forge installer...".to_string());
+
+ // Run the Forge installer to properly patch the client
+ core::forge::run_forge_installer(&game_dir, &game_version, &forge_version, &java_path)
+ .await
+ .map_err(|e| format!("Forge installer failed: {}", e))?;
+
+ emit_log!(window, "Forge installer completed, creating version profile...".to_string());
+
+ // Now create the version JSON
let result = core::forge::install_forge(&game_dir, &game_version, &forge_version)
.await
.map_err(|e| e.to_string())?;
@@ -1241,6 +1681,9 @@ fn main() {
.invoke_handler(tauri::generate_handler![
start_game,
get_versions,
+ check_version_installed,
+ install_version,
+ list_installed_versions,
login_offline,
get_active_account,
logout,
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index ce54ca8..060a871 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -1,6 +1,6 @@
{
"productName": "dropout",
- "version": "0.1.21",
+ "version": "0.1.23",
"identifier": "com.dropout.launcher",
"build": {
"beforeDevCommand": "pnpm -C ../ui dev",
diff --git a/ui/src/App.svelte b/ui/src/App.svelte
index 0bb31ae..760a15f 100644
--- a/ui/src/App.svelte
+++ b/ui/src/App.svelte
@@ -141,6 +141,32 @@
<LoginModal />
<StatusToast />
+ <!-- Logout Confirmation Dialog -->
+ {#if authState.isLogoutConfirmOpen}
+ <div class="fixed inset-0 z-[200] bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
+ <div class="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-6 max-w-sm w-full animate-in fade-in zoom-in-95 duration-200">
+ <h3 class="text-lg font-bold text-white mb-2">Logout</h3>
+ <p class="text-zinc-400 text-sm mb-6">
+ Are you sure you want to logout <span class="text-white font-medium">{authState.currentAccount?.username}</span>?
+ </p>
+ <div class="flex gap-3 justify-end">
+ <button
+ onclick={() => authState.cancelLogout()}
+ class="px-4 py-2 text-sm font-medium text-zinc-300 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded-lg transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onclick={() => authState.confirmLogout()}
+ class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-500 rounded-lg transition-colors"
+ >
+ Logout
+ </button>
+ </div>
+ </div>
+ </div>
+ {/if}
+
{#if uiState.showConsole}
<div class="fixed inset-0 z-[100] bg-black/80 backdrop-blur-sm flex items-center justify-center p-8">
<div class="w-full h-full max-w-6xl max-h-[85vh] bg-[#1e1e1e] rounded-lg overflow-hidden border border-zinc-700 shadow-2xl relative flex flex-col">
diff --git a/ui/src/components/BottomBar.svelte b/ui/src/components/BottomBar.svelte
index abb0b23..b7bbf71 100644
--- a/ui/src/components/BottomBar.svelte
+++ b/ui/src/components/BottomBar.svelte
@@ -1,23 +1,68 @@
<script lang="ts">
+ import { invoke } from "@tauri-apps/api/core";
+ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { authState } from "../stores/auth.svelte";
import { gameState } from "../stores/game.svelte";
import { uiState } from "../stores/ui.svelte";
- import { Terminal, ChevronDown, Play, User, Check } from 'lucide-svelte';
+ import { Terminal, ChevronDown, Play, User, Check, RefreshCw } from 'lucide-svelte';
+
+ interface InstalledVersion {
+ id: string;
+ type: string;
+ }
let isVersionDropdownOpen = $state(false);
let dropdownRef: HTMLDivElement;
+ let installedVersions = $state<InstalledVersion[]>([]);
+ let isLoadingVersions = $state(true);
+ let downloadCompleteUnlisten: UnlistenFn | null = null;
+
+ // Load installed versions on mount
+ $effect(() => {
+ loadInstalledVersions();
+ setupDownloadListener();
+ return () => {
+ if (downloadCompleteUnlisten) {
+ downloadCompleteUnlisten();
+ }
+ };
+ });
+
+ async function setupDownloadListener() {
+ // Refresh list when a download completes
+ downloadCompleteUnlisten = await listen("download-complete", () => {
+ loadInstalledVersions();
+ });
+ }
+
+ async function loadInstalledVersions() {
+ isLoadingVersions = true;
+ try {
+ installedVersions = await invoke<InstalledVersion[]>("list_installed_versions");
+ // If no version is selected but we have installed versions, select the first one
+ if (!gameState.selectedVersion && installedVersions.length > 0) {
+ gameState.selectedVersion = installedVersions[0].id;
+ }
+ } catch (e) {
+ console.error("Failed to load installed versions:", e);
+ } finally {
+ isLoadingVersions = false;
+ }
+ }
let versionOptions = $derived(
- gameState.versions.length === 0
+ isLoadingVersions
? [{ id: "loading", type: "loading", label: "Loading..." }]
- : gameState.versions.map(v => ({
- ...v,
- label: `${v.id}${v.type !== 'release' ? ` (${v.type})` : ''}`
- }))
+ : installedVersions.length === 0
+ ? [{ id: "empty", type: "empty", label: "No versions installed" }]
+ : installedVersions.map(v => ({
+ ...v,
+ label: `${v.id}${v.type !== 'release' ? ` (${v.type})` : ''}`
+ }))
);
function selectVersion(id: string) {
- if (id !== "loading") {
+ if (id !== "loading" && id !== "empty") {
gameState.selectedVersion = id;
isVersionDropdownOpen = false;
}
@@ -35,6 +80,16 @@
return () => document.removeEventListener('click', handleClickOutside);
}
});
+
+ function getVersionTypeColor(type: string) {
+ switch (type) {
+ case 'fabric': return 'text-indigo-400';
+ case 'forge': return 'text-orange-400';
+ case 'snapshot': return 'text-amber-400';
+ case 'modpack': return 'text-purple-400';
+ default: return 'text-emerald-400';
+ }
+ }
</script>
<div
@@ -67,12 +122,23 @@
{authState.currentAccount ? authState.currentAccount.username : "Login Account"}
</div>
<div class="text-[10px] uppercase tracking-wider dark:text-zinc-500 text-gray-500 flex items-center gap-2">
- <span
- class="w-1.5 h-1.5 rounded-full {authState.currentAccount
- ? 'bg-emerald-500'
- : 'bg-zinc-400'}"
- ></span>
- {authState.currentAccount ? "Online" : "Guest"}
+ {#if authState.currentAccount}
+ {#if authState.currentAccount.type === "Microsoft"}
+ {#if authState.currentAccount.expires_at && authState.currentAccount.expires_at * 1000 < Date.now()}
+ <span class="w-1.5 h-1.5 rounded-full bg-red-500"></span>
+ <span class="text-red-400">Expired</span>
+ {:else}
+ <span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
+ Online
+ {/if}
+ {:else}
+ <span class="w-1.5 h-1.5 rounded-full bg-amber-500"></span>
+ Offline
+ {/if}
+ {:else}
+ <span class="w-1.5 h-1.5 rounded-full bg-zinc-400"></span>
+ Guest
+ {/if}
</div>
</div>
</div>
@@ -94,47 +160,70 @@
<div class="flex flex-col items-end mr-2">
<!-- Custom Version Dropdown -->
<div class="relative" bind:this={dropdownRef}>
- <button
- type="button"
- onclick={() => isVersionDropdownOpen = !isVersionDropdownOpen}
- class="flex items-center justify-between gap-2 w-56 px-4 py-2.5 text-left
- dark:bg-zinc-900 bg-zinc-50 border dark:border-zinc-700 border-zinc-300 rounded-md
- text-sm font-mono dark:text-white text-gray-900
- dark:hover:border-zinc-600 hover:border-zinc-400
- focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
- transition-colors cursor-pointer outline-none"
- >
- <span class="truncate">
- {#if gameState.versions.length === 0}
- Loading...
- {:else}
- {gameState.selectedVersion || "Select version"}
- {/if}
- </span>
- <ChevronDown
- size={14}
- class="shrink-0 dark:text-zinc-500 text-zinc-400 transition-transform duration-200 {isVersionDropdownOpen ? 'rotate-180' : ''}"
- />
- </button>
+ <div class="flex items-center gap-2">
+ <button
+ type="button"
+ onclick={() => loadInstalledVersions()}
+ class="p-2.5 dark:bg-zinc-900 bg-zinc-50 border dark:border-zinc-700 border-zinc-300 rounded-md
+ dark:text-zinc-500 text-zinc-400 dark:hover:text-white hover:text-black
+ dark:hover:border-zinc-600 hover:border-zinc-400 transition-colors"
+ title="Refresh installed versions"
+ >
+ <RefreshCw size={14} class={isLoadingVersions ? 'animate-spin' : ''} />
+ </button>
+ <button
+ type="button"
+ onclick={() => isVersionDropdownOpen = !isVersionDropdownOpen}
+ disabled={installedVersions.length === 0 && !isLoadingVersions}
+ class="flex items-center justify-between gap-2 w-56 px-4 py-2.5 text-left
+ dark:bg-zinc-900 bg-zinc-50 border dark:border-zinc-700 border-zinc-300 rounded-md
+ text-sm font-mono dark:text-white text-gray-900
+ dark:hover:border-zinc-600 hover:border-zinc-400
+ focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
+ transition-colors cursor-pointer outline-none
+ disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ <span class="truncate">
+ {#if isLoadingVersions}
+ Loading...
+ {:else if installedVersions.length === 0}
+ No versions installed
+ {:else}
+ {gameState.selectedVersion || "Select version"}
+ {/if}
+ </span>
+ <ChevronDown
+ size={14}
+ class="shrink-0 dark:text-zinc-500 text-zinc-400 transition-transform duration-200 {isVersionDropdownOpen ? 'rotate-180' : ''}"
+ />
+ </button>
+ </div>
- {#if isVersionDropdownOpen}
+ {#if isVersionDropdownOpen && installedVersions.length > 0}
<div
class="absolute z-50 w-full mt-1 py-1 dark:bg-zinc-900 bg-white border dark:border-zinc-700 border-zinc-300 rounded-md shadow-xl
- max-h-72 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150 bottom-full mb-1"
+ max-h-72 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150 bottom-full mb-1 right-0"
>
{#each versionOptions as version}
<button
type="button"
onclick={() => selectVersion(version.id)}
- disabled={version.id === "loading"}
+ disabled={version.id === "loading" || version.id === "empty"}
class="w-full flex items-center justify-between px-3 py-2 text-sm font-mono text-left
transition-colors outline-none
{version.id === gameState.selectedVersion
? 'bg-indigo-600 text-white'
: 'dark:text-zinc-300 text-gray-700 dark:hover:bg-zinc-800 hover:bg-zinc-100'}
- {version.id === 'loading' ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
+ {version.id === 'loading' || version.id === 'empty' ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
>
- <span class="truncate">{version.label}</span>
+ <span class="truncate flex items-center gap-2">
+ {version.id}
+ {#if version.type && version.type !== 'release' && version.type !== 'loading' && version.type !== 'empty'}
+ <span class="text-[10px] uppercase {version.id === gameState.selectedVersion ? 'text-white/70' : getVersionTypeColor(version.type)}">
+ {version.type}
+ </span>
+ {/if}
+ </span>
{#if version.id === gameState.selectedVersion}
<Check size={14} class="shrink-0 ml-2" />
{/if}
@@ -147,7 +236,8 @@
<button
onclick={() => gameState.startGame()}
- class="bg-emerald-600 hover:bg-emerald-500 text-white h-14 px-10 rounded-sm transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-emerald-500/20 flex items-center gap-3 font-bold text-lg tracking-widest uppercase"
+ disabled={installedVersions.length === 0 || !gameState.selectedVersion}
+ class="bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed text-white h-14 px-10 rounded-sm transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-emerald-500/20 flex items-center gap-3 font-bold text-lg tracking-widest uppercase"
>
<Play size={24} fill="currentColor" />
<span>Launch</span>
diff --git a/ui/src/components/HomeView.svelte b/ui/src/components/HomeView.svelte
index 7bb7e44..2fa8390 100644
--- a/ui/src/components/HomeView.svelte
+++ b/ui/src/components/HomeView.svelte
@@ -3,6 +3,7 @@
import { gameState } from '../stores/game.svelte';
import { releasesState } from '../stores/releases.svelte';
import { Calendar, ExternalLink } from 'lucide-svelte';
+ import { getSaturnEffect } from './ParticleBackground.svelte';
type Props = {
mouseX: number;
@@ -10,6 +11,60 @@
};
let { mouseX = 0, mouseY = 0 }: Props = $props();
+ // Saturn effect mouse interaction handlers
+ function handleSaturnMouseDown(e: MouseEvent) {
+ const effect = getSaturnEffect();
+ if (effect) {
+ effect.handleMouseDown(e.clientX);
+ }
+ }
+
+ function handleSaturnMouseMove(e: MouseEvent) {
+ const effect = getSaturnEffect();
+ if (effect) {
+ effect.handleMouseMove(e.clientX);
+ }
+ }
+
+ function handleSaturnMouseUp() {
+ const effect = getSaturnEffect();
+ if (effect) {
+ effect.handleMouseUp();
+ }
+ }
+
+ function handleSaturnMouseLeave() {
+ const effect = getSaturnEffect();
+ if (effect) {
+ effect.handleMouseUp();
+ }
+ }
+
+ function handleSaturnTouchStart(e: TouchEvent) {
+ if (e.touches.length === 1) {
+ const effect = getSaturnEffect();
+ if (effect) {
+ effect.handleTouchStart(e.touches[0].clientX);
+ }
+ }
+ }
+
+ function handleSaturnTouchMove(e: TouchEvent) {
+ if (e.touches.length === 1) {
+ const effect = getSaturnEffect();
+ if (effect) {
+ effect.handleTouchMove(e.touches[0].clientX);
+ }
+ }
+ }
+
+ function handleSaturnTouchEnd() {
+ const effect = getSaturnEffect();
+ if (effect) {
+ effect.handleTouchEnd();
+ }
+ }
+
onMount(() => {
releasesState.loadReleases();
});
@@ -65,6 +120,7 @@
// Formatting helper
const formatLine = (text: string) => text
.replace(/\*\*(.*?)\*\*/g, '<strong class="text-zinc-200">$1</strong>')
+ .replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em class="text-zinc-400 italic">$1</em>')
.replace(/`([^`]+)`/g, '<code class="bg-zinc-800 px-1 py-0.5 rounded text-xs text-zinc-300 font-mono border border-white/5 break-all whitespace-normal">$1</code>')
.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank" class="text-indigo-400 hover:text-indigo-300 hover:underline decoration-indigo-400/30 break-all">$1</a>');
@@ -103,8 +159,18 @@
<!-- Scrollable Container -->
<div class="relative z-10 h-full {releasesState.isLoading ? 'overflow-hidden' : 'overflow-y-auto custom-scrollbar scroll-smooth'}">
- <!-- Hero Section (Full Height) -->
- <div class="min-h-full flex flex-col justify-end p-12 pb-32">
+ <!-- Hero Section (Full Height) - Interactive area for Saturn rotation -->
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
+ <div
+ class="min-h-full flex flex-col justify-end p-12 pb-32 cursor-grab active:cursor-grabbing select-none"
+ onmousedown={handleSaturnMouseDown}
+ onmousemove={handleSaturnMouseMove}
+ onmouseup={handleSaturnMouseUp}
+ onmouseleave={handleSaturnMouseLeave}
+ ontouchstart={handleSaturnTouchStart}
+ ontouchmove={handleSaturnTouchMove}
+ ontouchend={handleSaturnTouchEnd}
+ >
<!-- 3D Floating Hero Text -->
<div
class="transition-transform duration-200 ease-out origin-bottom-left"
diff --git a/ui/src/components/ModLoaderSelector.svelte b/ui/src/components/ModLoaderSelector.svelte
index cb949c5..e9d147b 100644
--- a/ui/src/components/ModLoaderSelector.svelte
+++ b/ui/src/components/ModLoaderSelector.svelte
@@ -1,12 +1,14 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
+ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type {
FabricGameVersion,
FabricLoaderVersion,
ForgeVersion,
ModLoaderType,
} from "../types";
- import { Loader2, Download, AlertCircle, Check, ChevronDown } from 'lucide-svelte';
+ import { Loader2, Download, AlertCircle, Check, ChevronDown, CheckCircle } from 'lucide-svelte';
+ import { logsState } from "../stores/logs.svelte";
interface Props {
selectedGameVersion: string;
@@ -18,7 +20,9 @@
// State
let selectedLoader = $state<ModLoaderType>("vanilla");
let isLoading = $state(false);
+ let isInstalling = $state(false);
let error = $state<string | null>(null);
+ let isVersionInstalled = $state(false);
// Fabric state
let fabricLoaders = $state<FabricLoaderVersion[]>([]);
@@ -33,13 +37,35 @@
let fabricDropdownRef = $state<HTMLDivElement | null>(null);
let forgeDropdownRef = $state<HTMLDivElement | null>(null);
- // Load mod loader versions when game version changes
+ // Check if version is installed when game version changes
+ $effect(() => {
+ if (selectedGameVersion) {
+ checkInstallStatus();
+ }
+ });
+
+ // Load mod loader versions when game version or loader type changes
$effect(() => {
if (selectedGameVersion && selectedLoader !== "vanilla") {
loadModLoaderVersions();
}
});
+ async function checkInstallStatus() {
+ if (!selectedGameVersion) {
+ isVersionInstalled = false;
+ return;
+ }
+ try {
+ isVersionInstalled = await invoke<boolean>("check_version_installed", {
+ versionId: selectedGameVersion,
+ });
+ } catch (e) {
+ console.error("Failed to check install status:", e);
+ isVersionInstalled = false;
+ }
+ }
+
async function loadModLoaderVersions() {
isLoading = true;
error = null;
@@ -51,7 +77,6 @@
});
fabricLoaders = loaders.map((l) => l.loader);
if (fabricLoaders.length > 0) {
- // Select first stable version or first available
const stable = fabricLoaders.find((l) => l.stable);
selectedFabricLoader = stable?.version || fabricLoaders[0].version;
}
@@ -63,7 +88,6 @@
}
);
if (forgeVersions.length > 0) {
- // Select recommended version first, then latest
const recommended = forgeVersions.find((v) => v.recommended);
const latest = forgeVersions.find((v) => v.latest);
selectedForgeVersion =
@@ -78,34 +102,75 @@
}
}
+ async function installVanilla() {
+ if (!selectedGameVersion) {
+ error = "Please select a Minecraft version first";
+ return;
+ }
+
+ isInstalling = true;
+ error = null;
+ logsState.addLog("info", "Installer", `Starting installation of ${selectedGameVersion}...`);
+
+ try {
+ await invoke("install_version", {
+ versionId: selectedGameVersion,
+ });
+ logsState.addLog("info", "Installer", `Successfully installed ${selectedGameVersion}`);
+ isVersionInstalled = true;
+ onInstall(selectedGameVersion);
+ } catch (e) {
+ error = `Failed to install: ${e}`;
+ logsState.addLog("error", "Installer", `Installation failed: ${e}`);
+ console.error(e);
+ } finally {
+ isInstalling = false;
+ }
+ }
+
async function installModLoader() {
if (!selectedGameVersion) {
error = "Please select a Minecraft version first";
return;
}
- isLoading = true;
+ isInstalling = true;
error = null;
try {
+ // First install the base game if not installed
+ if (!isVersionInstalled) {
+ logsState.addLog("info", "Installer", `Installing base game ${selectedGameVersion} first...`);
+ await invoke("install_version", {
+ versionId: selectedGameVersion,
+ });
+ isVersionInstalled = true;
+ }
+
+ // Then install the mod loader
if (selectedLoader === "fabric" && selectedFabricLoader) {
+ logsState.addLog("info", "Installer", `Installing Fabric ${selectedFabricLoader} for ${selectedGameVersion}...`);
const result = await invoke<any>("install_fabric", {
gameVersion: selectedGameVersion,
loaderVersion: selectedFabricLoader,
});
+ logsState.addLog("info", "Installer", `Fabric installed successfully: ${result.id}`);
onInstall(result.id);
} else if (selectedLoader === "forge" && selectedForgeVersion) {
+ logsState.addLog("info", "Installer", `Installing Forge ${selectedForgeVersion} for ${selectedGameVersion}...`);
const result = await invoke<any>("install_forge", {
gameVersion: selectedGameVersion,
forgeVersion: selectedForgeVersion,
});
+ logsState.addLog("info", "Installer", `Forge installed successfully: ${result.id}`);
onInstall(result.id);
}
} catch (e) {
error = `Failed to install ${selectedLoader}: ${e}`;
+ logsState.addLog("error", "Installer", `Installation failed: ${e}`);
console.error(e);
} finally {
- isLoading = false;
+ isInstalling = false;
}
}
@@ -170,6 +235,7 @@
? 'bg-white dark:bg-white/10 text-black dark:text-white shadow-sm'
: 'text-zinc-500 dark:text-zinc-500 hover:text-black dark:hover:text-white'}"
onclick={() => onLoaderChange(loader as ModLoaderType)}
+ disabled={isInstalling}
>
{loader}
</button>
@@ -178,15 +244,38 @@
<!-- Content Area -->
<div class="min-h-[100px] flex flex-col justify-center">
- {#if selectedLoader === "vanilla"}
- <div class="text-center p-6 border border-dashed border-zinc-200 dark:border-white/10 rounded-sm text-zinc-500 text-sm">
- Standard Minecraft experience. No modifications.
- </div>
-
- {:else if !selectedGameVersion}
+ {#if !selectedGameVersion}
<div class="flex items-center gap-3 p-4 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 text-amber-700 dark:text-amber-200 rounded-sm text-sm">
<AlertCircle size={16} />
- <span>Please select a base Minecraft version first.</span>
+ <span>Please select a Minecraft version first.</span>
+ </div>
+
+ {:else if selectedLoader === "vanilla"}
+ <div class="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
+ <div class="text-center p-4 border border-dashed border-zinc-200 dark:border-white/10 rounded-sm text-zinc-500 text-sm">
+ Standard Minecraft experience. No modifications.
+ </div>
+
+ {#if isVersionInstalled}
+ <div class="flex items-center justify-center gap-2 p-3 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 text-emerald-700 dark:text-emerald-300 rounded-sm text-sm">
+ <CheckCircle size={16} />
+ <span>Version {selectedGameVersion} is installed</span>
+ </div>
+ {:else}
+ <button
+ class="w-full bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed text-white py-2.5 px-4 rounded-sm font-bold text-sm transition-all flex items-center justify-center gap-2"
+ onclick={installVanilla}
+ disabled={isInstalling}
+ >
+ {#if isInstalling}
+ <Loader2 class="animate-spin" size={16} />
+ Installing...
+ {:else}
+ <Download size={16} />
+ Install {selectedGameVersion}
+ {/if}
+ </button>
+ {/if}
</div>
{:else if isLoading}
@@ -211,12 +300,13 @@
<button
type="button"
onclick={() => isFabricDropdownOpen = !isFabricDropdownOpen}
+ disabled={isInstalling}
class="w-full flex items-center justify-between gap-2 px-4 py-2.5 text-left
bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md
text-sm text-gray-900 dark:text-white
hover:border-zinc-400 dark:hover:border-zinc-600
focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
- transition-colors cursor-pointer outline-none"
+ transition-colors cursor-pointer outline-none disabled:opacity-50"
>
<span class="truncate">{selectedFabricLabel}</span>
<ChevronDown
@@ -252,12 +342,17 @@
</div>
<button
- class="w-full bg-black dark:bg-white text-white dark:text-black py-2.5 px-4 rounded-sm font-bold text-sm transition-all hover:opacity-90 flex items-center justify-center gap-2"
+ class="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white py-2.5 px-4 rounded-sm font-bold text-sm transition-all flex items-center justify-center gap-2"
onclick={installModLoader}
- disabled={isLoading || !selectedFabricLoader}
+ disabled={isInstalling || !selectedFabricLoader}
>
- <Download size={16} />
- Install Fabric
+ {#if isInstalling}
+ <Loader2 class="animate-spin" size={16} />
+ Installing...
+ {:else}
+ <Download size={16} />
+ Install Fabric {selectedFabricLoader}
+ {/if}
</button>
</div>
@@ -277,12 +372,13 @@
<button
type="button"
onclick={() => isForgeDropdownOpen = !isForgeDropdownOpen}
+ disabled={isInstalling}
class="w-full flex items-center justify-between gap-2 px-4 py-2.5 text-left
bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md
text-sm text-gray-900 dark:text-white
hover:border-zinc-400 dark:hover:border-zinc-600
focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
- transition-colors cursor-pointer outline-none"
+ transition-colors cursor-pointer outline-none disabled:opacity-50"
>
<span class="truncate">{selectedForgeLabel}</span>
<ChevronDown
@@ -318,12 +414,17 @@
</div>
<button
- class="w-full bg-black dark:bg-white text-white dark:text-black py-2.5 px-4 rounded-sm font-bold text-sm transition-all hover:opacity-90 flex items-center justify-center gap-2"
- onclick={installModLoader}
- disabled={isLoading || !selectedForgeVersion}
+ class="w-full bg-orange-600 hover:bg-orange-500 disabled:opacity-50 disabled:cursor-not-allowed text-white py-2.5 px-4 rounded-sm font-bold text-sm transition-all flex items-center justify-center gap-2"
+ onclick={installModLoader}
+ disabled={isInstalling || !selectedForgeVersion}
>
- <Download size={16} />
- Install Forge
+ {#if isInstalling}
+ <Loader2 class="animate-spin" size={16} />
+ Installing...
+ {:else}
+ <Download size={16} />
+ Install Forge {selectedForgeVersion}
+ {/if}
</button>
{/if}
</div>
diff --git a/ui/src/components/ParticleBackground.svelte b/ui/src/components/ParticleBackground.svelte
index 080f1f2..7644b1a 100644
--- a/ui/src/components/ParticleBackground.svelte
+++ b/ui/src/components/ParticleBackground.svelte
@@ -1,7 +1,17 @@
+<script lang="ts" module>
+ import { SaturnEffect } from "../lib/effects/SaturnEffect";
+
+ // Global reference to the active Saturn effect for external control
+ let globalSaturnEffect: SaturnEffect | null = null;
+
+ export function getSaturnEffect(): SaturnEffect | null {
+ return globalSaturnEffect;
+ }
+</script>
+
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { ConstellationEffect } from "../lib/effects/ConstellationEffect";
- import { SaturnEffect } from "../lib/effects/SaturnEffect";
import { settingsState } from "../stores/settings.svelte";
let canvas: HTMLCanvasElement;
@@ -16,8 +26,10 @@
if (settingsState.settings.active_effect === "saturn") {
activeEffect = new SaturnEffect(canvas);
+ globalSaturnEffect = activeEffect;
} else {
activeEffect = new ConstellationEffect(canvas);
+ globalSaturnEffect = null;
}
// Ensure correct size immediately
@@ -48,6 +60,7 @@
onDestroy(() => {
if (activeEffect) activeEffect.destroy();
+ globalSaturnEffect = null;
});
</script>
diff --git a/ui/src/components/VersionsView.svelte b/ui/src/components/VersionsView.svelte
index 99cc296..ce354b9 100644
--- a/ui/src/components/VersionsView.svelte
+++ b/ui/src/components/VersionsView.svelte
@@ -80,6 +80,8 @@
return { text: "Fabric", class: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-300 dark:border-indigo-500/30" };
case "forge":
return { text: "Forge", class: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-500/20 dark:text-orange-300 dark:border-orange-500/30" };
+ case "modpack":
+ return { text: "Modpack", class: "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-500/20 dark:text-purple-300 dark:border-purple-500/30" };
default:
return { text: type, class: "bg-zinc-100 text-zinc-700 border-zinc-200 dark:bg-zinc-500/20 dark:text-zinc-300 dark:border-zinc-500/30" };
}
diff --git a/ui/src/lib/effects/SaturnEffect.ts b/ui/src/lib/effects/SaturnEffect.ts
index a370936..42aee66 100644
--- a/ui/src/lib/effects/SaturnEffect.ts
+++ b/ui/src/lib/effects/SaturnEffect.ts
@@ -18,6 +18,21 @@ export class SaturnEffect {
private angle: number = 0;
private scaleFactor: number = 1;
+ // Mouse interaction properties
+ private isDragging: boolean = false;
+ private lastMouseX: number = 0;
+ private lastMouseTime: number = 0;
+ private mouseVelocities: number[] = []; // Store recent velocities for averaging
+
+ // Rotation speed control
+ private readonly baseSpeed: number = 0.005; // Original rotation speed
+ private currentSpeed: number = 0.005; // Current rotation speed (can be modified by mouse)
+ private rotationDirection: number = 1; // 1 for clockwise, -1 for counter-clockwise
+ private readonly speedDecayRate: number = 0.992; // How fast speed returns to normal (closer to 1 = slower decay)
+ private readonly minSpeedMultiplier: number = 1; // Minimum speed is baseSpeed
+ private readonly maxSpeedMultiplier: number = 50; // Maximum speed is 50x baseSpeed
+ private isStopped: boolean = false; // Whether the user has stopped the rotation
+
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d', {
@@ -33,6 +48,121 @@ export class SaturnEffect {
this.animate();
}
+ // Public methods for external mouse event handling
+ // These can be called from any element that wants to control the Saturn rotation
+
+ handleMouseDown(clientX: number) {
+ this.isDragging = true;
+ this.lastMouseX = clientX;
+ this.lastMouseTime = performance.now();
+ this.mouseVelocities = [];
+ }
+
+ handleMouseMove(clientX: number) {
+ if (!this.isDragging) return;
+
+ const currentTime = performance.now();
+ const deltaTime = currentTime - this.lastMouseTime;
+
+ if (deltaTime > 0) {
+ const deltaX = clientX - this.lastMouseX;
+ const velocity = deltaX / deltaTime; // pixels per millisecond
+
+ // Store recent velocities (keep last 5 for smoothing)
+ this.mouseVelocities.push(velocity);
+ if (this.mouseVelocities.length > 5) {
+ this.mouseVelocities.shift();
+ }
+
+ // Apply direct rotation while dragging
+ this.angle += deltaX * 0.002;
+ }
+
+ this.lastMouseX = clientX;
+ this.lastMouseTime = currentTime;
+ }
+
+ handleMouseUp() {
+ if (this.isDragging && this.mouseVelocities.length > 0) {
+ this.applyFlingVelocity();
+ }
+ this.isDragging = false;
+ }
+
+ handleTouchStart(clientX: number) {
+ this.isDragging = true;
+ this.lastMouseX = clientX;
+ this.lastMouseTime = performance.now();
+ this.mouseVelocities = [];
+ }
+
+ handleTouchMove(clientX: number) {
+ if (!this.isDragging) return;
+
+ const currentTime = performance.now();
+ const deltaTime = currentTime - this.lastMouseTime;
+
+ if (deltaTime > 0) {
+ const deltaX = clientX - this.lastMouseX;
+ const velocity = deltaX / deltaTime;
+
+ this.mouseVelocities.push(velocity);
+ if (this.mouseVelocities.length > 5) {
+ this.mouseVelocities.shift();
+ }
+
+ this.angle += deltaX * 0.002;
+ }
+
+ this.lastMouseX = clientX;
+ this.lastMouseTime = currentTime;
+ }
+
+ handleTouchEnd() {
+ if (this.isDragging && this.mouseVelocities.length > 0) {
+ this.applyFlingVelocity();
+ }
+ this.isDragging = false;
+ }
+
+ private applyFlingVelocity() {
+ // Calculate average velocity from recent samples
+ const avgVelocity = this.mouseVelocities.reduce((a, b) => a + b, 0) / this.mouseVelocities.length;
+
+ // Threshold for considering it a "fling" (pixels per millisecond)
+ const flingThreshold = 0.3;
+ // Threshold for considering the rotation as "stopped" by user
+ const stopThreshold = 0.1;
+
+ if (Math.abs(avgVelocity) > flingThreshold) {
+ // User flung it - start rotating again
+ this.isStopped = false;
+
+ // Determine new direction based on fling direction
+ const newDirection = avgVelocity > 0 ? 1 : -1;
+
+ // If direction changed, update it permanently
+ if (newDirection !== this.rotationDirection) {
+ this.rotationDirection = newDirection;
+ }
+
+ // Calculate speed boost based on fling strength
+ // Map velocity to speed multiplier (stronger fling = faster rotation)
+ const speedMultiplier = Math.min(
+ this.maxSpeedMultiplier,
+ this.minSpeedMultiplier + Math.abs(avgVelocity) * 10
+ );
+
+ this.currentSpeed = this.baseSpeed * speedMultiplier;
+ } else if (Math.abs(avgVelocity) < stopThreshold) {
+ // User gently released - keep it stopped
+ this.isStopped = true;
+ this.currentSpeed = 0;
+ }
+ // If velocity is between stopThreshold and flingThreshold,
+ // keep current state (don't change isStopped)
+ }
+
resize(width: number, height: number) {
const dpr = window.devicePixelRatio || 1;
this.width = width;
@@ -104,8 +234,21 @@ export class SaturnEffect {
// Normal blending
this.ctx.globalCompositeOperation = 'source-over';
- // Slower rotation (from 0.0015 to 0.0005)
- this.angle += 0.0005;
+ // Update rotation speed - decay towards base speed while maintaining direction
+ if (!this.isDragging && !this.isStopped) {
+ if (this.currentSpeed > this.baseSpeed) {
+ // Gradually decay speed back to base speed
+ this.currentSpeed = this.baseSpeed + (this.currentSpeed - this.baseSpeed) * this.speedDecayRate;
+
+ // Snap to base speed when close enough
+ if (this.currentSpeed - this.baseSpeed < 0.00001) {
+ this.currentSpeed = this.baseSpeed;
+ }
+ }
+
+ // Apply rotation with current speed and direction
+ this.angle += this.currentSpeed * this.rotationDirection;
+ }
const cx = this.width * 0.6;
const cy = this.height * 0.5;
diff --git a/ui/src/stores/auth.svelte.ts b/ui/src/stores/auth.svelte.ts
index 3d58245..eb9dccd 100644
--- a/ui/src/stores/auth.svelte.ts
+++ b/ui/src/stores/auth.svelte.ts
@@ -1,11 +1,14 @@
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-shell";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type { Account, DeviceCodeResponse } from "../types";
import { uiState } from "./ui.svelte";
+import { logsState } from "./logs.svelte";
export class AuthState {
currentAccount = $state<Account | null>(null);
isLoginModalOpen = $state(false);
+ isLogoutConfirmOpen = $state(false);
loginMode = $state<"select" | "offline" | "microsoft">("select");
offlineUsername = $state("");
deviceCodeData = $state<DeviceCodeResponse | null>(null);
@@ -14,6 +17,7 @@ export class AuthState {
private pollInterval: ReturnType<typeof setInterval> | null = null;
private isPollingRequestActive = false;
+ private authProgressUnlisten: UnlistenFn | null = null;
async checkAccount() {
try {
@@ -26,15 +30,29 @@ export class AuthState {
openLoginModal() {
if (this.currentAccount) {
- if (confirm("Logout " + this.currentAccount.username + "?")) {
- invoke("logout").then(() => (this.currentAccount = null));
- }
+ // Show custom logout confirmation dialog
+ this.isLogoutConfirmOpen = true;
return;
}
this.resetLoginState();
this.isLoginModalOpen = true;
}
+ cancelLogout() {
+ this.isLogoutConfirmOpen = false;
+ }
+
+ async confirmLogout() {
+ this.isLogoutConfirmOpen = false;
+ try {
+ await invoke("logout");
+ this.currentAccount = null;
+ uiState.setStatus("Logged out successfully");
+ } catch (e) {
+ console.error("Logout failed:", e);
+ }
+ }
+
closeLoginModal() {
this.stopPolling();
this.isLoginModalOpen = false;
@@ -65,6 +83,9 @@ export class AuthState {
this.msLoginStatus = "Waiting for authorization...";
this.stopPolling();
+ // Setup auth progress listener
+ this.setupAuthProgressListener();
+
try {
this.deviceCodeData = (await invoke(
"start_microsoft_login"
@@ -78,6 +99,7 @@ export class AuthState {
}
open(this.deviceCodeData.verification_uri);
+ logsState.addLog("info", "Auth", "Microsoft login started, waiting for browser authorization...");
console.log("Starting polling for token...");
const intervalMs = (this.deviceCodeData.interval || 5) * 1000;
@@ -87,6 +109,7 @@ export class AuthState {
);
}
} catch (e) {
+ logsState.addLog("error", "Auth", `Failed to start Microsoft login: ${e}`);
alert("Failed to start Microsoft login: " + e);
this.loginMode = "select";
} finally {
@@ -94,6 +117,27 @@ export class AuthState {
}
}
+ private async setupAuthProgressListener() {
+ // Clean up previous listener if exists
+ if (this.authProgressUnlisten) {
+ this.authProgressUnlisten();
+ this.authProgressUnlisten = null;
+ }
+
+ this.authProgressUnlisten = await listen<string>("auth-progress", (event) => {
+ const message = event.payload;
+ this.msLoginStatus = message;
+ logsState.addLog("info", "Auth", message);
+ });
+ }
+
+ private cleanupAuthListener() {
+ if (this.authProgressUnlisten) {
+ this.authProgressUnlisten();
+ this.authProgressUnlisten = null;
+ }
+ }
+
stopPolling() {
if (this.pollInterval) {
clearInterval(this.pollInterval);
@@ -113,7 +157,9 @@ export class AuthState {
console.log("Login Successful!", this.currentAccount);
this.stopPolling();
+ this.cleanupAuthListener();
this.isLoginModalOpen = false;
+ logsState.addLog("info", "Auth", `Login successful! Welcome, ${this.currentAccount.username}`);
uiState.setStatus("Welcome back, " + this.currentAccount.username);
} catch (e: any) {
const errStr = e.toString();
@@ -122,12 +168,14 @@ export class AuthState {
} else {
console.error("Polling Error:", errStr);
this.msLoginStatus = "Error: " + errStr;
+ logsState.addLog("error", "Auth", `Login error: ${errStr}`);
if (
errStr.includes("expired_token") ||
errStr.includes("access_denied")
) {
this.stopPolling();
+ this.cleanupAuthListener();
alert("Login failed: " + errStr);
this.loginMode = "select";
}
diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts
index 09a7d5e..0f02d64 100644
--- a/ui/src/types/index.ts
+++ b/ui/src/types/index.ts
@@ -12,6 +12,9 @@ export interface Account {
type: "Offline" | "Microsoft";
username: string;
uuid: string;
+ access_token?: string;
+ refresh_token?: string;
+ expires_at?: number; // Unix timestamp for Microsoft accounts
}
export interface DeviceCodeResponse {