diff options
| author | 2026-01-16 20:56:27 +0800 | |
|---|---|---|
| committer | 2026-01-16 20:56:27 +0800 | |
| commit | fd4d01a629105d9405a6cecb2a1efd3ffa6a9334 (patch) | |
| tree | 831d979118234ae8b1a2eebdfe7187c2ff966391 | |
| parent | 1119f6c3cf421da2f2db92873efae8135c76b678 (diff) | |
| parent | bb6cf77844343bbf55be3dad201340572564d132 (diff) | |
| download | DropOut-fd4d01a629105d9405a6cecb2a1efd3ffa6a9334.tar.gz DropOut-fd4d01a629105d9405a6cecb2a1efd3ffa6a9334.zip | |
Merge pull request #55 from HsiangNianian/feat/Instance/Profile-System
| -rw-r--r-- | .github/workflows/prek.yml | 16 | ||||
| -rw-r--r-- | .pre-commit-config.yaml | 18 | ||||
| -rw-r--r-- | src-tauri/Cargo.toml | 1 | ||||
| -rw-r--r-- | src-tauri/src/core/instance.rs | 325 | ||||
| -rw-r--r-- | src-tauri/src/core/mod.rs | 1 | ||||
| -rw-r--r-- | src-tauri/src/main.rs | 260 | ||||
| -rw-r--r-- | src-tauri/src/utils/mod.rs | 2 | ||||
| -rw-r--r-- | src-tauri/src/utils/path.rs | 30 | ||||
| -rw-r--r-- | ui/src/App.svelte | 5 | ||||
| -rw-r--r-- | ui/src/components/BottomBar.svelte | 10 | ||||
| -rw-r--r-- | ui/src/components/InstancesView.svelte | 331 | ||||
| -rw-r--r-- | ui/src/components/ModLoaderSelector.svelte | 18 | ||||
| -rw-r--r-- | ui/src/components/Sidebar.svelte | 3 | ||||
| -rw-r--r-- | ui/src/components/VersionsView.svelte | 14 | ||||
| -rw-r--r-- | ui/src/stores/game.svelte.ts | 14 | ||||
| -rw-r--r-- | ui/src/stores/instances.svelte.ts | 109 | ||||
| -rw-r--r-- | ui/src/types/index.ts | 17 |
17 files changed, 1065 insertions, 109 deletions
diff --git a/.github/workflows/prek.yml b/.github/workflows/prek.yml index b49447d..8e43763 100644 --- a/.github/workflows/prek.yml +++ b/.github/workflows/prek.yml @@ -35,15 +35,23 @@ jobs: librsvg2-dev \ pkg-config - - name: Run prek (auto-fix) + - name: Run prek id: prek uses: j178/prek-action@v1 continue-on-error: true - with: - all_files: true - - name: Commit fixes + - name: Check for changes + id: check_changes if: steps.prek.outcome == 'failure' + run: | + if [ -n "$(git status --porcelain)" ]; then + echo "has_changes=true" >> $GITHUB_OUTPUT + else + echo "has_changes=false" >> $GITHUB_OUTPUT + fi + + - name: Commit fixes + if: steps.prek.outcome == 'failure' && steps.check_changes.outputs.has_changes == 'true' uses: stefanzweifel/git-auto-commit-action@v5 with: commit_message: "chore: apply prek auto-fixes [skip ci]" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5a5ed67..89bdd7e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,11 @@ ci: autofix_prs: true autofix_commit_msg: "[pre-commit.ci] auto fixes from pre-commit hooks [skip ci]" - + skip: [fmt,cargo-check,clippy] + repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-json exclude: ^ui/tsconfig.*\.json$ @@ -18,20 +19,15 @@ repos: - id: check-ast - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.6 + rev: v0.14.13 hooks: - id: ruff args: [ --fix ] - id: ruff-format - - repo: https://github.com/doublify/pre-commit-rust - rev: v1.0 + - repo: https://github.com/FeryET/pre-commit-rust + rev: v1.2.1 hooks: - id: fmt - args: ["--check", "--manifest-path", "src-tauri/Cargo.toml", "--"] - files: ^src-tauri/.*\.rs$ - pass_filenames: false + - id: cargo-check - id: clippy - args: ["--manifest-path", "src-tauri/Cargo.toml", "--", "-D", "warnings"] - files: ^src-tauri/.*\.rs$ - pass_filenames: false diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 407da5a..663a5b1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,6 +30,7 @@ serde_urlencoded = "0.7.1" tauri-plugin-dialog = "2.6.0" tauri-plugin-fs = "2.4.5" bytes = "1.11.0" +chrono = "0.4" [build-dependencies] tauri-build = { version = "2.0", features = [] } diff --git a/src-tauri/src/core/instance.rs b/src-tauri/src/core/instance.rs new file mode 100644 index 0000000..90ec34e --- /dev/null +++ b/src-tauri/src/core/instance.rs @@ -0,0 +1,325 @@ +//! Instance/Profile management module. +//! +//! This module provides functionality to: +//! - Create and manage multiple isolated game instances +//! - Each instance has its own versions, libraries, assets, mods, and saves +//! - Support for instance switching and isolation + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use tauri::{AppHandle, Manager}; + +/// Represents a game instance/profile +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Instance { + pub id: String, // 唯一标识符(UUID) + pub name: String, // 显示名称 + pub game_dir: PathBuf, // 游戏目录路径 + pub version_id: Option<String>, // 当前选择的版本ID + pub created_at: i64, // 创建时间戳 + pub last_played: Option<i64>, // 最后游玩时间 + pub icon_path: Option<String>, // 图标路径(可选) + pub notes: Option<String>, // 备注(可选) + pub mod_loader: Option<String>, // 模组加载器类型:"fabric", "forge", "vanilla" + pub mod_loader_version: Option<String>, // 模组加载器版本 +} + +/// Configuration for all instances +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct InstanceConfig { + pub instances: Vec<Instance>, + pub active_instance_id: Option<String>, // 当前活动的实例ID +} + +/// State management for instances +pub struct InstanceState { + pub instances: Mutex<InstanceConfig>, + pub file_path: PathBuf, +} + +impl InstanceState { + /// Create a new InstanceState + pub fn new(app_handle: &AppHandle) -> Self { + let app_dir = app_handle.path().app_data_dir().unwrap(); + let file_path = app_dir.join("instances.json"); + + let config = if file_path.exists() { + let content = fs::read_to_string(&file_path).unwrap_or_default(); + serde_json::from_str(&content).unwrap_or_else(|_| InstanceConfig::default()) + } else { + InstanceConfig::default() + }; + + Self { + instances: Mutex::new(config), + file_path, + } + } + + /// Save the instance configuration to disk + pub fn save(&self) -> Result<(), String> { + let config = self.instances.lock().unwrap(); + let content = serde_json::to_string_pretty(&*config).map_err(|e| e.to_string())?; + fs::create_dir_all(self.file_path.parent().unwrap()).map_err(|e| e.to_string())?; + fs::write(&self.file_path, content).map_err(|e| e.to_string())?; + Ok(()) + } + + /// Create a new instance + pub fn create_instance( + &self, + name: String, + app_handle: &AppHandle, + ) -> Result<Instance, String> { + let app_dir = app_handle.path().app_data_dir().unwrap(); + let instance_id = uuid::Uuid::new_v4().to_string(); + let instance_dir = app_dir.join("instances").join(&instance_id); + let game_dir = instance_dir.clone(); + + // Create instance directory structure + fs::create_dir_all(&instance_dir).map_err(|e| e.to_string())?; + fs::create_dir_all(instance_dir.join("versions")).map_err(|e| e.to_string())?; + fs::create_dir_all(instance_dir.join("libraries")).map_err(|e| e.to_string())?; + fs::create_dir_all(instance_dir.join("assets")).map_err(|e| e.to_string())?; + fs::create_dir_all(instance_dir.join("mods")).map_err(|e| e.to_string())?; + fs::create_dir_all(instance_dir.join("config")).map_err(|e| e.to_string())?; + fs::create_dir_all(instance_dir.join("saves")).map_err(|e| e.to_string())?; + + let instance = Instance { + id: instance_id.clone(), + name, + game_dir, + version_id: None, + created_at: chrono::Utc::now().timestamp(), + last_played: None, + icon_path: None, + notes: None, + mod_loader: Some("vanilla".to_string()), + mod_loader_version: None, + }; + + let mut config = self.instances.lock().unwrap(); + config.instances.push(instance.clone()); + + // If this is the first instance, set it as active + if config.active_instance_id.is_none() { + config.active_instance_id = Some(instance_id); + } + + drop(config); + self.save()?; + + Ok(instance) + } + + /// Delete an instance + pub fn delete_instance(&self, id: &str) -> Result<(), String> { + let mut config = self.instances.lock().unwrap(); + + // Find the instance + let instance_index = config + .instances + .iter() + .position(|i| i.id == id) + .ok_or_else(|| format!("Instance {} not found", id))?; + + let instance = config.instances[instance_index].clone(); + + // Remove from list + config.instances.remove(instance_index); + + // If this was the active instance, clear or set another as active + if config.active_instance_id.as_ref() == Some(&id.to_string()) { + config.active_instance_id = config.instances.first().map(|i| i.id.clone()); + } + + drop(config); + self.save()?; + + // Delete the instance directory + if instance.game_dir.exists() { + fs::remove_dir_all(&instance.game_dir) + .map_err(|e| format!("Failed to delete instance directory: {}", e))?; + } + + Ok(()) + } + + /// Update an instance + pub fn update_instance(&self, instance: Instance) -> Result<(), String> { + let mut config = self.instances.lock().unwrap(); + + let index = config + .instances + .iter() + .position(|i| i.id == instance.id) + .ok_or_else(|| format!("Instance {} not found", instance.id))?; + + config.instances[index] = instance; + drop(config); + self.save()?; + + Ok(()) + } + + /// Get an instance by ID + pub fn get_instance(&self, id: &str) -> Option<Instance> { + let config = self.instances.lock().unwrap(); + config.instances.iter().find(|i| i.id == id).cloned() + } + + /// List all instances + pub fn list_instances(&self) -> Vec<Instance> { + let config = self.instances.lock().unwrap(); + config.instances.clone() + } + + /// Set the active instance + pub fn set_active_instance(&self, id: &str) -> Result<(), String> { + let mut config = self.instances.lock().unwrap(); + + // Verify the instance exists + if !config.instances.iter().any(|i| i.id == id) { + return Err(format!("Instance {} not found", id)); + } + + config.active_instance_id = Some(id.to_string()); + drop(config); + self.save()?; + + Ok(()) + } + + /// Get the active instance + pub fn get_active_instance(&self) -> Option<Instance> { + let config = self.instances.lock().unwrap(); + config + .active_instance_id + .as_ref() + .and_then(|id| config.instances.iter().find(|i| i.id == *id)) + .cloned() + } + + /// Get the game directory for an instance + pub fn get_instance_game_dir(&self, id: &str) -> Option<PathBuf> { + self.get_instance(id).map(|i| i.game_dir) + } + + /// Duplicate an instance + pub fn duplicate_instance( + &self, + id: &str, + new_name: String, + app_handle: &AppHandle, + ) -> Result<Instance, String> { + let source_instance = self + .get_instance(id) + .ok_or_else(|| format!("Instance {} not found", id))?; + + // Create new instance + let mut new_instance = self.create_instance(new_name, app_handle)?; + + // Copy instance properties + new_instance.version_id = source_instance.version_id.clone(); + new_instance.mod_loader = source_instance.mod_loader.clone(); + new_instance.mod_loader_version = source_instance.mod_loader_version.clone(); + new_instance.notes = source_instance.notes.clone(); + + // Copy directory contents + if source_instance.game_dir.exists() { + copy_dir_all(&source_instance.game_dir, &new_instance.game_dir) + .map_err(|e| format!("Failed to copy instance directory: {}", e))?; + } + + self.update_instance(new_instance.clone())?; + + Ok(new_instance) + } +} + +/// Copy a directory recursively +fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), std::io::Error> { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let ty = entry.file_type()?; + if ty.is_dir() { + copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?; + } else { + fs::copy(entry.path(), dst.join(entry.file_name()))?; + } + } + Ok(()) +} + +/// Migrate legacy data to instance system +pub fn migrate_legacy_data( + app_handle: &AppHandle, + instance_state: &InstanceState, +) -> Result<(), String> { + let app_dir = app_handle.path().app_data_dir().unwrap(); + let old_versions_dir = app_dir.join("versions"); + let old_libraries_dir = app_dir.join("libraries"); + let old_assets_dir = app_dir.join("assets"); + + // Check if legacy data exists + let has_legacy_data = + old_versions_dir.exists() || old_libraries_dir.exists() || old_assets_dir.exists(); + + if !has_legacy_data { + return Ok(()); // No legacy data to migrate + } + + // Check if instances already exist + let config = instance_state.instances.lock().unwrap(); + if !config.instances.is_empty() { + drop(config); + return Ok(()); // Already have instances, skip migration + } + drop(config); + + // Create default instance + let default_instance = instance_state + .create_instance("Default".to_string(), app_handle) + .map_err(|e| format!("Failed to create default instance: {}", e))?; + + let new_versions_dir = default_instance.game_dir.join("versions"); + let new_libraries_dir = default_instance.game_dir.join("libraries"); + let new_assets_dir = default_instance.game_dir.join("assets"); + + // Move legacy data + if old_versions_dir.exists() { + if new_versions_dir.exists() { + // Merge directories + copy_dir_all(&old_versions_dir, &new_versions_dir) + .map_err(|e| format!("Failed to migrate versions: {}", e))?; + } else { + fs::rename(&old_versions_dir, &new_versions_dir) + .map_err(|e| format!("Failed to migrate versions: {}", e))?; + } + } + + if old_libraries_dir.exists() { + if new_libraries_dir.exists() { + copy_dir_all(&old_libraries_dir, &new_libraries_dir) + .map_err(|e| format!("Failed to migrate libraries: {}", e))?; + } else { + fs::rename(&old_libraries_dir, &new_libraries_dir) + .map_err(|e| format!("Failed to migrate libraries: {}", e))?; + } + } + + if old_assets_dir.exists() { + if new_assets_dir.exists() { + copy_dir_all(&old_assets_dir, &new_assets_dir) + .map_err(|e| format!("Failed to migrate assets: {}", e))?; + } else { + fs::rename(&old_assets_dir, &new_assets_dir) + .map_err(|e| format!("Failed to migrate assets: {}", e))?; + } + } + + Ok(()) +} diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index 7ad6ef9..dcbd47a 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -6,6 +6,7 @@ pub mod downloader; pub mod fabric; pub mod forge; pub mod game_version; +pub mod instance; pub mod java; pub mod manifest; pub mod maven; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 661309a..2871b03 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -68,11 +68,16 @@ async fn start_game( auth_state: State<'_, core::auth::AccountState>, config_state: State<'_, core::config::ConfigState>, assistant_state: State<'_, core::assistant::AssistantState>, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, version_id: String, ) -> Result<String, String> { emit_log!( window, - format!("Starting game launch for version: {}", version_id) + format!( + "Starting game launch for version: {} in instance: {}", + version_id, instance_id + ) ); // Check for active account @@ -93,14 +98,10 @@ async fn start_game( format!("Memory: {}MB - {}MB", config.min_memory, config.max_memory) ); - // Get App Data Directory (e.g., ~/.local/share/com.dropout.launcher or similar) - // The identifier is set in tauri.conf.json. - // If not accessible, use a specific logic. - 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))?; + // Get game directory from instance + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; // Ensure game directory exists tokio::fs::create_dir_all(&game_dir) @@ -169,6 +170,7 @@ async fn start_game( }; // Check if configured Java is compatible + let app_handle = window.app_handle(); let mut java_path_to_use = config.java_path.clone(); if !java_path_to_use.is_empty() && java_path_to_use != "java" { let is_compatible = @@ -890,12 +892,15 @@ async fn get_versions(window: Window) -> Result<Vec<core::manifest::Version>, St /// 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))?; +async fn check_version_installed( + _window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, + version_id: String, +) -> Result<bool, String> { + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; // For modded versions, check the parent vanilla version let minecraft_version = if version_id.starts_with("fabric-loader-") { @@ -929,19 +934,24 @@ async fn check_version_installed(window: Window, version_id: String) -> Result<b async fn install_version( window: Window, config_state: State<'_, core::config::ConfigState>, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, version_id: String, ) -> Result<(), String> { emit_log!( window, - format!("Starting installation for version: {}", version_id) + format!( + "Starting installation for version: {} in instance: {}", + version_id, instance_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))?; + + // Get game directory from instance + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; // Ensure game directory exists tokio::fs::create_dir_all(&game_dir) @@ -1530,22 +1540,22 @@ async fn get_fabric_loaders_for_version( #[tauri::command] async fn install_fabric( window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, game_version: String, loader_version: String, ) -> Result<core::fabric::InstalledFabricVersion, String> { emit_log!( window, format!( - "Installing Fabric {} for Minecraft {}...", - loader_version, game_version + "Installing Fabric {} for Minecraft {} in instance {}...", + loader_version, game_version, instance_id ) ); - 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 game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; let result = core::fabric::install_fabric(&game_dir, &game_version, &loader_version) .await @@ -1564,12 +1574,14 @@ async fn install_fabric( /// List installed Fabric versions #[tauri::command] -async fn list_installed_fabric_versions(window: Window) -> Result<Vec<String>, 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))?; +async fn list_installed_fabric_versions( + _window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, +) -> Result<Vec<String>, String> { + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; core::fabric::list_installed_fabric_versions(&game_dir) .await @@ -1579,14 +1591,14 @@ async fn list_installed_fabric_versions(window: Window) -> Result<Vec<String>, S /// Get Java version requirement for a specific version #[tauri::command] async fn get_version_java_version( - window: Window, + _window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, version_id: String, ) -> Result<Option<u64>, 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 game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; // Try to load the version JSON to get javaVersion match core::manifest::load_version(&game_dir, &version_id).await { @@ -1607,12 +1619,15 @@ struct VersionMetadata { /// Delete a version (remove version directory) #[tauri::command] -async fn delete_version(window: Window, version_id: String) -> Result<(), 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))?; +async fn delete_version( + window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, + version_id: String, +) -> Result<(), String> { + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; let version_dir = game_dir.join("versions").join(&version_id); @@ -1634,14 +1649,14 @@ async fn delete_version(window: Window, version_id: String) -> Result<(), String /// Get detailed metadata for a specific version #[tauri::command] async fn get_version_metadata( - window: Window, + _window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, version_id: String, ) -> Result<VersionMetadata, 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 game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; // Initialize metadata let mut metadata = VersionMetadata { @@ -1728,12 +1743,14 @@ struct InstalledVersion { /// 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))?; +async fn list_installed_versions( + _window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, +) -> Result<Vec<InstalledVersion>, String> { + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; let versions_dir = game_dir.join("versions"); let mut installed = Vec::new(); @@ -1813,15 +1830,15 @@ async fn list_installed_versions(window: Window) -> Result<Vec<InstalledVersion> /// Check if Fabric is installed for a specific version #[tauri::command] async fn is_fabric_installed( - window: Window, + _window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, game_version: String, loader_version: 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))?; + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; Ok(core::fabric::is_fabric_installed( &game_dir, @@ -1853,25 +1870,26 @@ async fn get_forge_versions_for_game( async fn install_forge( window: Window, config_state: State<'_, core::config::ConfigState>, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, game_version: String, forge_version: String, ) -> Result<core::forge::InstalledForgeVersion, String> { emit_log!( window, format!( - "Installing Forge {} for Minecraft {}...", - forge_version, game_version + "Installing Forge {} for Minecraft {} in instance {}...", + forge_version, game_version, instance_id ) ); - 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 game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; // Get Java path from config or detect let config = config_state.config.lock().unwrap().clone(); + let app_handle = window.app_handle(); let java_path_str = if !config.java_path.is_empty() && config.java_path != "java" { config.java_path.clone() } else { @@ -2075,6 +2093,85 @@ async fn list_openai_models( assistant.list_openai_models(&config.assistant).await } +// ==================== Instance Management Commands ==================== + +/// Create a new instance +#[tauri::command] +async fn create_instance( + window: Window, + state: State<'_, core::instance::InstanceState>, + name: String, +) -> Result<core::instance::Instance, String> { + let app_handle = window.app_handle(); + state.create_instance(name, app_handle) +} + +/// Delete an instance +#[tauri::command] +async fn delete_instance( + state: State<'_, core::instance::InstanceState>, + instance_id: String, +) -> Result<(), String> { + state.delete_instance(&instance_id) +} + +/// Update an instance +#[tauri::command] +async fn update_instance( + state: State<'_, core::instance::InstanceState>, + instance: core::instance::Instance, +) -> Result<(), String> { + state.update_instance(instance) +} + +/// Get all instances +#[tauri::command] +async fn list_instances( + state: State<'_, core::instance::InstanceState>, +) -> Result<Vec<core::instance::Instance>, String> { + Ok(state.list_instances()) +} + +/// Get a single instance by ID +#[tauri::command] +async fn get_instance( + state: State<'_, core::instance::InstanceState>, + instance_id: String, +) -> Result<core::instance::Instance, String> { + state + .get_instance(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id)) +} + +/// Set the active instance +#[tauri::command] +async fn set_active_instance( + state: State<'_, core::instance::InstanceState>, + instance_id: String, +) -> Result<(), String> { + state.set_active_instance(&instance_id) +} + +/// Get the active instance +#[tauri::command] +async fn get_active_instance( + state: State<'_, core::instance::InstanceState>, +) -> Result<Option<core::instance::Instance>, String> { + Ok(state.get_active_instance()) +} + +/// Duplicate an instance +#[tauri::command] +async fn duplicate_instance( + window: Window, + state: State<'_, core::instance::InstanceState>, + instance_id: String, + new_name: String, +) -> Result<core::instance::Instance, String> { + let app_handle = window.app_handle(); + state.duplicate_instance(&instance_id, new_name, app_handle) +} + #[tauri::command] async fn assistant_chat_stream( window: tauri::Window, @@ -2101,6 +2198,16 @@ fn main() { let config_state = core::config::ConfigState::new(app.handle()); app.manage(config_state); + // Initialize instance state + let instance_state = core::instance::InstanceState::new(app.handle()); + + // Migrate legacy data if needed + if let Err(e) = core::instance::migrate_legacy_data(app.handle(), &instance_state) { + eprintln!("[Startup] Warning: Failed to migrate legacy data: {}", e); + } + + app.manage(instance_state); + // Load saved account on startup let app_dir = app.path().app_data_dir().unwrap(); let storage = core::account_storage::AccountStorage::new(app_dir); @@ -2176,7 +2283,16 @@ fn main() { assistant_chat, assistant_chat_stream, list_ollama_models, - list_openai_models + list_openai_models, + // Instance management commands + create_instance, + delete_instance, + update_instance, + list_instances, + get_instance, + set_active_instance, + get_active_instance, + duplicate_instance ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/utils/mod.rs b/src-tauri/src/utils/mod.rs index 651d26b..c9ac368 100644 --- a/src-tauri/src/utils/mod.rs +++ b/src-tauri/src/utils/mod.rs @@ -1,5 +1,5 @@ -pub mod zip; pub mod path; +pub mod zip; // File system related utility functions #[allow(dead_code)] diff --git a/src-tauri/src/utils/path.rs b/src-tauri/src/utils/path.rs index deaebb5..ab14c12 100644 --- a/src-tauri/src/utils/path.rs +++ b/src-tauri/src/utils/path.rs @@ -48,8 +48,12 @@ pub fn normalize_java_path(java_path: &str) -> Result<PathBuf, String> { path.set_extension("exe"); } - // If still not found and it's just "java.exe", try to find it in PATH - if !path.exists() && path.file_name() == Some(std::ffi::OsStr::new("java.exe")) { + // If still not found and it's just "java.exe" (not an absolute path), try to find it in PATH + // Only search PATH for relative paths or just "java", not for absolute paths that don't exist + if !path.exists() + && !path.is_absolute() + && path.file_name() == Some(std::ffi::OsStr::new("java.exe")) + { // Try to locate java.exe in PATH if let Ok(output) = std::process::Command::new("where").arg("java").output() { if output.status.success() { @@ -59,7 +63,7 @@ pub fn normalize_java_path(java_path: &str) -> Result<PathBuf, String> { } } } - + // If still not found after PATH search, return specific error if !path.exists() { return Err( @@ -80,7 +84,7 @@ pub fn normalize_java_path(java_path: &str) -> Result<PathBuf, String> { // Canonicalize and strip UNC prefix for clean path let canonical = std::fs::canonicalize(&path) .map_err(|e| format!("Failed to resolve Java path '{}': {}", path.display(), e))?; - + Ok(strip_unc_prefix(canonical)) } @@ -98,7 +102,7 @@ pub fn normalize_java_path(java_path: &str) -> Result<PathBuf, String> { } } } - + // If still not found after PATH search, return specific error if !path.exists() { return Err( @@ -119,7 +123,7 @@ pub fn normalize_java_path(java_path: &str) -> Result<PathBuf, String> { // Canonicalize to resolve symlinks and get absolute path let canonical = std::fs::canonicalize(&path) .map_err(|e| format!("Failed to resolve Java path '{}': {}", path.display(), e))?; - + Ok(strip_unc_prefix(canonical)) } @@ -196,23 +200,23 @@ mod tests { fn test_normalize_with_temp_file() { // Create a temporary file to test with an actual existing path let temp_dir = std::env::temp_dir(); - + #[cfg(target_os = "windows")] let temp_file = temp_dir.join("test_java_normalize.exe"); #[cfg(not(target_os = "windows"))] let temp_file = temp_dir.join("test_java_normalize"); - + // Create the file if let Ok(mut file) = fs::File::create(&temp_file) { let _ = file.write_all(b"#!/bin/sh\necho test"); drop(file); - + // Test normalization let result = normalize_java_path(temp_file.to_str().unwrap()); - + // Clean up let _ = fs::remove_file(&temp_file); - + // Verify result assert!(result.is_ok(), "Failed to normalize temp file path"); let normalized = result.unwrap(); @@ -227,12 +231,12 @@ mod tests { let unc_path = PathBuf::from(r"\\?\C:\Windows\System32\cmd.exe"); let stripped = strip_unc_prefix(unc_path); assert_eq!(stripped.to_string_lossy(), r"C:\Windows\System32\cmd.exe"); - + let normal_path = PathBuf::from(r"C:\Windows\System32\cmd.exe"); let unchanged = strip_unc_prefix(normal_path.clone()); assert_eq!(unchanged, normal_path); } - + #[cfg(not(target_os = "windows"))] { let path = PathBuf::from("/usr/bin/java"); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 2b78892..127bbea 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -11,12 +11,14 @@ import ParticleBackground from "./components/ParticleBackground.svelte"; import SettingsView from "./components/SettingsView.svelte"; import AssistantView from "./components/AssistantView.svelte"; + import InstancesView from "./components/InstancesView.svelte"; import Sidebar from "./components/Sidebar.svelte"; import StatusToast from "./components/StatusToast.svelte"; import VersionsView from "./components/VersionsView.svelte"; // Stores import { authState } from "./stores/auth.svelte"; import { gameState } from "./stores/game.svelte"; + import { instancesState } from "./stores/instances.svelte"; import { settingsState } from "./stores/settings.svelte"; import { uiState } from "./stores/ui.svelte"; import { logsState } from "./stores/logs.svelte"; @@ -40,6 +42,7 @@ await settingsState.loadSettings(); logsState.init(); await settingsState.detectJava(); + await instancesState.loadInstances(); gameState.loadVersions(); getVersion().then((v) => (uiState.appVersion = v)); window.addEventListener("mousemove", handleMouseMove); @@ -113,6 +116,8 @@ <div class="flex-1 relative overflow-hidden"> {#if uiState.currentView === "home"} <HomeView mouseX={mouseX} mouseY={mouseY} /> + {:else if uiState.currentView === "instances"} + <InstancesView /> {:else if uiState.currentView === "versions"} <VersionsView /> {:else if uiState.currentView === "settings"} diff --git a/ui/src/components/BottomBar.svelte b/ui/src/components/BottomBar.svelte index 8a6b7ff..19cf35d 100644 --- a/ui/src/components/BottomBar.svelte +++ b/ui/src/components/BottomBar.svelte @@ -4,6 +4,7 @@ import { authState } from "../stores/auth.svelte"; import { gameState } from "../stores/game.svelte"; import { uiState } from "../stores/ui.svelte"; + import { instancesState } from "../stores/instances.svelte"; import { Terminal, ChevronDown, Play, User, Check } from 'lucide-svelte'; interface InstalledVersion { @@ -44,9 +45,16 @@ } async function loadInstalledVersions() { + if (!instancesState.activeInstanceId) { + installedVersions = []; + isLoadingVersions = false; + return; + } isLoadingVersions = true; try { - installedVersions = await invoke<InstalledVersion[]>("list_installed_versions"); + installedVersions = await invoke<InstalledVersion[]>("list_installed_versions", { + instanceId: instancesState.activeInstanceId, + }); // 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; diff --git a/ui/src/components/InstancesView.svelte b/ui/src/components/InstancesView.svelte new file mode 100644 index 0000000..a4881e6 --- /dev/null +++ b/ui/src/components/InstancesView.svelte @@ -0,0 +1,331 @@ +<script lang="ts"> + import { onMount } from "svelte"; + import { instancesState } from "../stores/instances.svelte"; + import { Plus, Trash2, Edit2, Copy, Check, X } from "lucide-svelte"; + import type { Instance } from "../types"; + + let showCreateModal = $state(false); + let showEditModal = $state(false); + let showDeleteConfirm = $state(false); + let showDuplicateModal = $state(false); + let selectedInstance: Instance | null = $state(null); + let newInstanceName = $state(""); + let duplicateName = $state(""); + + onMount(() => { + instancesState.loadInstances(); + }); + + function handleCreate() { + newInstanceName = ""; + showCreateModal = true; + } + + function handleEdit(instance: Instance) { + selectedInstance = instance; + newInstanceName = instance.name; + showEditModal = true; + } + + function handleDelete(instance: Instance) { + selectedInstance = instance; + showDeleteConfirm = true; + } + + function handleDuplicate(instance: Instance) { + selectedInstance = instance; + duplicateName = `${instance.name} (Copy)`; + showDuplicateModal = true; + } + + async function confirmCreate() { + if (!newInstanceName.trim()) return; + await instancesState.createInstance(newInstanceName.trim()); + showCreateModal = false; + newInstanceName = ""; + } + + async function confirmEdit() { + if (!selectedInstance || !newInstanceName.trim()) return; + await instancesState.updateInstance({ + ...selectedInstance, + name: newInstanceName.trim(), + }); + showEditModal = false; + selectedInstance = null; + newInstanceName = ""; + } + + async function confirmDelete() { + if (!selectedInstance) return; + await instancesState.deleteInstance(selectedInstance.id); + showDeleteConfirm = false; + selectedInstance = null; + } + + async function confirmDuplicate() { + if (!selectedInstance || !duplicateName.trim()) return; + await instancesState.duplicateInstance(selectedInstance.id, duplicateName.trim()); + showDuplicateModal = false; + selectedInstance = null; + duplicateName = ""; + } + + function formatDate(timestamp: number): string { + return new Date(timestamp * 1000).toLocaleDateString(); + } + + function formatLastPlayed(timestamp: number): string { + const date = new Date(timestamp * 1000); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + + if (days === 0) return "Today"; + if (days === 1) return "Yesterday"; + if (days < 7) return `${days} days ago`; + return date.toLocaleDateString(); + } +</script> + +<div class="h-full flex flex-col gap-4 p-6 overflow-y-auto"> + <div class="flex items-center justify-between"> + <h1 class="text-2xl font-bold text-gray-900 dark:text-white">Instances</h1> + <button + onclick={handleCreate} + class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors" + > + <Plus size={18} /> + Create Instance + </button> + </div> + + {#if instancesState.instances.length === 0} + <div class="flex-1 flex items-center justify-center"> + <div class="text-center text-gray-500 dark:text-gray-400"> + <p class="text-lg mb-2">No instances yet</p> + <p class="text-sm">Create your first instance to get started</p> + </div> + </div> + {:else} + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> + {#each instancesState.instances as instance (instance.id)} + <div + class="relative p-4 bg-gray-100 dark:bg-gray-800 rounded-lg border-2 transition-all cursor-pointer hover:border-blue-500 {instancesState.activeInstanceId === instance.id + ? 'border-blue-500' + : 'border-transparent'}" + onclick={() => instancesState.setActiveInstance(instance.id)} + > + {#if instancesState.activeInstanceId === instance.id} + <div class="absolute top-2 right-2"> + <div class="w-3 h-3 bg-blue-500 rounded-full"></div> + </div> + {/if} + + <div class="flex items-start justify-between mb-2"> + <h3 class="text-lg font-semibold text-gray-900 dark:text-white"> + {instance.name} + </h3> + <div class="flex gap-1"> + <button + onclick={(e) => { + e.stopPropagation(); + handleEdit(instance); + }} + class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors" + title="Edit" + > + <Edit2 size={16} class="text-gray-600 dark:text-gray-400" /> + </button> + <button + onclick={(e) => { + e.stopPropagation(); + handleDuplicate(instance); + }} + class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors" + title="Duplicate" + > + <Copy size={16} class="text-gray-600 dark:text-gray-400" /> + </button> + <button + onclick={(e) => { + e.stopPropagation(); + handleDelete(instance); + }} + class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors" + title="Delete" + > + <Trash2 size={16} class="text-red-600 dark:text-red-400" /> + </button> + </div> + </div> + + <div class="space-y-1 text-sm text-gray-600 dark:text-gray-400"> + {#if instance.version_id} + <p>Version: <span class="font-medium">{instance.version_id}</span></p> + {:else} + <p class="text-gray-400">No version selected</p> + {/if} + + {#if instance.mod_loader && instance.mod_loader !== "vanilla"} + <p> + Mod Loader: <span class="font-medium capitalize">{instance.mod_loader}</span> + {#if instance.mod_loader_version} + <span class="text-gray-500">({instance.mod_loader_version})</span> + {/if} + </p> + {/if} + + <p>Created: {formatDate(instance.created_at)}</p> + + {#if instance.last_played} + <p>Last played: {formatLastPlayed(instance.last_played)}</p> + {/if} + </div> + + {#if instance.notes} + <p class="mt-2 text-sm text-gray-500 dark:text-gray-500 italic"> + {instance.notes} + </p> + {/if} + </div> + {/each} + </div> + {/if} +</div> + +<!-- Create Modal --> +{#if showCreateModal} + <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> + <div class="bg-white dark:bg-gray-800 rounded-lg p-6 w-96"> + <h2 class="text-xl font-bold mb-4 text-gray-900 dark:text-white">Create Instance</h2> + <input + type="text" + bind:value={newInstanceName} + placeholder="Instance name" + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4" + onkeydown={(e) => e.key === "Enter" && confirmCreate()} + autofocus + /> + <div class="flex gap-2 justify-end"> + <button + onclick={() => { + showCreateModal = false; + newInstanceName = ""; + }} + class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors" + > + Cancel + </button> + <button + onclick={confirmCreate} + disabled={!newInstanceName.trim()} + class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" + > + Create + </button> + </div> + </div> + </div> +{/if} + +<!-- Edit Modal --> +{#if showEditModal && selectedInstance} + <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> + <div class="bg-white dark:bg-gray-800 rounded-lg p-6 w-96"> + <h2 class="text-xl font-bold mb-4 text-gray-900 dark:text-white">Edit Instance</h2> + <input + type="text" + bind:value={newInstanceName} + placeholder="Instance name" + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4" + onkeydown={(e) => e.key === "Enter" && confirmEdit()} + autofocus + /> + <div class="flex gap-2 justify-end"> + <button + onclick={() => { + showEditModal = false; + selectedInstance = null; + newInstanceName = ""; + }} + class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors" + > + Cancel + </button> + <button + onclick={confirmEdit} + disabled={!newInstanceName.trim()} + class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" + > + Save + </button> + </div> + </div> + </div> +{/if} + +<!-- Delete Confirmation --> +{#if showDeleteConfirm && selectedInstance} + <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> + <div class="bg-white dark:bg-gray-800 rounded-lg p-6 w-96"> + <h2 class="text-xl font-bold mb-4 text-red-600 dark:text-red-400">Delete Instance</h2> + <p class="mb-4 text-gray-700 dark:text-gray-300"> + Are you sure you want to delete "{selectedInstance.name}"? This action cannot be undone and will delete all game data for this instance. + </p> + <div class="flex gap-2 justify-end"> + <button + onclick={() => { + showDeleteConfirm = false; + selectedInstance = null; + }} + class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors" + > + Cancel + </button> + <button + onclick={confirmDelete} + class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors" + > + Delete + </button> + </div> + </div> + </div> +{/if} + +<!-- Duplicate Modal --> +{#if showDuplicateModal && selectedInstance} + <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> + <div class="bg-white dark:bg-gray-800 rounded-lg p-6 w-96"> + <h2 class="text-xl font-bold mb-4 text-gray-900 dark:text-white">Duplicate Instance</h2> + <input + type="text" + bind:value={duplicateName} + placeholder="New instance name" + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4" + onkeydown={(e) => e.key === "Enter" && confirmDuplicate()} + autofocus + /> + <div class="flex gap-2 justify-end"> + <button + onclick={() => { + showDuplicateModal = false; + selectedInstance = null; + duplicateName = ""; + }} + class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors" + > + Cancel + </button> + <button + onclick={confirmDuplicate} + disabled={!duplicateName.trim()} + class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" + > + Duplicate + </button> + </div> + </div> + </div> +{/if} diff --git a/ui/src/components/ModLoaderSelector.svelte b/ui/src/components/ModLoaderSelector.svelte index 34f6f2e..50caa8c 100644 --- a/ui/src/components/ModLoaderSelector.svelte +++ b/ui/src/components/ModLoaderSelector.svelte @@ -9,6 +9,7 @@ } from "../types"; import { Loader2, Download, AlertCircle, Check, ChevronDown, CheckCircle } from 'lucide-svelte'; import { logsState } from "../stores/logs.svelte"; + import { instancesState } from "../stores/instances.svelte"; interface Props { selectedGameVersion: string; @@ -52,12 +53,13 @@ }); async function checkInstallStatus() { - if (!selectedGameVersion) { + if (!selectedGameVersion || !instancesState.activeInstanceId) { isVersionInstalled = false; return; } try { isVersionInstalled = await invoke<boolean>("check_version_installed", { + instanceId: instancesState.activeInstanceId, versionId: selectedGameVersion, }); } catch (e) { @@ -112,8 +114,13 @@ error = null; logsState.addLog("info", "Installer", `Starting installation of ${selectedGameVersion}...`); + if (!instancesState.activeInstanceId) { + error = "Please select an instance first"; + return; + } try { await invoke("install_version", { + instanceId: instancesState.activeInstanceId, versionId: selectedGameVersion, }); logsState.addLog("info", "Installer", `Successfully installed ${selectedGameVersion}`); @@ -134,6 +141,12 @@ return; } + if (!instancesState.activeInstanceId) { + error = "Please select an instance first"; + isInstalling = false; + return; + } + isInstalling = true; error = null; @@ -142,6 +155,7 @@ if (!isVersionInstalled) { logsState.addLog("info", "Installer", `Installing base game ${selectedGameVersion} first...`); await invoke("install_version", { + instanceId: instancesState.activeInstanceId, versionId: selectedGameVersion, }); isVersionInstalled = true; @@ -151,6 +165,7 @@ if (selectedLoader === "fabric" && selectedFabricLoader) { logsState.addLog("info", "Installer", `Installing Fabric ${selectedFabricLoader} for ${selectedGameVersion}...`); const result = await invoke<any>("install_fabric", { + instanceId: instancesState.activeInstanceId, gameVersion: selectedGameVersion, loaderVersion: selectedFabricLoader, }); @@ -159,6 +174,7 @@ } else if (selectedLoader === "forge" && selectedForgeVersion) { logsState.addLog("info", "Installer", `Installing Forge ${selectedForgeVersion} for ${selectedGameVersion}...`); const result = await invoke<any>("install_forge", { + instanceId: instancesState.activeInstanceId, gameVersion: selectedGameVersion, forgeVersion: selectedForgeVersion, }); diff --git a/ui/src/components/Sidebar.svelte b/ui/src/components/Sidebar.svelte index 3d36f89..83f4ac6 100644 --- a/ui/src/components/Sidebar.svelte +++ b/ui/src/components/Sidebar.svelte @@ -1,6 +1,6 @@ <script lang="ts"> import { uiState } from '../stores/ui.svelte'; - import { Home, Package, Settings, Bot } from 'lucide-svelte'; + import { Home, Package, Settings, Bot, Folder } from 'lucide-svelte'; </script> <aside @@ -76,6 +76,7 @@ {/snippet} {@render navItem('home', Home, 'Overview')} + {@render navItem('instances', Folder, 'Instances')} {@render navItem('versions', Package, 'Versions')} {@render navItem('guide', Bot, 'Assistant')} {@render navItem('settings', Settings, 'Settings')} diff --git a/ui/src/components/VersionsView.svelte b/ui/src/components/VersionsView.svelte index 2e8b028..d4d36d5 100644 --- a/ui/src/components/VersionsView.svelte +++ b/ui/src/components/VersionsView.svelte @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { gameState } from "../stores/game.svelte"; + import { instancesState } from "../stores/instances.svelte"; import ModLoaderSelector from "./ModLoaderSelector.svelte"; let searchQuery = $state(""); @@ -18,11 +19,17 @@ // Load installed modded versions with Java version info (both Fabric and Forge) async function loadInstalledModdedVersions() { + if (!instancesState.activeInstanceId) { + installedFabricVersions = []; + isLoadingModded = false; + return; + } isLoadingModded = true; try { // Get all installed versions and filter for modded ones (Fabric and Forge) const allInstalled = await invoke<Array<{ id: string; type: string }>>( - "list_installed_versions" + "list_installed_versions", + { instanceId: instancesState.activeInstanceId } ); // Filter for Fabric and Forge versions @@ -36,7 +43,10 @@ try { const javaVersion = await invoke<number | null>( "get_version_java_version", - { versionId: id } + { + instanceId: instancesState.activeInstanceId!, + versionId: id, + } ); return { id, diff --git a/ui/src/stores/game.svelte.ts b/ui/src/stores/game.svelte.ts index ca5dc2b..3efcf71 100644 --- a/ui/src/stores/game.svelte.ts +++ b/ui/src/stores/game.svelte.ts @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { Version } from "../types"; import { uiState } from "./ui.svelte"; import { authState } from "./auth.svelte"; +import { instancesState } from "./instances.svelte"; export class GameState { versions = $state<Version[]>([]); @@ -34,10 +35,19 @@ export class GameState { return; } + if (!instancesState.activeInstanceId) { + alert("Please select an instance first!"); + uiState.setView("instances"); + return; + } + uiState.setStatus("Preparing to launch " + this.selectedVersion + "..."); - console.log("Invoking start_game for version:", this.selectedVersion); + console.log("Invoking start_game for version:", this.selectedVersion, "instance:", instancesState.activeInstanceId); try { - const msg = await invoke<string>("start_game", { versionId: this.selectedVersion }); + const msg = await invoke<string>("start_game", { + instanceId: instancesState.activeInstanceId, + versionId: this.selectedVersion, + }); console.log("Response:", msg); uiState.setStatus(msg); } catch (e) { diff --git a/ui/src/stores/instances.svelte.ts b/ui/src/stores/instances.svelte.ts new file mode 100644 index 0000000..f4ac4e9 --- /dev/null +++ b/ui/src/stores/instances.svelte.ts @@ -0,0 +1,109 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { Instance } from "../types"; +import { uiState } from "./ui.svelte"; + +export class InstancesState { + instances = $state<Instance[]>([]); + activeInstanceId = $state<string | null>(null); + get activeInstance(): Instance | null { + if (!this.activeInstanceId) return null; + return this.instances.find((i) => i.id === this.activeInstanceId) || null; + } + + async loadInstances() { + try { + this.instances = await invoke<Instance[]>("list_instances"); + const active = await invoke<Instance | null>("get_active_instance"); + if (active) { + this.activeInstanceId = active.id; + } else if (this.instances.length > 0) { + // If no active instance but instances exist, set the first one as active + await this.setActiveInstance(this.instances[0].id); + } + } catch (e) { + console.error("Failed to load instances:", e); + uiState.setStatus("Error loading instances: " + e); + } + } + + async createInstance(name: string): Promise<Instance | null> { + try { + const instance = await invoke<Instance>("create_instance", { name }); + await this.loadInstances(); + uiState.setStatus(`Instance "${name}" created successfully`); + return instance; + } catch (e) { + console.error("Failed to create instance:", e); + uiState.setStatus("Error creating instance: " + e); + return null; + } + } + + async deleteInstance(id: string) { + try { + await invoke("delete_instance", { instanceId: id }); + await this.loadInstances(); + // If deleted instance was active, set another as active + if (this.activeInstanceId === id) { + if (this.instances.length > 0) { + await this.setActiveInstance(this.instances[0].id); + } else { + this.activeInstanceId = null; + } + } + uiState.setStatus("Instance deleted successfully"); + } catch (e) { + console.error("Failed to delete instance:", e); + uiState.setStatus("Error deleting instance: " + e); + } + } + + async updateInstance(instance: Instance) { + try { + await invoke("update_instance", { instance }); + await this.loadInstances(); + uiState.setStatus("Instance updated successfully"); + } catch (e) { + console.error("Failed to update instance:", e); + uiState.setStatus("Error updating instance: " + e); + } + } + + async setActiveInstance(id: string) { + try { + await invoke("set_active_instance", { instanceId: id }); + this.activeInstanceId = id; + uiState.setStatus("Active instance changed"); + } catch (e) { + console.error("Failed to set active instance:", e); + uiState.setStatus("Error setting active instance: " + e); + } + } + + async duplicateInstance(id: string, newName: string): Promise<Instance | null> { + try { + const instance = await invoke<Instance>("duplicate_instance", { + instanceId: id, + newName, + }); + await this.loadInstances(); + uiState.setStatus(`Instance duplicated as "${newName}"`); + return instance; + } catch (e) { + console.error("Failed to duplicate instance:", e); + uiState.setStatus("Error duplicating instance: " + e); + return null; + } + } + + async getInstance(id: string): Promise<Instance | null> { + try { + return await invoke<Instance>("get_instance", { instanceId: id }); + } catch (e) { + console.error("Failed to get instance:", e); + return null; + } + } +} + +export const instancesState = new InstancesState(); diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index 9a4da2b..a5b336e 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -1,4 +1,4 @@ -export type ViewType = "home" | "versions" | "settings" | "guide"; +export type ViewType = "home" | "versions" | "settings" | "guide" | "instances"; export interface Version { id: string; @@ -187,3 +187,18 @@ export interface InstalledForgeVersion { // ==================== Mod Loader Type ==================== export type ModLoaderType = "vanilla" | "fabric" | "forge"; + +// ==================== Instance Types ==================== + +export interface Instance { + id: string; + name: string; + game_dir: string; + version_id?: string; + created_at: number; + last_played?: number; + icon_path?: string; + notes?: string; + mod_loader?: string; + mod_loader_version?: string; +} |