aboutsummaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
-rw-r--r--README.md20
-rw-r--r--src-tauri/src/core/account_storage.rs159
-rw-r--r--src-tauri/src/core/auth.rs83
-rw-r--r--src-tauri/src/core/java.rs254
-rw-r--r--src-tauri/src/core/mod.rs2
-rw-r--r--src-tauri/src/main.rs219
-rw-r--r--ui/src/App.svelte64
7 files changed, 791 insertions, 10 deletions
diff --git a/README.md b/README.md
index 99b28bb..a240b99 100644
--- a/README.md
+++ b/README.md
@@ -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(&params).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>