From 48d9d886c078a04ead31a9d10744a085307444fa Mon Sep 17 00:00:00 2001 From: HsiangNianian Date: Tue, 13 Jan 2026 20:19:34 +0800 Subject: feat: implement Microsoft account token refresh and storage management; add Java detection functionality --- src-tauri/src/core/account_storage.rs | 159 +++++++++++++++++++++ src-tauri/src/core/auth.rs | 83 +++++++++++ src-tauri/src/core/java.rs | 254 ++++++++++++++++++++++++++++++++++ src-tauri/src/core/mod.rs | 2 + 4 files changed, 498 insertions(+) create mode 100644 src-tauri/src/core/account_storage.rs create mode 100644 src-tauri/src/core/java.rs (limited to 'src-tauri/src/core') 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, + pub active_account_id: Option, +} + +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, + pub ms_refresh_token: Option, // 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) -> 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, + ) -> 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)> { + 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 { + 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 { + 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::(&text) { + println!("[Auth] Token refreshed successfully!"); + return Ok(token_resp); + } + + if let Ok(err_resp) = serde_json::from_str::(&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 { + 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 { + 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 { + 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 { + 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) -> Option { + 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; -- cgit v1.2.3-70-g09d2