diff options
| -rw-r--r-- | README.md | 20 | ||||
| -rw-r--r-- | src-tauri/src/core/account_storage.rs | 159 | ||||
| -rw-r--r-- | src-tauri/src/core/auth.rs | 83 | ||||
| -rw-r--r-- | src-tauri/src/core/java.rs | 254 | ||||
| -rw-r--r-- | src-tauri/src/core/mod.rs | 2 | ||||
| -rw-r--r-- | src-tauri/src/main.rs | 219 | ||||
| -rw-r--r-- | ui/src/App.svelte | 64 |
7 files changed, 791 insertions, 10 deletions
@@ -23,6 +23,26 @@ DropOut is a modern, fast, and efficient Minecraft launcher built with the lates | **Windows** | x86_64 | Done | | **Windows** | ARM64 | Done | +## Roadmap + +- [x] **Account Persistence** — Save login state between sessions +- [x] **Token Refresh** — Auto-refresh expired Microsoft tokens +- [x] **JVM Arguments Parsing** — Parse `arguments.jvm` from version.json for Mac M1/ARM support +- [x] **Java Auto-detection** — Scan common paths for Java installations + +- [ ] **Fabric Loader Support** — Install and launch with Fabric +- [ ] **Forge Loader Support** — Install and launch with Forge +- [ ] **Instance/Profile System** — Multiple isolated game directories with different versions/mods +- [ ] **Version Filtering** — Filter by release/snapshot/old_beta in UI +- [ ] **Multi-account Support** — Switch between multiple accounts +- [ ] **Custom Game Directory** — Allow users to choose game files location + +- [ ] **Launcher Auto-updater** — Self-update mechanism via Tauri updater plugin +- [ ] **Mods Manager** — Enable/disable mods without deletion +- [ ] **Resource Packs Manager** — Browse and manage resource packs +- [ ] **Quilt Loader Support** — Install and launch with Quilt +- [ ] **Import from Other Launchers** — Migration tool for MultiMC/Prism profiles + ## Installation Download the latest release for your platform from the [Releases](https://github.com/HsiangNianian/DropOut/releases) page. diff --git a/src-tauri/src/core/account_storage.rs b/src-tauri/src/core/account_storage.rs new file mode 100644 index 0000000..b8e15e1 --- /dev/null +++ b/src-tauri/src/core/account_storage.rs @@ -0,0 +1,159 @@ +use crate::core::auth::{Account, MicrosoftAccount, OfflineAccount}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +/// Stored account data for persistence +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountStore { + pub accounts: Vec<StoredAccount>, + pub active_account_id: Option<String>, +} + +impl Default for AccountStore { + fn default() -> Self { + Self { + accounts: Vec::new(), + active_account_id: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum StoredAccount { + Offline(OfflineAccount), + Microsoft(StoredMicrosoftAccount), +} + +/// Microsoft account with refresh token for persistence +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredMicrosoftAccount { + pub username: String, + pub uuid: String, + pub access_token: String, + pub refresh_token: Option<String>, + pub ms_refresh_token: Option<String>, // Microsoft OAuth refresh token + pub expires_at: i64, +} + +impl StoredAccount { + pub fn id(&self) -> String { + match self { + StoredAccount::Offline(a) => a.uuid.clone(), + StoredAccount::Microsoft(a) => a.uuid.clone(), + } + } + + pub fn to_account(&self) -> Account { + match self { + StoredAccount::Offline(a) => Account::Offline(a.clone()), + StoredAccount::Microsoft(a) => Account::Microsoft(MicrosoftAccount { + username: a.username.clone(), + uuid: a.uuid.clone(), + access_token: a.access_token.clone(), + refresh_token: a.refresh_token.clone(), + expires_at: a.expires_at, + }), + } + } + + pub fn from_account(account: &Account, ms_refresh_token: Option<String>) -> Self { + match account { + Account::Offline(a) => StoredAccount::Offline(a.clone()), + Account::Microsoft(a) => StoredAccount::Microsoft(StoredMicrosoftAccount { + username: a.username.clone(), + uuid: a.uuid.clone(), + access_token: a.access_token.clone(), + refresh_token: a.refresh_token.clone(), + ms_refresh_token, + expires_at: a.expires_at, + }), + } + } +} + +pub struct AccountStorage { + file_path: PathBuf, +} + +impl AccountStorage { + pub fn new(app_data_dir: PathBuf) -> Self { + Self { + file_path: app_data_dir.join("accounts.json"), + } + } + + pub fn load(&self) -> AccountStore { + if self.file_path.exists() { + let content = fs::read_to_string(&self.file_path).unwrap_or_default(); + serde_json::from_str(&content).unwrap_or_default() + } else { + AccountStore::default() + } + } + + pub fn save(&self, store: &AccountStore) -> Result<(), String> { + let content = serde_json::to_string_pretty(store).map_err(|e| e.to_string())?; + if let Some(parent) = self.file_path.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + fs::write(&self.file_path, content).map_err(|e| e.to_string())?; + Ok(()) + } + + pub fn add_or_update_account( + &self, + account: &Account, + ms_refresh_token: Option<String>, + ) -> Result<(), String> { + let mut store = self.load(); + let stored = StoredAccount::from_account(account, ms_refresh_token); + let id = stored.id(); + + // Remove existing account with same ID + store.accounts.retain(|a| a.id() != id); + store.accounts.push(stored); + store.active_account_id = Some(id); + + self.save(&store) + } + + pub fn remove_account(&self, uuid: &str) -> Result<(), String> { + let mut store = self.load(); + store.accounts.retain(|a| a.id() != uuid); + if store.active_account_id.as_deref() == Some(uuid) { + store.active_account_id = store.accounts.first().map(|a| a.id()); + } + self.save(&store) + } + + pub fn get_active_account(&self) -> Option<(StoredAccount, Option<String>)> { + let store = self.load(); + if let Some(active_id) = &store.active_account_id { + store.accounts.iter().find(|a| &a.id() == active_id).map(|a| { + let ms_token = match a { + StoredAccount::Microsoft(m) => m.ms_refresh_token.clone(), + _ => None, + }; + (a.clone(), ms_token) + }) + } else { + None + } + } + + pub fn set_active_account(&self, uuid: &str) -> Result<(), String> { + let mut store = self.load(); + if store.accounts.iter().any(|a| a.id() == uuid) { + store.active_account_id = Some(uuid.to_string()); + self.save(&store) + } else { + Err("Account not found".to_string()) + } + } + + pub fn get_all_accounts(&self) -> Vec<StoredAccount> { + self.load().accounts + } +} diff --git a/src-tauri/src/core/auth.rs b/src-tauri/src/core/auth.rs index e26f850..624f1de 100644 --- a/src-tauri/src/core/auth.rs +++ b/src-tauri/src/core/auth.rs @@ -101,6 +101,89 @@ pub struct TokenError { pub error: String, } +/// Refresh Microsoft OAuth token using refresh_token +pub async fn refresh_microsoft_token(refresh_token: &str) -> Result<TokenResponse, String> { + let client = get_client(); + let url = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"; + + let params = [ + ("grant_type", "refresh_token"), + ("client_id", CLIENT_ID), + ("refresh_token", refresh_token), + ("scope", SCOPE), + ]; + + let resp = client + .post(url) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(serde_urlencoded::to_string(¶ms).map_err(|e| e.to_string())?) + .send() + .await + .map_err(|e| e.to_string())?; + + let text = resp.text().await.map_err(|e| e.to_string())?; + + if let Ok(token_resp) = serde_json::from_str::<TokenResponse>(&text) { + println!("[Auth] Token refreshed successfully!"); + return Ok(token_resp); + } + + if let Ok(err_resp) = serde_json::from_str::<TokenError>(&text) { + println!("[Auth] Token refresh error: {}", err_resp.error); + return Err(format!("Token refresh failed: {}", err_resp.error)); + } + + Err(format!("Unknown refresh response: {}", text)) +} + +/// Check if a Microsoft account token is expired or about to expire +pub fn is_token_expired(expires_at: i64) -> bool { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + // Consider expired if less than 5 minutes remaining + expires_at - now < 300 +} + +/// Full refresh flow: refresh MS token -> Xbox -> XSTS -> Minecraft +pub async fn refresh_full_auth(ms_refresh_token: &str) -> Result<(MicrosoftAccount, String), String> { + println!("[Auth] Starting full token refresh..."); + + // 1. Refresh Microsoft token + let token_resp = refresh_microsoft_token(ms_refresh_token).await?; + + // 2. Xbox Live Auth + let (xbl_token, uhs) = method_xbox_live(&token_resp.access_token).await?; + + // 3. XSTS Auth + let xsts_token = method_xsts(&xbl_token).await?; + + // 4. Minecraft Auth + let mc_token = login_minecraft(&xsts_token, &uhs).await?; + + // 5. Get Profile + let profile = fetch_profile(&mc_token).await?; + + // 6. Create Account + let account = MicrosoftAccount { + username: profile.name, + uuid: profile.id, + access_token: mc_token, + refresh_token: token_resp.refresh_token.clone(), + expires_at: (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + token_resp.expires_in) as i64, + }; + + // Return new MS refresh token for storage + let new_ms_refresh = token_resp.refresh_token.unwrap_or_else(|| ms_refresh_token.to_string()); + + Ok((account, new_ms_refresh)) +} + // Xbox Live Auth #[derive(Debug, Serialize, Deserialize)] pub struct XboxLiveResponse { diff --git a/src-tauri/src/core/java.rs b/src-tauri/src/core/java.rs new file mode 100644 index 0000000..e0962fa --- /dev/null +++ b/src-tauri/src/core/java.rs @@ -0,0 +1,254 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::process::Command; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JavaInstallation { + pub path: String, + pub version: String, + pub is_64bit: bool, +} + +/// Detect Java installations on the system +pub fn detect_java_installations() -> Vec<JavaInstallation> { + let mut installations = Vec::new(); + let candidates = get_java_candidates(); + + for candidate in candidates { + if let Some(java) = check_java_installation(&candidate) { + // Avoid duplicates + if !installations.iter().any(|j: &JavaInstallation| j.path == java.path) { + installations.push(java); + } + } + } + + // Sort by version (newer first) + installations.sort_by(|a, b| { + let v_a = parse_java_version(&a.version); + let v_b = parse_java_version(&b.version); + v_b.cmp(&v_a) + }); + + installations +} + +/// Get list of candidate Java paths to check +fn get_java_candidates() -> Vec<PathBuf> { + let mut candidates = Vec::new(); + + // Check PATH first + if let Ok(output) = Command::new(if cfg!(windows) { "where" } else { "which" }) + .arg("java") + .output() + { + if output.status.success() { + let paths = String::from_utf8_lossy(&output.stdout); + for line in paths.lines() { + let path = PathBuf::from(line.trim()); + if path.exists() { + candidates.push(path); + } + } + } + } + + #[cfg(target_os = "linux")] + { + // Common Linux Java paths + let linux_paths = [ + "/usr/lib/jvm", + "/usr/java", + "/opt/java", + "/opt/jdk", + "/opt/openjdk", + ]; + + for base in &linux_paths { + if let Ok(entries) = std::fs::read_dir(base) { + for entry in entries.flatten() { + let java_path = entry.path().join("bin/java"); + if java_path.exists() { + candidates.push(java_path); + } + } + } + } + + // Flatpak / Snap locations + let home = std::env::var("HOME").unwrap_or_default(); + let snap_java = PathBuf::from(&home).join(".sdkman/candidates/java"); + if snap_java.exists() { + if let Ok(entries) = std::fs::read_dir(&snap_java) { + for entry in entries.flatten() { + let java_path = entry.path().join("bin/java"); + if java_path.exists() { + candidates.push(java_path); + } + } + } + } + } + + #[cfg(target_os = "macos")] + { + // macOS Java paths + let mac_paths = [ + "/Library/Java/JavaVirtualMachines", + "/System/Library/Java/JavaVirtualMachines", + "/usr/local/opt/openjdk/bin/java", + "/opt/homebrew/opt/openjdk/bin/java", + ]; + + for path in &mac_paths { + let p = PathBuf::from(path); + if p.is_dir() { + if let Ok(entries) = std::fs::read_dir(&p) { + for entry in entries.flatten() { + let java_path = entry.path().join("Contents/Home/bin/java"); + if java_path.exists() { + candidates.push(java_path); + } + } + } + } else if p.exists() { + candidates.push(p); + } + } + + // Homebrew ARM64 + let homebrew_arm = PathBuf::from("/opt/homebrew/Cellar/openjdk"); + if homebrew_arm.exists() { + if let Ok(entries) = std::fs::read_dir(&homebrew_arm) { + for entry in entries.flatten() { + let java_path = entry.path().join("libexec/openjdk.jdk/Contents/Home/bin/java"); + if java_path.exists() { + candidates.push(java_path); + } + } + } + } + } + + #[cfg(target_os = "windows")] + { + // Windows Java paths + let program_files = std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".to_string()); + let program_files_x86 = std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| "C:\\Program Files (x86)".to_string()); + let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_default(); + + let win_paths = [ + format!("{}\\Java", program_files), + format!("{}\\Java", program_files_x86), + format!("{}\\Eclipse Adoptium", program_files), + format!("{}\\AdoptOpenJDK", program_files), + format!("{}\\Microsoft\\jdk", program_files), + format!("{}\\Zulu", program_files), + format!("{}\\Amazon Corretto", program_files), + format!("{}\\BellSoft\\LibericaJDK", program_files), + format!("{}\\Programs\\Eclipse Adoptium", local_app_data), + ]; + + for base in &win_paths { + let base_path = PathBuf::from(base); + if base_path.exists() { + if let Ok(entries) = std::fs::read_dir(&base_path) { + for entry in entries.flatten() { + let java_path = entry.path().join("bin\\java.exe"); + if java_path.exists() { + candidates.push(java_path); + } + } + } + } + } + + // Also check JAVA_HOME + if let Ok(java_home) = std::env::var("JAVA_HOME") { + let java_path = PathBuf::from(&java_home).join("bin\\java.exe"); + if java_path.exists() { + candidates.push(java_path); + } + } + } + + // JAVA_HOME environment variable (cross-platform) + if let Ok(java_home) = std::env::var("JAVA_HOME") { + let bin_name = if cfg!(windows) { "java.exe" } else { "java" }; + let java_path = PathBuf::from(&java_home).join("bin").join(bin_name); + if java_path.exists() { + candidates.push(java_path); + } + } + + candidates +} + +/// Check a specific Java installation and get its version info +fn check_java_installation(path: &PathBuf) -> Option<JavaInstallation> { + let output = Command::new(path) + .arg("-version") + .output() + .ok()?; + + // Java outputs version info to stderr + let version_output = String::from_utf8_lossy(&output.stderr); + + // Parse version string (e.g., "openjdk version \"17.0.1\"" or "java version \"1.8.0_301\"") + let version = parse_version_string(&version_output)?; + let is_64bit = version_output.contains("64-Bit"); + + Some(JavaInstallation { + path: path.to_string_lossy().to_string(), + version, + is_64bit, + }) +} + +/// Parse version string from java -version output +fn parse_version_string(output: &str) -> Option<String> { + for line in output.lines() { + if line.contains("version") { + // Find the quoted version string + if let Some(start) = line.find('"') { + if let Some(end) = line[start + 1..].find('"') { + return Some(line[start + 1..start + 1 + end].to_string()); + } + } + } + } + None +} + +/// Parse version for comparison (returns major version number) +fn parse_java_version(version: &str) -> u32 { + // Handle both old format (1.8.0_xxx) and new format (11.0.x, 17.0.x) + let parts: Vec<&str> = version.split('.').collect(); + if let Some(first) = parts.first() { + if *first == "1" { + // Old format: 1.8.0 -> major is 8 + parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0) + } else { + // New format: 17.0.1 -> major is 17 + first.parse().unwrap_or(0) + } + } else { + 0 + } +} + +/// Get the best Java for a specific Minecraft version +pub fn get_recommended_java(required_major_version: Option<u64>) -> Option<JavaInstallation> { + let installations = detect_java_installations(); + + if let Some(required) = required_major_version { + // Find exact match or higher + installations.into_iter().find(|java| { + let major = parse_java_version(&java.version); + major >= required as u32 + }) + } else { + // Return newest + installations.into_iter().next() + } +} diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index 746afe6..475a304 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -1,6 +1,8 @@ +pub mod account_storage; pub mod auth; pub mod config; pub mod downloader; pub mod game_version; +pub mod java; pub mod manifest; pub mod rules; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index d7f5b20..74b792a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,6 +2,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use std::process::Stdio; +use std::sync::Mutex; use tauri::{Emitter, Manager, State, Window}; // Added Emitter use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; @@ -10,6 +11,19 @@ mod core; mod launcher; mod utils; +// Global storage for MS refresh token (not in Account struct to keep it separate) +pub struct MsRefreshTokenState { + pub token: Mutex<Option<String>>, +} + +impl MsRefreshTokenState { + pub fn new() -> Self { + Self { + token: Mutex::new(None), + } + } +} + #[tauri::command] async fn start_game( window: Window, @@ -281,14 +295,28 @@ async fn start_game( let mut args = Vec::new(); let natives_path = natives_dir.to_string_lossy().to_string(); - // 7a. JVM Arguments (Simplified for now) - // We inject standard convenient defaults. - // TODO: Parse 'arguments.jvm' from version.json for full compatibility (Mac M1 support etc) - args.push(format!("-Djava.library.path={}", natives_path)); + // 7a. JVM Arguments - Parse from version.json for full compatibility + // First add arguments from version.json if available + if let Some(args_obj) = &version_details.arguments { + if let Some(jvm_args) = &args_obj.jvm { + parse_jvm_arguments(jvm_args, &mut args, &natives_path, &classpath); + } + } + + // Add memory settings (these override any defaults) args.push(format!("-Xmx{}M", config.max_memory)); args.push(format!("-Xms{}M", config.min_memory)); - args.push("-cp".to_string()); - args.push(classpath); + + // Ensure natives path is set if not already in jvm args + if !args.iter().any(|a| a.contains("-Djava.library.path")) { + args.push(format!("-Djava.library.path={}", natives_path)); + } + + // Ensure classpath is set if not already + if !args.iter().any(|a| a == "-cp" || a == "-classpath") { + args.push("-cp".to_string()); + args.push(classpath.clone()); + } // 7b. Main Class args.push(version_details.main_class.clone()); @@ -419,6 +447,75 @@ async fn start_game( Ok(format!("Launched Minecraft {} successfully!", version_id)) } +/// Parse JVM arguments from version.json +fn parse_jvm_arguments( + jvm_args: &serde_json::Value, + args: &mut Vec<String>, + natives_path: &str, + classpath: &str, +) { + let mut replacements = std::collections::HashMap::new(); + replacements.insert("${natives_directory}", natives_path.to_string()); + replacements.insert("${classpath}", classpath.to_string()); + replacements.insert("${launcher_name}", "DropOut".to_string()); + replacements.insert("${launcher_version}", env!("CARGO_PKG_VERSION").to_string()); + + if let Some(list) = jvm_args.as_array() { + for item in list { + if let Some(s) = item.as_str() { + // Simple string argument + let mut arg = s.to_string(); + for (key, val) in &replacements { + arg = arg.replace(key, val); + } + // Skip memory args as we set them explicitly + if !arg.starts_with("-Xmx") && !arg.starts_with("-Xms") { + args.push(arg); + } + } else if let Some(obj) = item.as_object() { + // Conditional argument with rules + let allow = if let Some(rules_val) = obj.get("rules") { + if let Ok(rules) = serde_json::from_value::<Vec<core::game_version::Rule>>( + rules_val.clone(), + ) { + core::rules::is_library_allowed(&Some(rules)) + } else { + false + } + } else { + true + }; + + if allow { + if let Some(val) = obj.get("value") { + if let Some(s) = val.as_str() { + let mut arg = s.to_string(); + for (key, replacement) in &replacements { + arg = arg.replace(key, replacement); + } + if !arg.starts_with("-Xmx") && !arg.starts_with("-Xms") { + args.push(arg); + } + } else if let Some(arr) = val.as_array() { + for sub in arr { + if let Some(s) = sub.as_str() { + let mut arg = s.to_string(); + for (key, replacement) in &replacements { + arg = arg.replace(key, replacement); + } + if !arg.starts_with("-Xmx") && !arg.starts_with("-Xms") { + args.push(arg); + } + } + } + } + } + } + } + } + } +} + #[tauri::command] async fn get_versions() -> Result<Vec<core::manifest::Version>, String> { match core::manifest::fetch_version_manifest().await { @@ -429,6 +526,7 @@ async fn get_versions() -> Result<Vec<core::manifest::Version>, String> { #[tauri::command] async fn login_offline( + window: Window, state: State<'_, core::auth::AccountState>, username: String, ) -> Result<core::auth::Account, String> { @@ -436,6 +534,13 @@ async fn login_offline( let account = core::auth::Account::Offline(core::auth::OfflineAccount { username, uuid }); *state.active_account.lock().unwrap() = Some(account.clone()); + + // Save to storage + let app_handle = window.app_handle(); + let app_dir = app_handle.path().app_data_dir().map_err(|e| e.to_string())?; + let storage = core::account_storage::AccountStorage::new(app_dir); + storage.add_or_update_account(&account, None)?; + Ok(account) } @@ -447,8 +552,23 @@ async fn get_active_account( } #[tauri::command] -async fn logout(state: State<'_, core::auth::AccountState>) -> Result<(), String> { +async fn logout( + window: Window, + state: State<'_, core::auth::AccountState>, +) -> Result<(), String> { + // Get current account UUID before clearing + let uuid = state.active_account.lock().unwrap().as_ref().map(|a| a.uuid()); + *state.active_account.lock().unwrap() = None; + + // Remove from storage + if let Some(uuid) = uuid { + let app_handle = window.app_handle(); + let app_dir = app_handle.path().app_data_dir().map_err(|e| e.to_string())?; + let storage = core::account_storage::AccountStorage::new(app_dir); + storage.remove_account(&uuid)?; + } + Ok(()) } @@ -476,12 +596,18 @@ async fn start_microsoft_login() -> Result<core::auth::DeviceCodeResponse, Strin #[tauri::command] async fn complete_microsoft_login( + window: Window, state: State<'_, core::auth::AccountState>, + ms_refresh_state: State<'_, MsRefreshTokenState>, device_code: String, ) -> Result<core::auth::Account, String> { // 1. Poll (once) for token let token_resp = core::auth::exchange_code_for_token(&device_code).await?; + // 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 let (xbl_token, uhs) = core::auth::method_xbox_live(&token_resp.access_token).await?; @@ -499,7 +625,7 @@ async fn complete_microsoft_login( username: profile.name, uuid: profile.id, access_token: mc_token, // This is the MC Access Token - refresh_token: token_resp.refresh_token, + refresh_token: token_resp.refresh_token.clone(), expires_at: (std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() @@ -509,16 +635,88 @@ async fn complete_microsoft_login( // 7. Save to state *state.active_account.lock().unwrap() = Some(account.clone()); + // 8. Save to storage + let app_handle = window.app_handle(); + let app_dir = app_handle.path().app_data_dir().map_err(|e| e.to_string())?; + let storage = core::account_storage::AccountStorage::new(app_dir); + storage.add_or_update_account(&account, ms_refresh_token)?; + + Ok(account) +} + +/// Refresh token for current Microsoft account +#[tauri::command] +async fn refresh_account( + window: Window, + state: State<'_, core::auth::AccountState>, + ms_refresh_state: State<'_, MsRefreshTokenState>, +) -> Result<core::auth::Account, String> { + // Get stored MS refresh token + let app_handle = window.app_handle(); + let app_dir = app_handle.path().app_data_dir().map_err(|e| e.to_string())?; + let storage = core::account_storage::AccountStorage::new(app_dir.clone()); + + let (stored_account, ms_refresh) = storage + .get_active_account() + .ok_or("No active account found")?; + + let ms_refresh_token = ms_refresh.ok_or("No refresh token available")?; + + // Perform full refresh + let (new_account, new_ms_refresh) = core::auth::refresh_full_auth(&ms_refresh_token).await?; + let account = core::auth::Account::Microsoft(new_account); + + // Update state + *state.active_account.lock().unwrap() = Some(account.clone()); + *ms_refresh_state.token.lock().unwrap() = Some(new_ms_refresh.clone()); + + // Update storage + storage.add_or_update_account(&account, Some(new_ms_refresh))?; + Ok(account) } +/// Detect Java installations on the system +#[tauri::command] +async fn detect_java() -> Result<Vec<core::java::JavaInstallation>, String> { + Ok(core::java::detect_java_installations()) +} + +/// Get recommended Java for a specific Minecraft version +#[tauri::command] +async fn get_recommended_java( + required_major_version: Option<u64>, +) -> Result<Option<core::java::JavaInstallation>, String> { + Ok(core::java::get_recommended_java(required_major_version)) +} + fn main() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .manage(core::auth::AccountState::new()) + .manage(MsRefreshTokenState::new()) .setup(|app| { let config_state = core::config::ConfigState::new(app.handle()); app.manage(config_state); + + // Load saved account on startup + let app_dir = app.path().app_data_dir().unwrap(); + let storage = core::account_storage::AccountStorage::new(app_dir); + + if let Some((stored_account, ms_refresh)) = storage.get_active_account() { + let account = stored_account.to_account(); + let auth_state: State<core::auth::AccountState> = app.state(); + *auth_state.active_account.lock().unwrap() = Some(account); + + // Store MS refresh token + if let Some(token) = ms_refresh { + let ms_state: State<MsRefreshTokenState> = app.state(); + *ms_state.token.lock().unwrap() = Some(token); + } + + println!("[Startup] Loaded saved account"); + } + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -530,7 +728,10 @@ fn main() { get_settings, save_settings, start_microsoft_login, - complete_microsoft_login + complete_microsoft_login, + refresh_account, + detect_java, + get_recommended_java ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 9e9d220..7bc056a 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -38,6 +38,8 @@ user_code: string; device_code: string; verification_uri: string; + expires_in: number; + interval: number; message?: string; } @@ -49,6 +51,12 @@ height: number; } + interface JavaInstallation { + path: string; + version: string; + is_64bit: boolean; + } + let versions: Version[] = []; let selectedVersion = ""; let currentAccount: Account | null = null; @@ -59,6 +67,8 @@ width: 854, height: 480, }; + let javaInstallations: JavaInstallation[] = []; + let isDetectingJava = false; // Login UI State let isLoginModalOpen = false; @@ -113,6 +123,27 @@ } } + async function detectJava() { + isDetectingJava = true; + try { + javaInstallations = await invoke("detect_java"); + if (javaInstallations.length === 0) { + status = "No Java installations found"; + } else { + status = `Found ${javaInstallations.length} Java installation(s)`; + } + } catch (e) { + console.error("Failed to detect Java:", e); + status = "Error detecting Java: " + e; + } finally { + isDetectingJava = false; + } + } + + function selectJava(path: string) { + settings.java_path = path; + } + // --- Auth Functions --- function openLoginModal() { @@ -422,9 +453,40 @@ class="bg-zinc-950 text-white flex-1 p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none font-mono text-sm" placeholder="e.g. java, /usr/bin/java" /> + <button + onclick={detectJava} + disabled={isDetectingJava} + class="bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 text-white px-4 py-2 rounded transition-colors whitespace-nowrap" + > + {isDetectingJava ? "Detecting..." : "Auto Detect"} + </button> </div> + + {#if javaInstallations.length > 0} + <div class="mt-4 space-y-2"> + <p class="text-xs text-zinc-400 uppercase font-bold">Detected Java Installations:</p> + {#each javaInstallations as java} + <button + onclick={() => selectJava(java.path)} + class="w-full text-left p-3 bg-zinc-950 rounded border transition-colors {settings.java_path === java.path ? 'border-indigo-500 bg-indigo-950/30' : 'border-zinc-700 hover:border-zinc-500'}" + > + <div class="flex justify-between items-center"> + <div> + <span class="text-white font-mono text-sm">{java.version}</span> + <span class="text-zinc-500 text-xs ml-2">{java.is_64bit ? "64-bit" : "32-bit"}</span> + </div> + {#if settings.java_path === java.path} + <span class="text-indigo-400 text-xs">Selected</span> + {/if} + </div> + <div class="text-zinc-500 text-xs font-mono truncate mt-1">{java.path}</div> + </button> + {/each} + </div> + {/if} + <p class="text-xs text-zinc-500 mt-2"> - The command or path to the Java Runtime Environment. + The command or path to the Java Runtime Environment. Click "Auto Detect" to find installed Java versions. </p> </div> |