Shared .minecraft folder

This commit is contained in:
Julian Chang 2026-07-06 04:39:50 -04:00
parent 700b45dd6b
commit a645388a40
No known key found for this signature in database
15 changed files with 234 additions and 13 deletions

View file

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

View file

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

View file

@ -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<PathBuf> {
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<PathBuf> {
if self.uses_dedicated_dir() {
self.dir()
} else {
paths::shared_minecraft_dir()
}
}
pub fn as_link_target(&self) -> ClusterLinkTarget<'_> {
ClusterLinkTarget {
id: self.id,

View file

@ -122,6 +122,33 @@ impl ClusterManager {
Ok(Cluster::try_from_row(row)?)
}
pub async fn uses_dedicated_dir(
state: &LauncherState,
cluster_id: ClusterId,
) -> LauncherResult<bool> {
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,

View file

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

View file

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

View file

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

View file

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

View file

@ -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<HashMap<i64, GameProcess>>,
kills: Mutex<HashMap<i64, oneshot::Sender<()>>>,
dirs: Mutex<HashMap<i64, PathBuf>>,
}
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<i64> {
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 {

View file

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

View file

@ -67,7 +67,7 @@ fn collect_dir(
pub fn list_cluster_logs(cluster: &Cluster) -> LauncherResult<Vec<LogFileInfo>> {
let mut out = Vec::new();
let dir = cluster.dir()?;
let dir = cluster.game_dir()?;
collect_dir(
&dir.join("logs"),

View file

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

View file

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

View file

@ -60,6 +60,10 @@ pub fn clusters_dir() -> LauncherResult<PathBuf> {
Ok(launcher_dir()?.join("clusters"))
}
pub fn shared_minecraft_dir() -> LauncherResult<PathBuf> {
Ok(launcher_dir()?.join("minecraft"))
}
pub fn packages_cache_dir() -> LauncherResult<PathBuf> {
Ok(launcher_dir()?.join("metadata").join("packages"))
}

View file

@ -34,16 +34,18 @@ fn is_image(name: &str) -> bool {
fn ensure_in_clusters(path: &Path) -> LauncherResult<PathBuf> {
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<Vec<ScreenshotInfo>> {
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 {