use directories::{BaseDirs, ProjectDirs}; use std::path::{Path, PathBuf}; use std::sync::OnceLock; use crate::domain::{ContentType, ProviderId}; use crate::error::{PathsError, PathsResult}; const QUALIFIER: &str = "org"; const ORGANIZATION: &str = "Polyfrost"; #[cfg(not(debug_assertions))] const APPLICATION: &str = "OneClient"; #[cfg(debug_assertions)] const APPLICATION: &str = "OneClient-dev"; pub const SETTINGS_FILE: &str = "settings.json"; pub const DATABASE_FILE: &str = "user_data.db"; static DEFAULT_DIR: OnceLock = OnceLock::new(); static CONFIG_DIR_OVERRIDE: OnceLock = OnceLock::new(); static DATA_DIR_OVERRIDE: OnceLock = OnceLock::new(); pub fn set_launcher_dir(dir: PathBuf) { let _ = CONFIG_DIR_OVERRIDE.set(dir.clone()); let _ = DATA_DIR_OVERRIDE.set(dir); } pub fn set_data_dir(dir: PathBuf) { let _ = DATA_DIR_OVERRIDE.set(dir); } fn organization_dir() -> PathsResult { BaseDirs::new() .map(|dirs| dirs.data_dir().join(ORGANIZATION)) .ok_or(PathsError::DataDirUnavailable) } #[must_use] pub fn legacy_dir() -> Option { ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION) .map(|dirs| dirs.data_local_dir().to_path_buf()) } fn resolve_default_dir() -> PathsResult { if let Some(legacy) = legacy_dir() && legacy.join(SETTINGS_FILE).is_file() { return Ok(legacy); } standard_dir() } pub fn standard_dir() -> PathsResult { Ok(organization_dir()?.join(APPLICATION)) } fn default_dir() -> PathsResult<&'static Path> { if let Some(dir) = DEFAULT_DIR.get() { return Ok(dir); } let _ = DEFAULT_DIR.set(resolve_default_dir()?); DEFAULT_DIR .get() .map(PathBuf::as_path) .ok_or(PathsError::DataDirUnavailable) } pub fn config_dir() -> PathsResult<&'static Path> { if let Some(dir) = CONFIG_DIR_OVERRIDE.get() { return Ok(dir); } default_dir() } pub fn data_dir() -> PathsResult<&'static Path> { if let Some(dir) = DATA_DIR_OVERRIDE.get() { return Ok(dir); } config_dir() } #[must_use] pub fn picker_start_dir() -> Option { organization_dir() .ok() .filter(|dir| dir.is_dir()) .or_else(|| data_dir().ok().map(Path::to_path_buf)) } pub fn database_file() -> PathsResult { Ok(data_dir()?.join(DATABASE_FILE)) } pub fn settings_file() -> PathsResult { Ok(config_dir()?.join(SETTINGS_FILE)) } pub fn damaged_settings_file() -> PathsResult { Ok(config_dir()?.join("settings.json.corrupt")) } pub fn auth_file() -> PathsResult { Ok(config_dir()?.join("auth.json")) } pub fn logs_dir() -> PathsResult { Ok(config_dir()?.join("logs")) } pub fn skins_dir() -> PathsResult { Ok(data_dir()?.join("skins")) } /// Returns all candidate skins directories across current data dir, release, dev, and legacy paths pub fn all_skins_dirs() -> Vec { let mut dirs = Vec::new(); if let Ok(dir) = skins_dir() { dirs.push(dir); } if let Some(base) = BaseDirs::new() { let data = base.data_dir(); for candidate in [ data.join("Polyfrost").join("OneClient").join("skins"), data.join("oneclient-dev").join("skins"), data.join("oneclient").join("skins"), ] { if !dirs.contains(&candidate) { dirs.push(candidate); } } } dirs } pub fn skin_file_path(uuid: &str) -> PathsResult { Ok(skins_dir()?.join(format!("{uuid}.png"))) } pub fn skin_meta_path(uuid: &str) -> PathsResult { Ok(skins_dir()?.join(format!("{uuid}.json"))) } pub fn java_dir() -> PathsResult { Ok(data_dir()?.join("metadata").join("java")) } pub fn authlib_injector_path() -> PathsResult { Ok(data_dir()?.join("metadata").join("authlib-injector.jar")) } pub fn clusters_dir() -> PathsResult { Ok(data_dir()?.join("clusters")) } pub fn cluster_trash_dir() -> PathsResult { Ok(data_dir()?.join(".trash")) } pub fn shared_minecraft_dir() -> PathsResult { Ok(data_dir()?.join(".minecraft")) } /// Presence marks a cluster folder as its own game dir instead of the shared `.minecraft` pub const DEDICATED_MARKER: &str = ".dedicated_directory"; pub const INSTANCE_FILE: &str = ".instance.json"; pub fn cluster_dir(folder_name: &str) -> PathsResult { Ok(clusters_dir()?.join(folder_name)) } pub fn cluster_uses_dedicated_dir(folder_name: &str) -> bool { cluster_dir(folder_name).is_ok_and(|dir| dir.join(DEDICATED_MARKER).exists()) } /// Lives here not on `Cluster` so the content layer can resolve it from a bare /// `ClusterRow` without duplicating the marker-file rule pub fn cluster_game_dir(folder_name: &str, isolated: bool) -> PathsResult { if isolated || cluster_uses_dedicated_dir(folder_name) { cluster_dir(folder_name) } else { shared_minecraft_dir() } } pub fn cluster_mods_dir(folder_name: &str) -> PathsResult { Ok(cluster_dir(folder_name)?.join(ContentType::Mod.folder_name())) } pub fn shared_mods_dir() -> PathsResult { Ok(shared_minecraft_dir()?.join(ContentType::Mod.folder_name())) } pub fn global_content_dir(content_type: ContentType) -> PathsResult { Ok(shared_minecraft_dir()?.join(content_type.folder_name())) } pub fn shared_mods_link(folder_name: &str) -> PathsResult { Ok(shared_mods_dir()?.join(folder_name)) } pub fn packages_cache_dir() -> PathsResult { Ok(data_dir()?.join("metadata").join("packages")) } pub fn caches_dir() -> PathsResult { Ok(data_dir()?.join("metadata").join("caches")) } pub fn bundles_dir() -> PathsResult { Ok(caches_dir()?.join("bundles")) } pub fn images_cache_dir() -> PathsResult { Ok(caches_dir()?.join("images")) } pub const LOCAL_IMAGE_SCHEME: &str = "local:"; pub fn local_icons_dir() -> PathsResult { Ok(images_cache_dir()?.join("local")) } pub fn local_image_path(reference: &str) -> Option { let name = reference.strip_prefix(LOCAL_IMAGE_SCHEME)?; if name.is_empty() || name.contains(['/', '\\']) || name.contains("..") { return None; } Some(local_icons_dir().ok()?.join(name)) } pub fn profiles_cache_dir() -> PathsResult { Ok(caches_dir()?.join("profiles")) } pub fn versions_dir() -> PathsResult { Ok(data_dir()?.join("metadata").join("versions")) } pub fn metadata_dir() -> PathsResult { Ok(data_dir()?.join("metadata")) } pub fn libraries_dir() -> PathsResult { Ok(metadata_dir()?.join("libraries")) } pub fn natives_dir() -> PathsResult { Ok(data_dir()?.join("metadata").join("natives")) } pub fn assets_dir() -> PathsResult { Ok(data_dir()?.join("metadata").join("assets")) } pub fn assets_index_dir() -> PathsResult { Ok(assets_dir()?.join("indexes")) } pub fn assets_object_dir() -> PathsResult { Ok(assets_dir()?.join("objects")) } pub fn legacy_assets_dir() -> PathsResult { Ok(data_dir()?.join("metadata").join("resources")) } pub fn package_version_dir( content_type: ContentType, provider: ProviderId, project_id: &str, version_id: &str, ) -> PathsResult { Ok(packages_cache_dir()? .join(content_type.folder_name()) .join(provider.dir_name()) .join(project_id) .join(version_id)) } #[cfg(test)] mod tests { use super::*; use std::ffi::OsStr; #[test] fn the_launcher_sits_one_level_under_the_organization_folder() { let organization = organization_dir().expect("a home directory"); assert_eq!(organization.file_name(), Some(OsStr::new(ORGANIZATION))); let app = organization.join(APPLICATION); let tail: Vec<&OsStr> = app.iter().rev().take(2).collect(); assert_eq!( tail, vec![OsStr::new(APPLICATION), OsStr::new(ORGANIZATION)], "every Polyfrost product shares one folder and takes a single name inside it" ); } #[cfg(windows)] #[test] fn a_windows_install_roams_with_the_user() { let organization = organization_dir().expect("a home directory"); assert!( organization .iter() .any(|part| part == OsStr::new("Roaming")), "settings and saves follow the user between machines; Local would strand them" ); } #[test] fn a_debug_build_never_shares_a_folder_with_a_release_one() { assert_eq!( APPLICATION, if cfg!(debug_assertions) { "OneClient-dev" } else { "OneClient" } ); } }