From 56d73e3ab134d7b07b9fc50644dc010391360485 Mon Sep 17 00:00:00 2001 From: Maciek <108873945+rozwader@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:24:25 +0200 Subject: [PATCH] feat: multiple minecraft instances (#955) * feat: mutliple minecraft instances (only dedicated clusters) * changeset --- .changeset/multiple_minecraft_instances.md | 5 + .../src/components/active_cluster_panel.rs | 5 +- .../src/hooks/shortcut_actions.rs | 6 +- .../oneclient_app/src/layout/cluster_shell.rs | 5 +- .../src/layout/pending_launch.rs | 25 +++- packages/oneclient_app/src/state.rs | 116 +++++++++++++++--- .../src/view/app/clusters/page.rs | 5 +- packages/oneclient_app/src/view/app/mod.rs | 29 +++++ .../oneclient_core/src/clusters/prepare.rs | 14 ++- packages/oneclient_core/src/game/launch.rs | 45 ++++--- packages/oneclient_core/src/game/mod.rs | 4 +- packages/oneclient_core/src/game/natives.rs | 58 +++++++++ packages/oneclient_core/src/game/process.rs | 93 ++++++++++++-- packages/oneclient_core/src/game/reattach.rs | 9 ++ .../oneclient_core/src/settings/launcher.rs | 18 ++- packages/oneclient_core/src/settings/store.rs | 4 +- packages/oneclient_core/src/storage.rs | 22 +--- packages/oneclient_core/src/verify.rs | 49 +++++++- packages/oneclient_events/src/event.rs | 7 +- packages/oneclient_mc/src/install.rs | 48 ++++++-- 20 files changed, 481 insertions(+), 86 deletions(-) create mode 100644 .changeset/multiple_minecraft_instances.md create mode 100644 packages/oneclient_core/src/game/natives.rs diff --git a/.changeset/multiple_minecraft_instances.md b/.changeset/multiple_minecraft_instances.md new file mode 100644 index 00000000..4c16950f --- /dev/null +++ b/.changeset/multiple_minecraft_instances.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +# Added safe multiple minecraft instances launching, only dedicated clusters tho \ No newline at end of file diff --git a/packages/oneclient_app/src/components/active_cluster_panel.rs b/packages/oneclient_app/src/components/active_cluster_panel.rs index 8271a98e..19b431f6 100644 --- a/packages/oneclient_app/src/components/active_cluster_panel.rs +++ b/packages/oneclient_app/src/components/active_cluster_panel.rs @@ -13,7 +13,7 @@ use crate::hooks::{ use crate::routes::Route; use crate::theme::colors; use crate::utils::sort_clusters_for_home; -use crate::view::app::{launch_button_state, launch_syncing}; +use crate::view::app::{launch_button_state, launch_syncing, shared_dir_taken}; #[derive(PartialEq)] pub struct ActiveClusterPanel; @@ -92,6 +92,7 @@ impl Component for ActiveClusterPanel { }; let cluster_id = cluster.id; let syncing = launch_syncing(&launcher, cluster.uses_bundles()); + let shared_dir_taken = shared_dir_taken(&game, &sorted, cluster_id); rect() .vertical() @@ -127,7 +128,7 @@ impl Component for ActiveClusterPanel { .child(launch_button( cluster_id, dispatch, - launch_button_state(&game, cluster_id, syncing), + launch_button_state(&game, cluster_id, syncing, shared_dir_taken), )) .child(cluster_settings_button(cluster_id)), ) diff --git a/packages/oneclient_app/src/hooks/shortcut_actions.rs b/packages/oneclient_app/src/hooks/shortcut_actions.rs index ca0e3094..9a61b691 100644 --- a/packages/oneclient_app/src/hooks/shortcut_actions.rs +++ b/packages/oneclient_app/src/hooks/shortcut_actions.rs @@ -26,12 +26,14 @@ impl Actions { .take() } + /// `shared` is every other cluster in the shared game dir when this one + /// launches there too see [`crate::state::GameState::launch_block`] #[must_use] - pub fn launch_block(&self, cluster_id: ClusterId) -> Option { + pub fn launch_block(&self, cluster_id: ClusterId, shared: &[ClusterId]) -> Option { let station = self.station(); let snapshot = station.peek(); let parallel = snapshot.settings.settings.allow_parallel_running_clusters; - snapshot.game.launch_block(cluster_id, parallel) + snapshot.game.launch_block(cluster_id, parallel, shared) } pub fn report_missing_shortcut_target(&self, folder: &str) { diff --git a/packages/oneclient_app/src/layout/cluster_shell.rs b/packages/oneclient_app/src/layout/cluster_shell.rs index 4b821fff..722bc036 100644 --- a/packages/oneclient_app/src/layout/cluster_shell.rs +++ b/packages/oneclient_app/src/layout/cluster_shell.rs @@ -10,7 +10,7 @@ use crate::hooks::{ use crate::routes::Route; use crate::theme::colors; use crate::ui::entrance_motion_layer; -use crate::view::app::{launch_button_state, launch_syncing}; +use crate::view::app::{launch_button_state, launch_syncing, use_shared_dir_taken}; const HEADER_HEIGHT: f32 = 64.; const TABS_HEIGHT: f32 = 38.; @@ -113,13 +113,14 @@ impl Component for ClusterShell { let launcher = use_launcher(); let cluster = use_cluster(cluster_id); let syncing = launch_syncing(&launcher, cluster.as_ref().is_none_or(|c| c.uses_bundles())); + let shared_dir_taken = use_shared_dir_taken(cluster_id); let show_game_log = game.is_active(cluster_id); let show_datapacks = cluster .as_ref() .is_none_or(|c| crate::view::app::cluster::supports_datapacks(&c.mc_version)); let show_mod_tabs = cluster.as_ref().is_none_or(|c| !c.lacks_mod_loader()); - let launch_state = launch_button_state(&game, cluster_id, syncing); + let launch_state = launch_button_state(&game, cluster_id, syncing, shared_dir_taken); let hidden_tab = !show_mod_tabs && matches!( diff --git a/packages/oneclient_app/src/layout/pending_launch.rs b/packages/oneclient_app/src/layout/pending_launch.rs index aa9f6f05..b193645e 100644 --- a/packages/oneclient_app/src/layout/pending_launch.rs +++ b/packages/oneclient_app/src/layout/pending_launch.rs @@ -1,6 +1,7 @@ use freya::prelude::*; use freya::router::*; use oneclient_cluster::Cluster; +use oneclient_core::LauncherState; use crate::Actions; use crate::components::IconType; @@ -65,7 +66,8 @@ async fn launch_shortcut(actions: Actions, router: RouterContext, folder: String cluster_id: cluster.id, }); - let Some(block) = actions.launch_block(cluster.id) else { + let shared = shared_dir_neighbours(&state, &cluster).await; + let Some(block) = actions.launch_block(cluster.id, &shared) else { actions.launch_cluster(cluster.id); return; }; @@ -74,6 +76,27 @@ async fn launch_shortcut(actions: Actions, router: RouterContext, folder: String report_already_open(&actions, &cluster, block, blocker.as_deref()); } +/// The other clusters that launch into the shared game dir empty when this +/// cluster has a dedicated one +async fn shared_dir_neighbours(state: &LauncherState, cluster: &Cluster) -> Vec { + if cluster.uses_dedicated_dir() { + return Vec::new(); + } + + match state.clusters.list().await { + Ok(clusters) => clusters + .into_iter() + .filter(|other| other.id != cluster.id && !other.uses_dedicated_dir()) + .map(|other| other.id) + .collect(), + Err(err) => { + // Core refuses a busy shared dir anyway this only words the notice + tracing::warn!(cluster_id = cluster.id, "cluster list failed: {err:#}"); + Vec::new() + } + } +} + async fn blocker_name(cluster: &Cluster, block: LaunchBlock) -> Option { if block.cluster_id() == cluster.id { return None; diff --git a/packages/oneclient_app/src/state.rs b/packages/oneclient_app/src/state.rs index 05d0ce9f..6d2cfe37 100644 --- a/packages/oneclient_app/src/state.rs +++ b/packages/oneclient_app/src/state.rs @@ -275,21 +275,25 @@ impl GameState { } } + /// `shared` lists the other clusters that would share the launch's game dir + /// empty for a dedicated cluster with parallel clusters allowed only those + /// can be in the way #[must_use] - pub fn launch_block(&self, cluster_id: i64, parallel: bool) -> Option { + pub fn launch_block( + &self, + cluster_id: i64, + parallel: bool, + shared: &[i64], + ) -> Option { if let Some(block) = self.block_for(cluster_id) { return Some(block); } - if parallel { - return None; - } - self.stages .keys() .chain(self.pending.iter()) .copied() - .filter(|id| *id != cluster_id) + .filter(|id| *id != cluster_id && (!parallel || shared.contains(id))) .find_map(|id| self.block_for(id)) } @@ -366,9 +370,32 @@ mod tests { let mut game = GameState::default(); game.stages.insert(1, LaunchStage::Running); - assert_eq!(game.launch_block(1, false), Some(LaunchBlock::Running(1))); - assert_eq!(game.launch_block(2, false), Some(LaunchBlock::Running(1))); - assert_eq!(game.launch_block(2, true), None); + assert_eq!( + game.launch_block(1, false, &[]), + Some(LaunchBlock::Running(1)) + ); + assert_eq!( + game.launch_block(2, false, &[]), + Some(LaunchBlock::Running(1)) + ); + assert_eq!(game.launch_block(2, true, &[]), None); + } + + #[test] + fn parallel_clusters_still_hold_one_shared_game() { + let mut game = GameState::default(); + game.stages.insert(1, LaunchStage::Running); + + // 2 shares the game dir with 1 and 3 has its own + assert_eq!( + game.launch_block(2, true, &[1]), + Some(LaunchBlock::Running(1)) + ); + assert_eq!(game.launch_block(3, true, &[]), None); + assert_eq!( + game.launch_block(1, true, &[]), + Some(LaunchBlock::Running(1)) + ); } #[test] @@ -376,8 +403,14 @@ mod tests { let mut game = GameState::default(); game.stages.insert(1, LaunchStage::Downloading); - assert_eq!(game.launch_block(1, false), Some(LaunchBlock::Starting(1))); - assert_eq!(game.launch_block(2, false), Some(LaunchBlock::Starting(1))); + assert_eq!( + game.launch_block(1, false, &[]), + Some(LaunchBlock::Starting(1)) + ); + assert_eq!( + game.launch_block(2, false, &[]), + Some(LaunchBlock::Starting(1)) + ); } #[test] @@ -385,8 +418,14 @@ mod tests { let mut game = GameState::default(); game.begin_launch(1); - assert_eq!(game.launch_block(1, false), Some(LaunchBlock::Starting(1))); - assert_eq!(game.launch_block(2, false), Some(LaunchBlock::Starting(1))); + assert_eq!( + game.launch_block(1, false, &[]), + Some(LaunchBlock::Starting(1)) + ); + assert_eq!( + game.launch_block(2, false, &[]), + Some(LaunchBlock::Starting(1)) + ); } #[test] @@ -394,23 +433,62 @@ mod tests { let mut game = GameState::default(); game.stages.insert(1, LaunchStage::Exited); - assert_eq!(game.launch_block(2, false), None); - assert_eq!(game.launch_block(1, false), None); + assert_eq!(game.launch_block(2, false, &[]), None); + assert_eq!(game.launch_block(1, false, &[]), None); } #[test] fn the_button_disables_on_the_claim_alone() { let mut game = GameState::default(); - assert_eq!(launch_button_state(&game, 1, false), ("Launch", true)); + assert_eq!( + launch_button_state(&game, 1, false, false), + ("Launch", true) + ); game.begin_launch(1); - assert_eq!(launch_button_state(&game, 1, false), ("Launching", false)); + assert_eq!( + launch_button_state(&game, 1, false, false), + ("Launching", false) + ); // Held past a failure which parks the stage at `Exited` game.stages.insert(1, LaunchStage::Exited); - assert_eq!(launch_button_state(&game, 1, false), ("Launching", false)); + assert_eq!( + launch_button_state(&game, 1, false, false), + ("Launching", false) + ); game.finish_launch(1); - assert_eq!(launch_button_state(&game, 1, false), ("Launch", true)); + assert_eq!( + launch_button_state(&game, 1, false, false), + ("Launch", true) + ); + } + + #[test] + fn a_queued_launch_says_it_is_waiting() { + let mut game = GameState::default(); + game.stages.insert(1, LaunchStage::Waiting); + + assert_eq!( + launch_button_state(&game, 1, false, false), + ("Waiting", false) + ); + assert!(game.is_busy(1)); + } + + #[test] + fn a_taken_shared_dir_blocks_only_an_idle_cluster() { + let mut game = GameState::default(); + assert_eq!( + launch_button_state(&game, 1, false, true), + ("Folder in use", false) + ); + + game.stages.insert(1, LaunchStage::Running); + assert_eq!( + launch_button_state(&game, 1, false, true), + ("Running", false) + ); } } diff --git a/packages/oneclient_app/src/view/app/clusters/page.rs b/packages/oneclient_app/src/view/app/clusters/page.rs index 975e91be..aa383145 100644 --- a/packages/oneclient_app/src/view/app/clusters/page.rs +++ b/packages/oneclient_app/src/view/app/clusters/page.rs @@ -26,7 +26,7 @@ use crate::utils::{ line_title, resolve_cluster, split_clusters, }; use crate::view::app::clusters::CreateInstanceModal; -use crate::view::app::{launch_button_state, launch_syncing}; +use crate::view::app::{launch_button_state, launch_syncing, use_shared_dir_taken}; const PAGE_PADDING: Gaps = Gaps::new(12., 28., 28., 28.); const COLUMN_GAP_PX: f32 = 24.; @@ -723,6 +723,7 @@ impl Component for Sidebar { let cluster_id = self.cluster_id; let syncing = launch_syncing(&launcher, self.uses_bundles); + let shared_dir_taken = use_shared_dir_taken(cluster_id); let art_height = art_height_for(*sidebar_height.read()); let top_padding = if art_height.is_some() { 4. } else { 22. }; @@ -797,7 +798,7 @@ impl Component for Sidebar { .child(play_button( cluster_id, dispatch, - launch_button_state(&game, cluster_id, syncing), + launch_button_state(&game, cluster_id, syncing, shared_dir_taken), )) .child( Button::new() diff --git a/packages/oneclient_app/src/view/app/mod.rs b/packages/oneclient_app/src/view/app/mod.rs index f9b4339c..319bfa26 100644 --- a/packages/oneclient_app/src/view/app/mod.rs +++ b/packages/oneclient_app/src/view/app/mod.rs @@ -9,21 +9,27 @@ pub mod browser; pub mod cluster; pub mod settings; +use crate::hooks::{settled_or_loading, use_clusters, use_game_snapshot}; use crate::state::{GameState as GameSnapshot, LauncherInit}; +use oneclient_core::clusters::Cluster; use oneclient_events::LaunchStage; pub fn launch_button_state( game: &GameSnapshot, cluster_id: i64, syncing: bool, + shared_dir_taken: bool, ) -> (&'static str, bool) { let state = match game.stage(cluster_id) { Some(LaunchStage::Checking) => ("Checking", false), + Some(LaunchStage::Waiting) => ("Waiting", false), Some(LaunchStage::Downloading) => ("Downloading", false), Some(LaunchStage::Launching) => ("Launching", false), Some(LaunchStage::Running) => ("Running", false), // The click's own flag held for a beat after a failure so a burst of clicks starts one game _ if game.is_launch_pending(cluster_id) => ("Launching", false), + // Core refuses it anyway the shared game dir holds one game at a time + _ if shared_dir_taken => ("Folder in use", false), _ => ("Launch", true), }; // Block launching while the startup bundle download is still running @@ -33,6 +39,29 @@ pub fn launch_button_state( state } +/// Whether another shared cluster is starting or running in the shared game dir +/// dedicated clusters never collide so any number of them may run alongside +pub fn shared_dir_taken(game: &GameSnapshot, clusters: &[Cluster], cluster_id: i64) -> bool { + // Active games are few so filter on them before touching the marker files + let mut others = clusters + .iter() + .filter(|c| c.id != cluster_id && (game.is_active(c.id) || game.is_launch_pending(c.id))) + .peekable(); + + others.peek().is_some() + && clusters + .iter() + .find(|c| c.id == cluster_id) + .is_some_and(|c| !c.uses_dedicated_dir()) + && others.any(|c| !c.uses_dedicated_dir()) +} + +pub fn use_shared_dir_taken(cluster_id: i64) -> bool { + let game = use_game_snapshot(); + let clusters = settled_or_loading(&use_clusters()).unwrap_or_default(); + shared_dir_taken(&game, &clusters, cluster_id) +} + pub fn launch_syncing(launcher: &LauncherInit, uses_bundles: bool) -> bool { launcher.fetching || (uses_bundles && launcher.syncing_bundles) } diff --git a/packages/oneclient_core/src/clusters/prepare.rs b/packages/oneclient_core/src/clusters/prepare.rs index 8bbff246..772bcecc 100644 --- a/packages/oneclient_core/src/clusters/prepare.rs +++ b/packages/oneclient_core/src/clusters/prepare.rs @@ -143,7 +143,16 @@ async fn game_download_bytes(services: &LauncherServices, info: &VersionInfo) -> let _ = services; // Runs before a Java runtime is resolved so rules are checked against the // host architecture only natives-related edge cases shift - match game::plan_downloads(info, assets_index, std::env::consts::ARCH, false, false).await { + match game::plan_downloads( + info, + assets_index, + std::env::consts::ARCH, + false, + false, + game::NativesMode::Extract, + ) + .await + { Ok(plan) => plan.total_bytes(), Err(err) => { tracing::warn!("download plan failed, estimating full size: {err}"); @@ -311,6 +320,8 @@ async fn install_cluster( .await? }; + let natives_mode = crate::game::natives::natives_mode(state, &version_info.id, None); + download_minecraft( &state.services.mc(), progress, @@ -318,6 +329,7 @@ async fn install_cluster( &java.os_arch, minecraft_updated, force, + natives_mode, ) .await?; diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index 3eca87da..0d646964 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -45,8 +45,9 @@ pub async fn launch_cluster( ) -> LauncherResult { tracing::info!(cluster_id, search_for_java, "launching cluster"); - let parallel = state.settings.read().allow_parallel_running_clusters; - if !parallel && state.games.is_active(cluster_id) { + // Parallel clusters or not a cluster runs only once a second game would + // share its directory and its slot in the process manager + if state.games.is_active(cluster_id) { tracing::warn!( cluster_id, "cluster already launching or running; refusing launch" @@ -54,13 +55,9 @@ pub async fn launch_cluster( return Err(GameError::AlreadyRunning(cluster_id).into()); } - // A parallel attempt shares its entry with the session already under way, so - // clearing it here would drop that game's kill sender and pid - let adopted = state.games.is_active(cluster_id); - let result = start(state, cluster_id, account, search_for_java).await; - if result.is_err() && !adopted { + if result.is_err() { state.games.remove(cluster_id); state .services @@ -83,7 +80,19 @@ async fn start( events.game_stage(cluster_id, s); }; + // The stage goes first so a click while this waits is refused as already + // active rather than queued behind it stage(LaunchStage::Checking); + let launch_slot = match state.games.try_launch_slot() { + Some(slot) => slot, + None => { + tracing::debug!(cluster_id, "another launch is preparing; waiting for it"); + stage(LaunchStage::Waiting); + let slot = state.games.launch_slot().await; + stage(LaunchStage::Checking); + slot + } + }; let existing = state.clusters.get(cluster_id).await?; @@ -92,20 +101,15 @@ async fn start( // Non-dedicated clusters share one directory so a second game there would // materialize over the running one's - // `dir_in_use_by` waves a cluster past - // its own session which parallel launches make reachable - if !dedicated && let Some(other) = state.games.dir_in_use(&game_dir) { + if let Err(other) = state.games.claim_dir(cluster_id, &game_dir, !dedicated) { + if dedicated { + return Err(GameError::DirectoryInUse(other).into()); + } tracing::warn!(cluster_id, other, "shared game dir busy; refusing launch"); let name = running_cluster_name(state, other).await; return Err(GameError::SharedDirectoryBusy(name).into()); } - if let Some(other) = state.games.dir_in_use_by(&game_dir, cluster_id) { - return Err(GameError::DirectoryInUse(other).into()); - } - - state.games.set_dir(cluster_id, game_dir.clone()); - match state.clusters.mark_played(cluster_id).await { Ok(()) => events.signal(oneclient_events::Signal::ClustersChanged), Err(err) => tracing::debug!(cluster_id, error = %err, "could not record the launch time"), @@ -221,6 +225,10 @@ async fn start( "resolved launch metadata" ); + if let Some(dir) = crate::game::natives::natives_dir(&version_name) { + state.games.set_natives(cluster_id, dir); + } + let java = if let Some(runtime) = state .java .runtime_for_profile(profile.java_path.as_deref()) @@ -248,6 +256,8 @@ async fn start( .set_stage(cluster_id, ClusterStage::Repairing) .await; stage(LaunchStage::Downloading); + let natives_mode = + crate::game::natives::natives_mode(state, &version_name, Some(cluster_id)); if let Err(err) = download_minecraft( &state.services.mc(), &progress, @@ -255,6 +265,7 @@ async fn start( &java.os_arch, updated, false, + natives_mode, ) .await { @@ -366,6 +377,8 @@ async fn start( profile.game_args.as_deref(), ); + drop(launch_slot); + if let Some(reason) = run_hook(profile.hook_pre.as_deref(), &cwd).await { events .notify("Pre-launch command failed") diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index fb504750..bc2d44e4 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -5,6 +5,7 @@ pub mod gpu; mod heal; mod launch; mod log_replay; +pub(crate) mod natives; mod process; mod reattach; mod session; @@ -28,7 +29,8 @@ pub use fabric::{mods_folder_argument, uses_cluster_mods_folder}; pub use heal::clear_zeroed_files; pub use launch::{LaunchedGame, is_running, launch_cluster, offer_repair}; pub use oneclient_mc::{ - DownloadPlan, GameVersionInfo, GameVersionKind, NativesReport, PlannedLibrary, check_natives, + DownloadPlan, GameVersionInfo, GameVersionKind, NativesMode, NativesReport, PlannedLibrary, + check_natives, confirm_incomplete_install, download_minecraft, download_version_info, game_files_missing, get_game_versions, get_loader_version, get_loader_versions, get_loaders_for_version, get_version_ids, get_versions_for_loader, is_version_updated, libraries_missing, diff --git a/packages/oneclient_core/src/game/natives.rs b/packages/oneclient_core/src/game/natives.rs new file mode 100644 index 00000000..aac88454 --- /dev/null +++ b/packages/oneclient_core/src/game/natives.rs @@ -0,0 +1,58 @@ +use std::path::PathBuf; + +use oneclient_common::domain::GameLoader; +use oneclient_common::paths; +use oneclient_mc::MetadataStore; + +use crate::clusters::Cluster; +use crate::game::NativesMode; +use crate::state::LauncherState; + +pub(crate) fn natives_dir(version_name: &str) -> Option { + paths::natives_dir().ok().map(|dir| dir.join(version_name)) +} + +pub(crate) fn cached_version_name(metadata: &MetadataStore, cluster: &Cluster) -> Option { + let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version); + + if cluster.mc_loader == GameLoader::Vanilla { + return Some(mc_version); + } + + let loader = oneclient_mc::get_loader_version_cached( + metadata, + &mc_version, + cluster.mc_loader, + cluster.mc_loader_version.as_deref(), + ) + .ok()??; + + Some(format!("{mc_version}-{}", loader.id)) +} + +pub(crate) fn natives_holder( + state: &LauncherState, + version_name: &str, + exclude: Option, +) -> Option { + let dir = natives_dir(version_name)?; + state.games.natives_in_use_by(&dir, exclude) +} + +pub(crate) fn natives_mode( + state: &LauncherState, + version_name: &str, + exclude: Option, +) -> NativesMode { + match natives_holder(state, version_name, exclude) { + Some(holder) => { + tracing::info!( + version_name, + holder, + "natives are loaded by a running game; leaving them in place" + ); + NativesMode::LeaveInPlace + } + None => NativesMode::Extract, + } +} diff --git a/packages/oneclient_core/src/game/process.rs b/packages/oneclient_core/src/game/process.rs index 5077f29a..1083ac48 100644 --- a/packages/oneclient_core/src/game/process.rs +++ b/packages/oneclient_core/src/game/process.rs @@ -63,6 +63,8 @@ pub struct GameProcessManager { inner: Mutex>, kills: Mutex>>, dirs: Mutex>, + natives: Mutex>, + launching: tokio::sync::Mutex<()>, } impl GameProcessManager { @@ -70,6 +72,18 @@ impl GameProcessManager { Self::default() } + /// One launch prepares at a time two launches at once would install the + /// same Java or download the same game files into the same paths + /// Held from the checks until the process spawns not for the whole session + pub async fn launch_slot(&self) -> tokio::sync::MutexGuard<'_, ()> { + self.launching.lock().await + } + + /// `None` while another launch holds the slot + pub fn try_launch_slot(&self) -> Option> { + self.launching.try_lock().ok() + } + pub fn register_kill(&self, cluster_id: i64, tx: oneshot::Sender<()>) { self.kills.lock().unwrap().insert(cluster_id, tx); } @@ -110,21 +124,44 @@ impl GameProcessManager { self.inner.lock().unwrap().remove(&cluster_id); self.kills.lock().unwrap().remove(&cluster_id); self.dirs.lock().unwrap().remove(&cluster_id); + self.natives.lock().unwrap().remove(&cluster_id); + } + + pub fn set_natives(&self, cluster_id: i64, dir: PathBuf) { + self.natives.lock().unwrap().insert(cluster_id, dir); + } + + pub fn natives_in_use_by(&self, dir: &Path, exclude: Option) -> Option { + self.natives + .lock() + .unwrap() + .iter() + .find(|(id, d)| Some(**id) != exclude && d.as_path() == dir) + .map(|(id, _)| *id) } pub fn set_dir(&self, cluster_id: i64, dir: PathBuf) { self.dirs.lock().unwrap().insert(cluster_id, dir); } - /// Like [`Self::dir_in_use_by`] but counts a cluster's own session too the - /// shared game directory holds one game at a time whoever it belongs to - pub fn dir_in_use(&self, dir: &Path) -> Option { - self.dirs - .lock() - .unwrap() + /// Check and record under one lock so two launches racing on separate + /// threads cannot both find the directory free + /// `exclusive` is for the shared game directory which holds one game at a + /// time whoever it belongs to counting the cluster's own session too + /// Returns the cluster already holding `dir` + pub fn claim_dir(&self, cluster_id: i64, dir: &Path, exclusive: bool) -> Result<(), i64> { + let mut dirs = self.dirs.lock().unwrap(); + + let holder = dirs .iter() - .find(|(_, d)| d.as_path() == dir) - .map(|(id, _)| *id) + .find(|(id, d)| (exclusive || **id != cluster_id) && d.as_path() == dir) + .map(|(id, _)| *id); + if let Some(holder) = holder { + return Err(holder); + } + + dirs.insert(cluster_id, dir.to_path_buf()); + Ok(()) } pub fn dir_in_use_by(&self, dir: &Path, exclude: i64) -> Option { @@ -177,3 +214,43 @@ impl GameProcessManager { .collect() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_shared_dir_takes_one_game() { + let games = GameProcessManager::new(); + let shared = Path::new("shared"); + + assert_eq!(games.claim_dir(1, shared, true), Ok(())); + assert_eq!(games.claim_dir(2, shared, true), Err(1)); + + games.remove(1); + assert_eq!(games.claim_dir(2, shared, true), Ok(())); + } + + #[test] + fn dedicated_dirs_do_not_get_in_each_others_way() { + let games = GameProcessManager::new(); + + assert_eq!(games.claim_dir(1, Path::new("one"), false), Ok(())); + assert_eq!(games.claim_dir(2, Path::new("two"), false), Ok(())); + assert_eq!(games.claim_dir(3, Path::new("one"), false), Err(1)); + } + + #[test] + fn natives_are_held_until_the_game_is_removed() { + let games = GameProcessManager::new(); + let natives = Path::new("natives/1.8.9"); + + games.set_natives(1, natives.to_path_buf()); + assert_eq!(games.natives_in_use_by(natives, None), Some(1)); + assert_eq!(games.natives_in_use_by(natives, Some(1)), None); + assert_eq!(games.natives_in_use_by(Path::new("natives/1.21"), None), None); + + games.remove(1); + assert_eq!(games.natives_in_use_by(natives, None), None); + } +} diff --git a/packages/oneclient_core/src/game/reattach.rs b/packages/oneclient_core/src/game/reattach.rs index 8a4c59a0..9f4d3b7c 100644 --- a/packages/oneclient_core/src/game/reattach.rs +++ b/packages/oneclient_core/src/game/reattach.rs @@ -155,6 +155,15 @@ async fn readopt( state.games.set_stage(cluster_id, LaunchStage::Running); state.games.set_pid(cluster_id, Some(pid)); state.games.set_dir(cluster_id, cwd.clone()); + let version_name = state + .metadata + .try_lock() + .ok() + .and_then(|metadata| crate::game::natives::cached_version_name(&metadata, &cluster)); + match version_name.as_deref().and_then(crate::game::natives::natives_dir) { + Some(dir) => state.games.set_natives(cluster_id, dir), + None => tracing::debug!(cluster_id, "could not resolve the re-adopted game's natives"), + } state .services .events diff --git a/packages/oneclient_core/src/settings/launcher.rs b/packages/oneclient_core/src/settings/launcher.rs index 29d7c893..91db0191 100644 --- a/packages/oneclient_core/src/settings/launcher.rs +++ b/packages/oneclient_core/src/settings/launcher.rs @@ -47,6 +47,8 @@ pub struct ViewState { pub sort: Option, } +pub const CURRENT_SETTINGS_VERSION: u32 = 2; + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(default)] pub struct LauncherSettings { @@ -93,10 +95,22 @@ impl LauncherSettings { } } +impl LauncherSettings { + /// Brings a file written by an older launcher up to the current defaults + pub fn migrate(&mut self) { + if self.settings_version < 2 { + // Had no toggle so a saved `false` was never the user's choice + // dedicated clusters plus one shared cluster may now run together + self.allow_parallel_running_clusters = true; + } + self.settings_version = CURRENT_SETTINGS_VERSION; + } +} + impl Default for LauncherSettings { fn default() -> Self { Self { - settings_version: 1, + settings_version: CURRENT_SETTINGS_VERSION, log_debug: false, auto_update: true, crash_reporting: true, @@ -104,7 +118,7 @@ impl Default for LauncherSettings { enable_gamemode: false, max_concurrent_requests: 25, global_game_settings: GameSettingsProfile::default_global_profile(), - allow_parallel_running_clusters: false, + allow_parallel_running_clusters: true, launch_behaviour: LaunchBehaviour::default(), run_in_background: false, show_tray_icon: true, diff --git a/packages/oneclient_core/src/settings/store.rs b/packages/oneclient_core/src/settings/store.rs index 9d642b62..7ca05990 100644 --- a/packages/oneclient_core/src/settings/store.rs +++ b/packages/oneclient_core/src/settings/store.rs @@ -17,7 +17,9 @@ pub async fn load_settings(notify: Option<&EventBus>) -> LauncherSettings { LauncherSettings::default() } else { let data = polyio::read(&path).await?; - serde_json::from_slice(&data)? + let mut settings: LauncherSettings = serde_json::from_slice(&data)?; + settings.migrate(); + settings }) } .await diff --git a/packages/oneclient_core/src/storage.rs b/packages/oneclient_core/src/storage.rs index 24b2e038..2185e3b1 100644 --- a/packages/oneclient_core/src/storage.rs +++ b/packages/oneclient_core/src/storage.rs @@ -156,25 +156,11 @@ async fn natives_in_use(state: &LauncherState) -> Option> { let mut in_use = HashSet::new(); for cluster in clusters { - let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version); - - if cluster.mc_loader == oneclient_common::domain::GameLoader::Vanilla { - in_use.insert(mc_version); - continue; - } - - let resolved = oneclient_mc::get_loader_version_cached( - &metadata, - &mc_version, - cluster.mc_loader, - cluster.mc_loader_version.as_deref(), - ); - - match resolved { - Ok(Some(loader)) => { - in_use.insert(format!("{mc_version}-{}", loader.id)); + match crate::game::natives::cached_version_name(&metadata, &cluster) { + Some(version_name) => { + in_use.insert(version_name); } - Ok(None) | Err(_) => { + None => { tracing::info!( cluster = %cluster.name, loader = %cluster.mc_loader, diff --git a/packages/oneclient_core/src/verify.rs b/packages/oneclient_core/src/verify.rs index fd3ef9de..c7f3dc08 100644 --- a/packages/oneclient_core/src/verify.rs +++ b/packages/oneclient_core/src/verify.rs @@ -25,6 +25,7 @@ pub struct ClusterVerifyReport { pub unrepairable: Vec, /// Game files re-downloaded uncounted because the assets index was gone too pub reinstalled_game_files: bool, + pub natives_in_use_by: Option, } impl ClusterVerifyReport { @@ -38,6 +39,16 @@ impl ClusterVerifyReport { #[must_use] pub fn summary(&self) -> String { + let summary = self.counts_summary(); + match &self.natives_in_use_by { + Some(name) => format!( + "{summary} Native libraries were skipped because {name} is running, verify again after closing it." + ), + None => summary, + } + } + + fn counts_summary(&self) -> String { if self.is_clean() { return format!("All {} files verified.", self.checked); } @@ -155,6 +166,7 @@ async fn run_verify( // Counted apart from content which repairs itself as it goes one bucket // would count the content ones twice let mut game_broken = 0; + let mut natives_broken = 0; // The index lives in the assets directory so its absence usually means that // whole directory is gone hand the work to prepare @@ -174,6 +186,7 @@ async fn run_verify( report.corrupt += game.corrupt; report.missing += game.missing; game_broken = game.corrupt + game.missing; + natives_broken = game.natives_unextracted; } else { tracing::warn!("assets index is missing; re-downloading the game files wholesale"); } @@ -182,9 +195,26 @@ async fn run_verify( // Runs `prepare` directly not the launch path which skips clusters already // marked `Ready` and so never heals one with deleted files - if game_broken > 0 || reinstalled_game_files { + let natives_holder = crate::game::natives::natives_holder(state, &version_info.id, None) + .filter(|_| natives_broken > 0); + let repairable = if natives_holder.is_some() { + game_broken.saturating_sub(natives_broken) + } else { + game_broken + }; + + if repairable > 0 || reinstalled_game_files { prepare_cluster_locked(state, cluster_id, false, true, true, Some(progress)).await?; - report.repaired += game_broken; + report.repaired += repairable; + } + + if let Some(holder) = natives_holder { + let name = state + .clusters + .get(holder) + .await + .map_or_else(|_| "another cluster".to_string(), |cluster| cluster.name); + report.natives_in_use_by = Some(name); } report.reinstalled_game_files = reinstalled_game_files; @@ -358,6 +388,7 @@ mod tests { repaired: 4, unrepairable: Vec::new(), reinstalled_game_files: false, + natives_in_use_by: None, }; assert!(!report.is_clean()); @@ -382,6 +413,20 @@ mod tests { ); } + #[test] + fn skipped_natives_are_named_rather_than_counted_as_repaired() { + let report = ClusterVerifyReport { + checked: 40, + natives_in_use_by: Some("Hypixel".to_string()), + ..Default::default() + }; + + assert_eq!( + report.summary(), + "All 40 files verified. Native libraries were skipped because Hypixel is running, verify again after closing it." + ); + } + #[test] fn content_that_cannot_be_replaced_is_still_reported() { let report = ClusterVerifyReport { diff --git a/packages/oneclient_events/src/event.rs b/packages/oneclient_events/src/event.rs index d118a802..9684dfe9 100644 --- a/packages/oneclient_events/src/event.rs +++ b/packages/oneclient_events/src/event.rs @@ -109,6 +109,8 @@ pub enum GameEvent { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LaunchStage { Checking, + /// Queued behind another cluster's launch + Waiting, Downloading, Launching, Running, @@ -118,7 +120,10 @@ pub enum LaunchStage { impl LaunchStage { #[must_use] pub fn is_busy(self) -> bool { - matches!(self, Self::Checking | Self::Downloading | Self::Launching) + matches!( + self, + Self::Checking | Self::Waiting | Self::Downloading | Self::Launching + ) } } diff --git a/packages/oneclient_mc/src/install.rs b/packages/oneclient_mc/src/install.rs index 1e427f6f..e9bcfe81 100644 --- a/packages/oneclient_mc/src/install.rs +++ b/packages/oneclient_mc/src/install.rs @@ -26,6 +26,12 @@ const ASSET_DOWNLOAD_CONCURRENCY: usize = 32; /// Libraries are larger and fewer less fan-out is needed to saturate the link const LIBRARY_DOWNLOAD_CONCURRENCY: usize = 16; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativesMode { + Extract, + LeaveInPlace, +} + #[derive(Debug, Clone)] pub struct PlannedLibrary { pub library: Library, @@ -167,6 +173,7 @@ pub async fn plan_downloads( java_arch: &str, minecraft_updated: bool, force: bool, + natives_mode: NativesMode, ) -> McResult { let legacy = uses_legacy_assets(&version.assets); let asset_dir = if legacy { @@ -227,7 +234,12 @@ pub async fn plan_downloads( &lib_dir.join(&artifact_path), library_artifact_size(&lib), )); - let natives = force || !natives_extracted(&natives_dir, &lib, &java_arch); + let natives = match natives_mode { + NativesMode::Extract => { + force || !natives_extracted(&natives_dir, &lib, &java_arch) + } + NativesMode::LeaveInPlace => false, + }; if !artifact && !natives { continue; @@ -269,6 +281,7 @@ pub struct VerifyReport { /// Mismatched the manifest and were removed pub corrupt: usize, pub missing: usize, + pub natives_unextracted: usize, } impl VerifyReport { @@ -293,6 +306,7 @@ fn arch_segment(java_arch: &str) -> &'static str { pub struct NativesReport { pub repairable: Vec, pub unsupported: Vec, + pub unextracted: Vec, } impl NativesReport { @@ -377,9 +391,10 @@ pub async fn check_natives( // One scan for the whole sweep the directory is the same for every library let unpacked = any_native_unpacked(&natives_dir).await; - let mut families: Vec<(String, Vec)> = Vec::new(); + let mut families: Vec<(String, Vec<(JarVerdict, bool)>)> = Vec::new(); for lib in host_natives { - let verdict = if native_download(lib, java_arch).is_some() { + let extracted = native_download(lib, java_arch).is_some(); + let verdict = if extracted { if unpacked && natives_extracted(&natives_dir, lib, java_arch) { JarVerdict::Usable } else { @@ -394,17 +409,23 @@ pub async fn check_natives( let family = natives_family(lib); match families.iter_mut().find(|(name, _)| name == &family) { - Some((_, verdicts)) => verdicts.push(verdict), - None => families.push((family, vec![verdict])), + Some((_, verdicts)) => verdicts.push((verdict, extracted)), + None => families.push((family, vec![(verdict, extracted)])), } } let mut report = NativesReport::default(); for (family, verdicts) in families { - if verdicts.iter().any(|v| matches!(v, JarVerdict::Usable)) { + if verdicts.iter().any(|(v, _)| matches!(v, JarVerdict::Usable)) { continue; } - if verdicts.iter().any(|v| matches!(v, JarVerdict::Unreadable)) { + if verdicts.iter().any(|(v, _)| matches!(v, JarVerdict::Unreadable)) { + if verdicts + .iter() + .any(|(v, extracted)| *extracted && matches!(v, JarVerdict::Unreadable)) + { + report.unextracted.push(family.clone()); + } report.repairable.push(family); } else { report.unsupported.push(family); @@ -554,6 +575,7 @@ pub async fn verify_game_files( Err(err) => return Err(err), }; report.missing += natives.repairable.len(); + report.natives_unextracted = natives.unextracted.len(); report.checked += natives.repairable.len() + natives.unsupported.len(); tracing::info!( @@ -577,16 +599,26 @@ pub async fn download_minecraft( java_arch: &str, minecraft_updated: bool, force: bool, + natives_mode: NativesMode, ) -> McResult<()> { let started = std::time::Instant::now(); let asset_index = download_assets_index(ctx, progress, version, force).await?; - let plan = plan_downloads(version, asset_index, java_arch, minecraft_updated, force).await?; + let plan = plan_downloads( + version, + asset_index, + java_arch, + minecraft_updated, + force, + natives_mode, + ) + .await?; tracing::info!( version = %version.id, assets = plan.assets.len(), libraries = plan.libraries.len(), bytes = plan.total_bytes(), + ?natives_mode, "planned minecraft download" );