diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs index e1150254..b2f05da5 100644 --- a/packages/oneclient_app/src/hooks/queries/mutations.rs +++ b/packages/oneclient_app/src/hooks/queries/mutations.rs @@ -51,6 +51,10 @@ pub enum ClusterAction { content_type: ContentType, path: PathBuf, }, + SetDedicatedDir { + cluster_id: ClusterId, + dedicated: bool, + }, } impl MutationCapability for ClusterMutation { @@ -99,6 +103,13 @@ impl MutationCapability for ClusterMutation { .notifier .send_info("Imported", &format!("Added {}", row.file_name)); }), + ClusterAction::SetDedicatedDir { + cluster_id, + dedicated, + } => oneclient_core::clusters::ClusterManager::set_dedicated_dir( + &state, *cluster_id, *dedicated, + ) + .await, }; result.map_err(|e| e.to_string()) } diff --git a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs index 4af35577..15f7c16b 100644 --- a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs +++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs @@ -5,10 +5,12 @@ use oneclient_core::java::JavaRuntime; use oneclient_core::packages::domain::GameLoader; use oneclient_core::settings::{GameSettingsProfile, ProfileUpdate, Resolution}; -use crate::components::{Dropdown, Icon, IconType, ScrollArea, TextInput, toggle, validate_number}; +use crate::components::{ + Dropdown, Icon, IconType, ScrollArea, TextInput, toggle, toggle_controlled, validate_number, +}; use crate::hooks::{ - java_runtimes, loader_versions, try_game_profile, use_dispatch, use_game_profile, - use_java_runtimes, use_loader_versions, use_settings_snapshot, + ClusterAction, java_runtimes, loader_versions, try_game_profile, use_cluster_mutation, + use_dispatch, use_game_profile, use_java_runtimes, use_loader_versions, use_settings_snapshot, }; use crate::layout::cluster_content; use crate::theme::colors; @@ -98,6 +100,14 @@ impl Component for ClusterSettings { } .into_element(), ) + .child(section_header("DIRECTORY")) + .child( + DedicatedDirRow { + cluster_id, + dedicated: cluster.uses_dedicated_dir(), + } + .into_element(), + ) .child(section_header("PROCESS")) .child(text_row(cluster_id, TextField::Pre, &profile, &global)) .child(text_row(cluster_id, TextField::Wrapper, &profile, &global)) @@ -216,6 +226,35 @@ impl Component for ToggleRow { } } +#[derive(PartialEq)] +struct DedicatedDirRow { + cluster_id: i64, + dedicated: bool, +} + +impl Component for DedicatedDirRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let dedicated = self.dedicated; + let mutation = use_cluster_mutation(); + + let on_toggle: EventHandler<()> = (move |()| { + mutation.mutate(ClusterAction::SetDedicatedDir { + cluster_id, + dedicated: !dedicated, + }); + }) + .into(); + + settings_row( + IconType::Folder, + "Dedicated Directory", + "Run this cluster in its own .minecraft instead of the shared one.", + toggle_controlled(dedicated, on_toggle), + ) + } +} + #[derive(PartialEq)] struct MemoryRow { cluster_id: i64, diff --git a/packages/oneclient_core/src/clusters/cluster.rs b/packages/oneclient_core/src/clusters/cluster.rs index 21bf2413..39b07e13 100644 --- a/packages/oneclient_core/src/clusters/cluster.rs +++ b/packages/oneclient_core/src/clusters/cluster.rs @@ -13,6 +13,8 @@ use crate::LauncherResult; use super::error::ClusterError; use super::stage::ClusterStage; +pub const DEDICATED_MARKER: &str = ".dedicated_directory"; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Cluster { pub id: ClusterId, @@ -76,6 +78,22 @@ impl Cluster { Ok(paths::clusters_dir()?.join(&self.folder_name)) } + pub fn dedicated_marker(&self) -> LauncherResult { + Ok(self.dir()?.join(DEDICATED_MARKER)) + } + + pub fn uses_dedicated_dir(&self) -> bool { + self.dedicated_marker().map(|p| p.exists()).unwrap_or(false) + } + + pub fn game_dir(&self) -> LauncherResult { + if self.uses_dedicated_dir() { + self.dir() + } else { + paths::shared_minecraft_dir() + } + } + pub fn as_link_target(&self) -> ClusterLinkTarget<'_> { ClusterLinkTarget { id: self.id, diff --git a/packages/oneclient_core/src/clusters/manager.rs b/packages/oneclient_core/src/clusters/manager.rs index 5610a98c..8e6001ab 100644 --- a/packages/oneclient_core/src/clusters/manager.rs +++ b/packages/oneclient_core/src/clusters/manager.rs @@ -122,6 +122,33 @@ impl ClusterManager { Ok(Cluster::try_from_row(row)?) } + pub async fn uses_dedicated_dir( + state: &LauncherState, + cluster_id: ClusterId, + ) -> LauncherResult { + Ok(Self::get(state, cluster_id).await?.uses_dedicated_dir()) + } + + pub async fn set_dedicated_dir( + state: &LauncherState, + cluster_id: ClusterId, + dedicated: bool, + ) -> LauncherResult<()> { + if state.games.is_active(cluster_id) { + return Err(crate::game::GameError::AlreadyRunning(cluster_id).into()); + } + + let cluster = Self::get(state, cluster_id).await?; + let marker = cluster.dedicated_marker()?; + if dedicated { + polyio::create_dir_all(cluster.dir()?).await?; + tokio::fs::write(&marker, b"").await.ok(); + } else if marker.exists() { + tokio::fs::remove_file(&marker).await.ok(); + } + Ok(()) + } + pub async fn add_playtime( state: &LauncherState, cluster_id: ClusterId, diff --git a/packages/oneclient_core/src/clusters/prepare.rs b/packages/oneclient_core/src/clusters/prepare.rs index 3b191b3a..5ca9b0bf 100644 --- a/packages/oneclient_core/src/clusters/prepare.rs +++ b/packages/oneclient_core/src/clusters/prepare.rs @@ -218,7 +218,7 @@ async fn run_forge_processors( .join(&version_info.id) .join(format!("{}.jar", version_info.id)); let libraries = paths::libraries_dir()?; - let cluster_dir = cluster.dir()?; + let cluster_dir = cluster.game_dir()?; let Some(data) = &mut version_info.data else { return Ok(()); diff --git a/packages/oneclient_core/src/game/error.rs b/packages/oneclient_core/src/game/error.rs index 8f490d92..8f5bd229 100644 --- a/packages/oneclient_core/src/game/error.rs +++ b/packages/oneclient_core/src/game/error.rs @@ -30,6 +30,9 @@ pub enum GameError { #[error("cluster {0} is already running")] AlreadyRunning(i64), + #[error("another cluster ({0}) is already running in the same directory")] + DirectoryInUse(i64), + #[error("failed to spawn the game process: {0}")] Spawn(String), } diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index d6da63f0..36a182b2 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -55,6 +55,13 @@ pub async fn launch_cluster( let existing = ClusterManager::get(state, cluster_id).await?; + let game_dir = existing.game_dir()?; + if let Some(other) = state.games.dir_in_use_by(&game_dir, cluster_id) { + return Err(GameError::DirectoryInUse(other).into()); + } + + let dedicated = existing.uses_dedicated_dir(); + let progress = GroupedProgressSession::start( &state.services.notifier, format!("Launching {}", existing.name), @@ -188,9 +195,17 @@ pub async fn launch_cluster( stage(LaunchStage::Launching); - let cwd = cluster.dir()?; + let cwd = game_dir; tokio::fs::create_dir_all(&cwd).await.ok(); + if !dedicated { + if let Err(err) = + crate::game::sync_shared_content(&state.services, &cluster, &cwd).await + { + tracing::warn!(cluster_id, error = %err, "failed to sync shared content"); + } + } + let client_jar = paths::versions_dir()? .join(&version_name) .join(format!("{version_name}.jar")); @@ -270,6 +285,7 @@ pub async fn launch_cluster( stage(LaunchStage::Running); state.games.set_pid(cluster_id, pid); + state.games.set_dir(cluster_id, cwd.clone()); let log_path = paths::logs_dir()?.join(format!("cluster-{cluster_id}.log")); if let Some(parent) = log_path.parent() { diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index 156e7617..1b9d5c3e 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -7,6 +7,7 @@ mod metadata; mod process; mod rules; mod session; +mod shared_dir; pub use analytics::{ Analytics, DayPlaytime, Persona, PlaytimeStats, ServerStat, WEEKDAY_LABELS, aggregate_servers, @@ -26,3 +27,4 @@ pub use metadata::{ resolve_minecraft_version, }; pub use rules::{validate_rule, validate_rules}; +pub use shared_dir::sync_shared_content; diff --git a/packages/oneclient_core/src/game/process.rs b/packages/oneclient_core/src/game/process.rs index f1b193e2..590f6e5e 100644 --- a/packages/oneclient_core/src/game/process.rs +++ b/packages/oneclient_core/src/game/process.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; +use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::Instant; @@ -18,6 +19,7 @@ pub struct GameProcess { pub struct GameProcessManager { inner: Mutex>, kills: Mutex>>, + dirs: Mutex>, } impl GameProcessManager { @@ -60,6 +62,20 @@ impl GameProcessManager { pub fn remove(&self, cluster_id: i64) { self.inner.lock().unwrap().remove(&cluster_id); self.kills.lock().unwrap().remove(&cluster_id); + self.dirs.lock().unwrap().remove(&cluster_id); + } + + pub fn set_dir(&self, cluster_id: i64, dir: PathBuf) { + self.dirs.lock().unwrap().insert(cluster_id, dir); + } + + pub fn dir_in_use_by(&self, dir: &Path, exclude: i64) -> Option { + self.dirs + .lock() + .unwrap() + .iter() + .find(|(id, d)| **id != exclude && d.as_path() == dir) + .map(|(id, _)| *id) } pub fn is_running(&self, cluster_id: i64) -> bool { diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs new file mode 100644 index 00000000..0c648dfe --- /dev/null +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -0,0 +1,83 @@ + +use std::path::Path; + +use oneclient_db::dao::artifact as artifact_dao; + +use crate::clusters::Cluster; +use crate::packages::domain::ContentType; +use crate::packages::store::{artifact_absolute_path, link_or_copy}; +use crate::packages::PackageStore; +use crate::state::LauncherServices; +use crate::LauncherResult; + +const SWAP_TYPES: [ContentType; 3] = + [ContentType::Mod, ContentType::ResourcePack, ContentType::Shader]; + +const FABRIC_DEP_OVERRIDES: &str = "config/fabric_loader_dependencies.json"; + +pub async fn sync_shared_content( + services: &LauncherServices, + cluster: &Cluster, + game_dir: &Path, +) -> LauncherResult<()> { + tokio::fs::create_dir_all(game_dir).await.ok(); + + for content_type in SWAP_TYPES { + let dir = game_dir.join(content_type.folder_name()); + if dir.exists() { + tokio::fs::remove_dir_all(&dir).await.ok(); + } + tokio::fs::create_dir_all(&dir).await.ok(); + } + + let linked = PackageStore::list_linked_artifacts(cluster.id, services).await?; + for link in linked { + if !link.enabled || !SWAP_TYPES.contains(&link.content_type) { + continue; + } + + let Some(artifact) = + artifact_dao::get_artifact_by_hash(&services.db, &link.hash).await? + else { + continue; + }; + + let src = artifact_absolute_path(&artifact.path)?; + if !src.exists() { + tracing::warn!(hash = %link.hash, "cached artifact missing; skipping shared link"); + continue; + } + + let dest = game_dir + .join(link.content_type.folder_name()) + .join(&link.cluster_file_name); + + if let Err(err) = link_or_copy(&src, &dest).await { + tracing::warn!( + file = %link.cluster_file_name, + error = %err, + "failed to link content into shared directory" + ); + } + } + + sync_fabric_dep_overrides(cluster, game_dir).await?; + + Ok(()) +} + +async fn sync_fabric_dep_overrides(cluster: &Cluster, game_dir: &Path) -> LauncherResult<()> { + let src = cluster.dir()?.join(FABRIC_DEP_OVERRIDES); + let dest = game_dir.join(FABRIC_DEP_OVERRIDES); + + if src.exists() { + if let Some(parent) = dest.parent() { + tokio::fs::create_dir_all(parent).await.ok(); + } + tokio::fs::copy(&src, &dest).await?; + } else if dest.exists() { + tokio::fs::remove_file(&dest).await.ok(); + } + + Ok(()) +} diff --git a/packages/oneclient_core/src/logs/manage.rs b/packages/oneclient_core/src/logs/manage.rs index d34b6100..83946480 100644 --- a/packages/oneclient_core/src/logs/manage.rs +++ b/packages/oneclient_core/src/logs/manage.rs @@ -67,7 +67,7 @@ fn collect_dir( pub fn list_cluster_logs(cluster: &Cluster) -> LauncherResult> { let mut out = Vec::new(); - let dir = cluster.dir()?; + let dir = cluster.game_dir()?; collect_dir( &dir.join("logs"), diff --git a/packages/oneclient_core/src/packages/store/link.rs b/packages/oneclient_core/src/packages/store/link.rs index 4746984e..de1ffa35 100644 --- a/packages/oneclient_core/src/packages/store/link.rs +++ b/packages/oneclient_core/src/packages/store/link.rs @@ -47,7 +47,7 @@ pub async fn link_artifact_to_cluster( Ok(()) } -async fn link_or_copy(src: &Path, dest: &Path) -> LauncherResult<()> { +pub async fn link_or_copy(src: &Path, dest: &Path) -> LauncherResult<()> { if dest.exists() { tokio::fs::remove_file(dest).await?; } diff --git a/packages/oneclient_core/src/packages/store/mod.rs b/packages/oneclient_core/src/packages/store/mod.rs index 3ebc8a83..e4495016 100644 --- a/packages/oneclient_core/src/packages/store/mod.rs +++ b/packages/oneclient_core/src/packages/store/mod.rs @@ -3,7 +3,7 @@ mod link; mod paths; pub use download::{download_external, download_version_file, ensure_artifact_file}; -pub use link::{link_artifact_to_cluster, unlink_cluster_file}; +pub use link::{link_artifact_to_cluster, link_or_copy, unlink_cluster_file}; pub use paths::{artifact_absolute_path, cache_file_path, relative_cache_path}; use oneclient_db::dao::{artifact as artifact_dao, cluster as cluster_dao}; diff --git a/packages/oneclient_core/src/paths.rs b/packages/oneclient_core/src/paths.rs index 315191db..e183b7cd 100644 --- a/packages/oneclient_core/src/paths.rs +++ b/packages/oneclient_core/src/paths.rs @@ -60,6 +60,10 @@ pub fn clusters_dir() -> LauncherResult { Ok(launcher_dir()?.join("clusters")) } +pub fn shared_minecraft_dir() -> LauncherResult { + Ok(launcher_dir()?.join("minecraft")) +} + pub fn packages_cache_dir() -> LauncherResult { Ok(launcher_dir()?.join("metadata").join("packages")) } diff --git a/packages/oneclient_core/src/screenshots/mod.rs b/packages/oneclient_core/src/screenshots/mod.rs index 5fcce84f..73cb8059 100644 --- a/packages/oneclient_core/src/screenshots/mod.rs +++ b/packages/oneclient_core/src/screenshots/mod.rs @@ -34,16 +34,18 @@ fn is_image(name: &str) -> bool { fn ensure_in_clusters(path: &Path) -> LauncherResult { let canon = std::fs::canonicalize(path).map_err(ScreenshotsError::Io)?; - if let Ok(root) = std::fs::canonicalize(paths::clusters_dir()?) - && canon.starts_with(&root) - { - return Ok(canon); + for root in [paths::clusters_dir()?, paths::shared_minecraft_dir()?] { + if let Ok(root) = std::fs::canonicalize(root) + && canon.starts_with(&root) + { + return Ok(canon); + } } Err(ScreenshotsError::InvalidPath(path.display().to_string()).into()) } pub fn list_cluster_screenshots(cluster: &Cluster) -> LauncherResult> { - let dir = cluster.dir()?.join("screenshots"); + let dir = cluster.game_dir()?.join("screenshots"); let mut out = Vec::new(); let Ok(entries) = std::fs::read_dir(&dir) else {