feat: multiple minecraft instances (#955)

* feat: mutliple minecraft instances (only dedicated clusters)

* changeset
This commit is contained in:
Maciek 2026-10-10 01:24:25 +02:00 • committed by GitHub
parent 011c4416b1
commit 56d73e3ab1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 481 additions and 86 deletions

View file

@ -0,0 +1,5 @@
---
default: minor
---
# Added safe multiple minecraft instances launching, only dedicated clusters tho

View file

@ -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)),
)

View file

@ -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<LaunchBlock> {
pub fn launch_block(&self, cluster_id: ClusterId, shared: &[ClusterId]) -> Option<LaunchBlock> {
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) {

View file

@ -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!(

View file

@ -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<i64> {
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<String> {
if block.cluster_id() == cluster.id {
return None;

View file

@ -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<LaunchBlock> {
pub fn launch_block(
&self,
cluster_id: i64,
parallel: bool,
shared: &[i64],
) -> Option<LaunchBlock> {
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)
);
}
}

View file

@ -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()

View file

@ -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)
}

View file

@ -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?;

View file

@ -45,8 +45,9 @@ pub async fn launch_cluster(
) -> LauncherResult<LaunchedGame> {
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")

View file

@ -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,

View file

@ -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<PathBuf> {
paths::natives_dir().ok().map(|dir| dir.join(version_name))
}
pub(crate) fn cached_version_name(metadata: &MetadataStore, cluster: &Cluster) -> Option<String> {
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<i64>,
) -> Option<i64> {
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<i64>,
) -> 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,
}
}

View file

@ -63,6 +63,8 @@ pub struct GameProcessManager {
inner: Mutex<HashMap<i64, GameProcess>>,
kills: Mutex<HashMap<i64, oneshot::Sender<()>>>,
dirs: Mutex<HashMap<i64, PathBuf>>,
natives: Mutex<HashMap<i64, PathBuf>>,
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<tokio::sync::MutexGuard<'_, ()>> {
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<i64>) -> Option<i64> {
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<i64> {
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<i64> {
@ -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);
}
}

View file

@ -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

View file

@ -47,6 +47,8 @@ pub struct ViewState {
pub sort: Option<String>,
}
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,

View file

@ -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

View file

@ -156,25 +156,11 @@ async fn natives_in_use(state: &LauncherState) -> Option<HashSet<String>> {
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,

View file

@ -25,6 +25,7 @@ pub struct ClusterVerifyReport {
pub unrepairable: Vec<String>,
/// Game files re-downloaded uncounted because the assets index was gone too
pub reinstalled_game_files: bool,
pub natives_in_use_by: Option<String>,
}
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 {

View file

@ -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
)
}
}

View file

@ -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<DownloadPlan> {
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<String>,
pub unsupported: Vec<String>,
pub unextracted: Vec<String>,
}
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<JarVerdict>)> = 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"
);