diff --git a/packages/oneclient_app/src/hooks/queries/skin.rs b/packages/oneclient_app/src/hooks/queries/skin.rs index 0f4cd28a..6c92ff60 100644 --- a/packages/oneclient_app/src/hooks/queries/skin.rs +++ b/packages/oneclient_app/src/hooks/queries/skin.rs @@ -220,22 +220,32 @@ pub async fn delete_account_skin(uuid: &str, username: Option<&str>) -> Result<( async fn sync_custom_skin_loader(username: &str, skin_png: &[u8]) -> Result<(), String> { if let Ok(mc_dir) = shared_minecraft_dir() { - let csl_skins_dir = mc_dir.join("CustomSkinLoader").join("skins"); - let _ = polyio::create_dir_all(&csl_skins_dir).await; - let target = csl_skins_dir.join(format!("{username}.png")); - let _ = polyio::write(&target, skin_png).await; + let cached_dir = mc_dir.join("cachedImages").join("skins"); + let _ = polyio::create_dir_all(&cached_dir).await; + let _ = polyio::write(&cached_dir.join(format!("{username}.png")), skin_png).await; + + let cfg_dir = mc_dir.join("config").join("offlineskins"); + let _ = polyio::create_dir_all(&cfg_dir).await; + let _ = polyio::write(&cfg_dir.join(format!("{username}.png")), skin_png).await; } Ok(()) } async fn remove_custom_skin_loader(username: &str) -> Result<(), String> { if let Ok(mc_dir) = shared_minecraft_dir() { - let target = mc_dir - .join("CustomSkinLoader") + let target1 = mc_dir + .join("cachedImages") .join("skins") .join(format!("{username}.png")); - if target.exists() { - let _ = polyio::remove_file(&target).await; + if target1.exists() { + let _ = polyio::remove_file(&target1).await; + } + let target2 = mc_dir + .join("config") + .join("offlineskins") + .join(format!("{username}.png")); + if target2.exists() { + let _ = polyio::remove_file(&target2).await; } } Ok(()) diff --git a/packages/oneclient_core/assets/offlineskins-fabric.jar b/packages/oneclient_core/assets/offlineskins-fabric.jar new file mode 100644 index 00000000..5ede3706 Binary files /dev/null and b/packages/oneclient_core/assets/offlineskins-fabric.jar differ diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index 31197c86..808b3255 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -267,6 +267,20 @@ async fn start( tracing::warn!(cluster_id, error = %err, "failed to materialize cluster content"); } + let mods_dir = paths::cluster_mods_dir(&cluster.folder_name)?; + + if account.is_offline() { + crate::game::skin_server::disable_incompatible_offline_mods(&[mods_dir.clone(), cwd.join("mods")]).await; + if cluster.mc_loader == oneclient_common::domain::GameLoader::Fabric { + if let Ok(mod_jar) = crate::game::skin_server::prepare_offline_skins_mod().await { + let target_mod = mods_dir.join("offlineskins-fabric.jar"); + if !target_mod.exists() { + let _ = polyio::copy(&mod_jar, &target_mod).await; + } + } + } + } + if !dedicated { crate::game::link_cluster_logs(&cluster, &cwd).await; } @@ -302,7 +316,6 @@ async fn start( java.major, )?; - let mods_dir = paths::cluster_mods_dir(&cluster.folder_name)?; if let Some(arg) = crate::game::mods_folder_argument( cluster.mc_loader, loader_version_id, @@ -323,6 +336,11 @@ async fn start( let account_uuid_str = account.id.to_string(); crate::game::skin_server::register_launch_account(&account_uuid_str, &account.username); crate::game::skin_server::sync_offline_skins(&cwd, &account_uuid_str, &account.username).await; + if let Some(parent) = mods_dir.parent() + && parent != cwd + { + crate::game::skin_server::sync_offline_skins(parent, &account_uuid_str, &account.username).await; + } if let Ok(port) = crate::game::skin_server::ensure_skin_server().await && let Ok(injector_jar) = crate::game::skin_server::prepare_authlib_injector().await @@ -334,13 +352,9 @@ async fn start( ); tracing::info!(%agent_arg, "attaching authlib-injector for proper offline skin support"); jvm_args.push(agent_arg); - jvm_args.push("-Dauthlibinjector.ignoredPackages=org.polyfrost".to_string()); jvm_args.push("-Dauthlibinjector.noShowServerName=true".to_string()); jvm_args.push("-Dauthlibinjector.noLogFile=true".to_string()); } - - crate::game::skin_server::write_custom_skin_loader_config(&cwd.join("CustomSkinLoader")).await; - crate::game::skin_server::write_custom_skin_loader_config(&cwd.join("config").join("CustomSkinLoader")).await; } let mut mc_args = arguments::minecraft_arguments( diff --git a/packages/oneclient_core/src/game/skin_server.rs b/packages/oneclient_core/src/game/skin_server.rs index 6bbe7164..b0cff223 100644 --- a/packages/oneclient_core/src/game/skin_server.rs +++ b/packages/oneclient_core/src/game/skin_server.rs @@ -19,6 +19,7 @@ type MojangCacheEntry = (Option, Instant); type MojangCacheMap = HashMap; static AUTHLIB_INJECTOR_BYTES: &[u8] = include_bytes!("../../assets/authlib-injector.jar"); +static OFFLINE_SKINS_MOD_BYTES: &[u8] = include_bytes!("../../assets/offlineskins-fabric.jar"); static SKIN_SERVER_PORT: OnceLock = OnceLock::new(); static REGISTERED_ACCOUNTS: OnceLock>> = OnceLock::new(); static MOJANG_CACHE: OnceLock> = OnceLock::new(); @@ -98,6 +99,34 @@ pub async fn prepare_authlib_injector() -> Result { Ok(jar_path) } +/// Ensures the offline skins Fabric mod is extracted to disk from embedded bytes. +pub async fn prepare_offline_skins_mod() -> Result { + let metadata = oneclient_common::paths::data_dir() + .map_err(|e| e.to_string())? + .join("metadata"); + let mod_path = metadata.join("offlineskins-fabric.jar"); + + let is_valid = if let Ok(meta) = tokio::fs::metadata(&mod_path).await { + meta.len() > 100_000 + } else { + false + }; + + if !is_valid { + if let Some(parent) = mod_path.parent() { + polyio::create_dir_all(parent) + .await + .map_err(|e| e.to_string())?; + } + polyio::write(&mod_path, OFFLINE_SKINS_MOD_BYTES) + .await + .map_err(|e| e.to_string())?; + tracing::info!(path = %mod_path.display(), "extracted embedded offlineskins mod jar"); + } + + Ok(mod_path) +} + /// Starts the local skin server on a random local port if not already started. pub async fn ensure_skin_server() -> Result { if let Some(&port) = SKIN_SERVER_PORT.get() { @@ -153,6 +182,8 @@ async fn handle_connection(mut stream: TcpStream, port: u16) -> Result<(), std:: let method = parts.next().unwrap_or(""); let uri = parts.next().unwrap_or(""); + tracing::info!(method, uri, "skin_server incoming request"); + if method != "GET" && method != "HEAD" && method != "POST" { let response = "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; stream.write_all(response.as_bytes()).await?; @@ -279,7 +310,7 @@ async fn handle_connection(mut stream: TcpStream, port: u16) -> Result<(), std:: } pub fn to_dashed_uuid(s: &str) -> String { - let clean = s.replace('-', ""); + let clean = s.replace('-', "").to_lowercase(); if clean.len() == 32 { format!( "{}-{}-{}-{}-{}", @@ -290,12 +321,12 @@ pub fn to_dashed_uuid(s: &str) -> String { &clean[20..32] ) } else { - s.to_string() + s.to_lowercase() } } pub fn to_undashed_uuid(s: &str) -> String { - s.replace('-', "") + s.replace('-', "").to_lowercase() } async fn find_skin_path(uuid_or_name: &str) -> Option { @@ -318,6 +349,32 @@ async fn find_skin_path(uuid_or_name: &str) -> Option { return Some(p); } + if let Some(accounts) = REGISTERED_ACCOUNTS.get() { + let read = accounts.read(); + if let Some(reg) = read + .get(&dashed) + .or_else(|| read.get(&undashed)) + .or_else(|| read.get(&uuid_or_name.to_lowercase())) + { + if let Ok(p) = skin_file_path(®.uuid) + && p.exists() + { + return Some(p); + } + if let Ok(p) = skin_file_path(®.undashed_uuid) + && p.exists() + { + return Some(p); + } + if let Ok(dir) = skins_dir() { + let name_path = dir.join(format!("{}.png", reg.username)); + if name_path.exists() { + return Some(name_path); + } + } + } + } + if let Ok(dir) = skins_dir() { let name_path = dir.join(format!("{uuid_or_name}.png")); if name_path.exists() { @@ -351,7 +408,7 @@ async fn resolve_username(uuid_input: &str) -> String { && let Some(users) = val.get("users").and_then(|u| u.as_object()) { for (id, user) in users { - if (id == &dashed || id.replace('-', "") == undashed) + if (id == &dashed || id.replace('-', "").to_lowercase() == undashed) && let Some(name) = user.get("username").and_then(|n| n.as_str()) { return name.to_string(); @@ -362,27 +419,27 @@ async fn resolve_username(uuid_input: &str) -> String { "Player".to_string() } +async fn read_skin_metadata(uuid_or_name: &str) -> bool { + let dashed = to_dashed_uuid(uuid_or_name); + let undashed = to_undashed_uuid(uuid_or_name); + let lower = uuid_or_name.to_lowercase(); + for key in [&dashed, &undashed, &lower] { + if let Ok(meta_p) = skin_meta_path(key) + && meta_p.exists() + && let Ok(s) = polyio::read_to_string(&meta_p).await + && let Ok(m) = serde_json::from_str::(&s) + { + return m.is_slim; + } + } + false +} + async fn build_profile_response(uuid_input: &str, port: u16) -> Option { - let dashed = to_dashed_uuid(uuid_input); let undashed = to_undashed_uuid(uuid_input); let _skin_path = find_skin_path(uuid_input).await?; - - let is_slim = if let Ok(meta_p) = skin_meta_path(&dashed) { - if meta_p.exists() { - polyio::read_to_string(&meta_p) - .await - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - .map(|m| m.is_slim) - .unwrap_or(false) - } else { - false - } - } else { - false - }; - + let is_slim = read_skin_metadata(uuid_input).await; let username = resolve_username(uuid_input).await; let skin_url = format!("http://127.0.0.1:{port}/textures/{undashed}.png"); @@ -410,31 +467,43 @@ async fn build_profile_response(uuid_input: &str, port: u16) -> Option { ) }; + tracing::info!(uuid = %uuid_input, username = %username, is_slim, "built profile response with signed skin"); Some(profile_json) } +/// Disables mods like `secureskins` and `customskinloader` that interfere with offline skin support or cause startup crashes. +pub async fn disable_incompatible_offline_mods(dirs: &[PathBuf]) { + for dir in dirs { + if let Ok(mut entries) = tokio::fs::read_dir(dir).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name().to_string_lossy().to_string(); + let lower = name.to_lowercase(); + if (lower.contains("secureskins") || lower.contains("customskinloader")) && name.ends_with(".jar") { + let path = entry.path(); + let disabled = path.with_extension("jar.disabled"); + tracing::info!(path = %path.display(), "disabling incompatible offline skin mod"); + let _ = tokio::fs::remove_file(&disabled).await; + let _ = tokio::fs::rename(&path, &disabled).await; + } + } + } + } + + // Clean up residual CustomSkinLoader core jars that break modern Fabric / Sponge Mixin + if let Ok(shared) = shared_minecraft_dir() { + let csl_core = shared.join("CustomSkinLoader").join("Core"); + if csl_core.exists() { + let _ = tokio::fs::remove_dir_all(&csl_core).await; + } + } +} + /// Builds the `--userProperties` JSON map for launch arguments with signed textures. pub async fn build_user_properties(uuid_input: &str, port: u16) -> Option { - let dashed = to_dashed_uuid(uuid_input); let undashed = to_undashed_uuid(uuid_input); let _skin_path = find_skin_path(uuid_input).await?; - - let is_slim = if let Ok(meta_p) = skin_meta_path(&dashed) { - if meta_p.exists() { - polyio::read_to_string(&meta_p) - .await - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - .map(|m| m.is_slim) - .unwrap_or(false) - } else { - false - } - } else { - false - }; - + let is_slim = read_skin_metadata(uuid_input).await; let username = resolve_username(uuid_input).await; let skin_url = format!("http://127.0.0.1:{port}/textures/{undashed}.png"); @@ -503,19 +572,20 @@ async fn fetch_mojang_profile(uuid_str: &str) -> Option { result } -/// Synchronizes custom skin to various mod directory layouts (CustomSkinLoader, OfflineSkins, etc.) +/// Synchronizes custom skin to mod directory layouts (OfflineSkins, etc.) pub async fn sync_offline_skins( game_dir: &Path, account_uuid: &str, account_username: &str, ) { if let Some(png_bytes) = load_skin_png(account_uuid).await { + let undashed = to_undashed_uuid(account_uuid); let targets = [ - game_dir.join("CustomSkinLoader").join("skins").join(format!("{account_username}.png")), - game_dir.join("CustomSkinLoader").join("skins").join(format!("{account_uuid}.png")), + game_dir.join("cachedImages").join("skins").join(format!("{account_username}.png")), + game_dir.join("cachedImages").join("skins").join("uuid").join(format!("{account_uuid}.png")), + game_dir.join("cachedImages").join("skins").join("uuid").join(format!("{undashed}.png")), game_dir.join("config").join("offlineskins").join(format!("{account_username}.png")), game_dir.join("config").join("offlineskins").join(format!("{account_uuid}.png")), - game_dir.join("cachedImages").join("skins").join(format!("{account_uuid}.png")), ]; for target in targets { @@ -529,8 +599,11 @@ pub async fn sync_offline_skins( && shared != game_dir { let shared_targets = [ - shared.join("CustomSkinLoader").join("skins").join(format!("{account_username}.png")), + shared.join("cachedImages").join("skins").join(format!("{account_username}.png")), + shared.join("cachedImages").join("skins").join("uuid").join(format!("{account_uuid}.png")), + shared.join("cachedImages").join("skins").join("uuid").join(format!("{undashed}.png")), shared.join("config").join("offlineskins").join(format!("{account_username}.png")), + shared.join("config").join("offlineskins").join(format!("{account_uuid}.png")), ]; for target in shared_targets { if let Some(parent) = target.parent() { @@ -548,20 +621,28 @@ pub async fn write_custom_skin_loader_config(csl_dir: &Path) { let config_path = csl_dir.join("CustomSkinLoader.json"); let content = r#"{ "version": "14.14", + "enableTransparentSkin": true, "loadlist": [ { "name": "LocalSkin", - "type": "CustomSkinLoader", - "root": "CustomSkinLoader/skins/{USERNAME}.png" + "type": "Legacy", + "skin": "LocalSkin/skins/{USERNAME}.png", + "cape": "LocalSkin/capes/{USERNAME}.png", + "elytra": "LocalSkin/elytras/{USERNAME}.png", + "model": "auto" + }, + { + "name": "LocalSkinRoot", + "type": "Legacy", + "skin": "skins/{USERNAME}.png", + "cape": "capes/{USERNAME}.png", + "elytra": "elytras/{USERNAME}.png", + "model": "auto" }, { "name": "Mojang", "type": "Mojang" }, - { - "name": "Ely.by", - "type": "ElyBy" - }, { "name": "SkinRestorer", "type": "CustomSkinAPI",