diff --git a/packages/oneclient_common/src/paths.rs b/packages/oneclient_common/src/paths.rs index 0a725a34..99331506 100644 --- a/packages/oneclient_common/src/paths.rs +++ b/packages/oneclient_common/src/paths.rs @@ -112,10 +112,26 @@ pub fn logs_dir() -> PathsResult { Ok(config_dir()?.join("logs")) } +pub fn skins_dir() -> PathsResult { + Ok(data_dir()?.join("skins")) +} + +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")) } diff --git a/packages/oneclient_core/Cargo.toml b/packages/oneclient_core/Cargo.toml index 08fed796..839bae6a 100644 --- a/packages/oneclient_core/Cargo.toml +++ b/packages/oneclient_core/Cargo.toml @@ -73,6 +73,8 @@ digest.workspace = true md-5.workspace = true sha1.workspace = true sha2.workspace = true +sha1_0_10 = { package = "sha1", version = "0.10", features = ["oid"] } +rsa = { version = "0.9", features = ["sha1", "pem"] } # util base64.workspace = true diff --git a/packages/oneclient_core/assets/authlib-injector.jar b/packages/oneclient_core/assets/authlib-injector.jar new file mode 100644 index 00000000..dc50e5b4 Binary files /dev/null and b/packages/oneclient_core/assets/authlib-injector.jar differ 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 0d646964..497f91d4 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -314,6 +314,22 @@ async fn start( crate::game::link_cluster_logs(&cluster, &cwd).await; } + 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 + && version.id == "1.21.11" + { + 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; + } + } + } + } + let client_jar = paths::versions_dir()? .join(&version_name) .join(format!("{version_name}.jar")); @@ -346,7 +362,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, @@ -357,6 +372,37 @@ async fn start( jvm_args.push(arg); } + // Always clean up legacy jar patches and resource packs to ensure clean vanilla state + crate::game::skin_server::cleanup_legacy_skin_injection(&cwd, &client_jar).await; + + let mut offline_skin_port = None; + + // Attach proper Authlib-Injector offline skin system if running an offline account + if account.is_offline() { + 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 + { + offline_skin_port = Some(port); + let agent_arg = format!( + "-javaagent:{}=http://127.0.0.1:{port}", + injector_jar.display() + ); + tracing::info!(%agent_arg, "attaching authlib-injector for proper offline skin support"); + jvm_args.push(agent_arg); + jvm_args.push("-Dauthlibinjector.noShowServerName=true".to_string()); + jvm_args.push("-Dauthlibinjector.noLogFile=true".to_string()); + } + } + let mut mc_args = arguments::minecraft_arguments( arg_map.get(&ArgumentType::Game).map(Vec::as_slice), version_info.minecraft_arguments.as_deref(), @@ -371,6 +417,19 @@ async fn start( profile.resolution.unwrap_or_default(), &java.os_arch, )?; + + if account.is_offline() + && let Some(port) = offline_skin_port + && let Some(user_props) = + crate::game::skin_server::build_user_properties(&account.id.to_string(), port).await + { + for i in 0..mc_args.len() { + if mc_args[i] == "--userProperties" && i + 1 < mc_args.len() && mc_args[i + 1] == "{}" { + mc_args[i + 1] = user_props; + break; + } + } + } arguments::append_profile_game_arguments( &mut mc_args, profile.force_fullscreen, diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index bc2d44e4..63424f61 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -6,6 +6,7 @@ mod heal; mod launch; mod log_replay; pub(crate) mod natives; +pub mod skin_server; mod process; mod reattach; mod session; diff --git a/packages/oneclient_core/src/game/skin_server.rs b/packages/oneclient_core/src/game/skin_server.rs new file mode 100644 index 00000000..afd07ce6 --- /dev/null +++ b/packages/oneclient_core/src/game/skin_server.rs @@ -0,0 +1,865 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +use base64::Engine; +use oneclient_common::paths::{ + authlib_injector_path, shared_minecraft_dir, skin_file_path, skin_meta_path, skins_dir, +}; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +use rsa::pkcs8::DecodePrivateKey; +use rsa::{Pkcs1v15Sign, RsaPrivateKey}; + +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(); + +pub static SKIN_SERVER_PUBLIC_KEY: &str = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2ZEWcf6IB84Atm2pnE/N\na19ipdSUpXjMYWaloNhP5ygsMB63i1T55RxaMeVoX/CLY1cFI14+BqgeHGYHOLyo\nVoLz1ylC1QvI99Q1fyl0caTD41K11T5l+EGs5gpepGbQ11tRhtTFNpZg016W83t9\nQMMqpVzSETfK65bu5jOeipvf9TAlXNCpRPZBXe12+kJ5H1t3H9NT2KMUpcYPER7p\nIeIMOgiUwCCBqlXVhoKqjTvplKmyaY/qP3zBdLDQ/+mA1i9SOM8529vi452g+ftP\nkUK+AP52KBb4MnmrwasnH9JdzmS8t2Kl17HYORZ6RpHAPlF4DEC7QDEhvJRLvf1N\nbQIDAQAB\n-----END PUBLIC KEY-----"; + +static SKIN_SERVER_PRIVATE_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDZkRZx/ogHzgC2\nbamcT81rX2Kl1JSleMxhZqWg2E/nKCwwHreLVPnlHFox5Whf8ItjVwUjXj4GqB4c\nZgc4vKhWgvPXKULVC8j31DV/KXRxpMPjUrXVPmX4QazmCl6kZtDXW1GG1MU2lmDT\nXpbze31AwyqlXNIRN8rrlu7mM56Km9/1MCVc0KlE9kFd7Xb6QnkfW3cf01PYoxSl\nxg8RHukh4gw6CJTAIIGqVdWGgqqNO+mUqbJpj+o/fMF0sND/6YDWL1I4zznb2+Lj\nnaD5+0+RQr4A/nYoFvgyeavBqycf0l3OZLy3YqXXsdg5FnpGkcA+UXgMQLtAMSG8\nlEu9/U1tAgMBAAECggEACCDgDk08ptH++9HYCuN+YLsVE+3/ybjcJe1wVbSPM6sw\nD3IuWFHJ7lHjWsbf4em6Q3FeW0ZrmdMRIO186pU90tGcq5a6jPweO4gdoY0acR5/\nRRBzg66Ln71QaN3NUGYY+lrKjneHkLUIlA0OJbWg5dkE0F3J6WPEvI2MimQ2UaZU\nkqrGKT7QiAhJSlBSAi4dcDXQbSyFRbCm1hw3goL+hpOfFUVymR0TLFqAl8p0uaei\nnGOt4A3g84xx7zHQx4iUVzanX7mGnKDfEHPB1wohvKkn37vGIocAT43kt/AxJS8p\nzMgy1UyyzIwc6vjuDOqLKqTl6dqNvftzucBHTp8NHwKBgQDtUlYCNsyptDwLXHC7\nmIQd4OJIihZxoOwbPx2gzahpaymyeu3/2w6hA2BlqS1/PFpkhe1igOsYkqUWMyNs\nwA9ob4df9CQhPe787q12YL6LahY73A/Qfgn5+6dW8l9pYPyBgwmUd/NMdkQjZILH\nHTxB8eX1F2IsxAKoQ0g8unQ2OwKBgQDqsLiysueH3gL/u19RksQy9PIVi6dnSuiH\nq93zIKVjKcQmAOrQboo9w8wXbwX3z8WGGbxWNbnIEFYTiKzQD2fviserV1d4Xz9E\nOwIDuc8PdBWbgpjSfIDbBqc6AmE34JLyn1AdX9fkhBjquAy370v4ZdHdG5wnpxSg\nw8qQCy7IdwKBgCA5lOo6DLJigeC9DaW7gP0Zo0BcV83YHxdYC6rhIiQmZAQTQywB\nz8u3TKihP0dOp6uMr/43KTUt/HK2QPIsZis1MbmqyhklcsUvl6hCXL1Li3dXW2Jh\nKvOh40ggIyqI++COLYfWfdf9GyV/KW7mHl+J/EK6iR8xAndco3tzigIvAoGAXBRE\nExCwWJVZld59EnND+T4zcRKe9p7kRr6+0TJA0XxEkiiP+IE2Se91Nsh/je/97pRQ\nWX6wynbmXrmkG+m/fLN1jZsyHW85UlrYen+/Zq/D/oSp0wO4RrcAi3j9jb/Vx82L\n0EqXWPgfEpBtpQkFRIsmYNsBVGlwZXcMFaHdlBcCgYAsBi2KNp3/88XdWxODFpEu\nZ5YjqmhGEAInCYTQOquYHFBjVTrV04I2Zr031Z6iETopm/9nLZ15VMjbZyoZWS3B\nAteqz/MfyDOUN3U4xrBr6nxD/HcpNoWeLXyzGnRya8K+/1vz0hWP/e5Q/5KvCGAo\npmSM49/FvJ7y20c1x1fq1A==\n-----END PRIVATE KEY-----"; + +static SIGNING_KEY: OnceLock = OnceLock::new(); + +pub fn sign_sha1_with_rsa(data: &[u8]) -> Option { + use sha1_0_10::Digest; + let key = SIGNING_KEY.get_or_init(|| { + RsaPrivateKey::from_pkcs8_pem(SKIN_SERVER_PRIVATE_KEY_PEM) + .expect("valid embedded private key") + }); + let mut hasher = sha1_0_10::Sha1::new(); + hasher.update(data); + let digest = hasher.finalize(); + let signing_scheme = Pkcs1v15Sign::new::(); + let sig_bytes = key.sign(signing_scheme, &digest).ok()?; + Some(base64::engine::general_purpose::STANDARD.encode(sig_bytes)) +} + +#[derive(Clone, Debug)] +pub struct AccountRegistration { + pub uuid: String, + pub undashed_uuid: String, + pub username: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct SkinMetadata { + pub is_slim: bool, +} + +/// Registers an active launch account with its UUID and username so the local +/// skin server can immediately resolve textures and profile properties for it. +pub fn register_launch_account(uuid: &str, username: &str) { + let accounts = REGISTERED_ACCOUNTS.get_or_init(|| RwLock::new(HashMap::new())); + let dashed = to_dashed_uuid(uuid); + let undashed = to_undashed_uuid(uuid); + let reg = AccountRegistration { + uuid: dashed.clone(), + undashed_uuid: undashed.clone(), + username: username.to_string(), + }; + let mut map = accounts.write(); + map.insert(dashed, reg.clone()); + map.insert(undashed, reg.clone()); + map.insert(username.to_lowercase(), reg); +} + +/// Ensures the authlib-injector jar is extracted to disk from embedded bytes. +/// Operates 100% offline with zero network dependencies. +pub async fn prepare_authlib_injector() -> Result { + let jar_path = authlib_injector_path().map_err(|e| e.to_string())?; + + let is_valid = if let Ok(meta) = tokio::fs::metadata(&jar_path).await { + meta.len() > 100_000 + } else { + false + }; + + if !is_valid { + if let Some(parent) = jar_path.parent() { + polyio::create_dir_all(parent) + .await + .map_err(|e| e.to_string())?; + } + polyio::write(&jar_path, AUTHLIB_INJECTOR_BYTES) + .await + .map_err(|e| e.to_string())?; + tracing::info!(path = %jar_path.display(), "extracted embedded authlib-injector jar"); + } + + 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() { + return Ok(port); + } + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .map_err(|e| format!("failed to bind local skin server: {e}"))?; + + let port = listener + .local_addr() + .map_err(|e| format!("failed to get listener port: {e}"))? + .port(); + + let _ = SKIN_SERVER_PORT.set(port); + + tokio::spawn(async move { + tracing::info!(port, "local offline skin server listening"); + loop { + match listener.accept().await { + Ok((stream, _)) => { + tokio::spawn(async move { + let _ = handle_connection(stream, port).await; + }); + } + Err(err) => { + tracing::warn!(error = %err, "skin server accept error"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + } + }); + + Ok(port) +} + +async fn handle_connection(mut stream: TcpStream, port: u16) -> Result<(), std::io::Error> { + let mut buffer = [0u8; 4096]; + let n = stream.read(&mut buffer).await?; + if n == 0 { + return Ok(()); + } + + let request_str = String::from_utf8_lossy(&buffer[..n]); + let mut lines = request_str.lines(); + let first_line = match lines.next() { + Some(l) => l, + None => return Ok(()), + }; + + let mut parts = first_line.split_whitespace(); + 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?; + return Ok(()); + } + + let path = uri.split('?').next().unwrap_or(uri); + + // 1. Root / Yggdrasil API metadata + if path == "/" || path == "/api/yggdrasil" || path == "/api/yggdrasil/" { + let body = serde_json::json!({ + "meta": { + "serverName": "OneLauncher-Cracked", + "implementationName": "onelauncher-skin-server", + "implementationVersion": env!("CARGO_PKG_VERSION") + }, + "skinDomains": ["127.0.0.1", "localhost"], + "signaturePublickey": SKIN_SERVER_PUBLIC_KEY + }).to_string(); + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(header.as_bytes()).await?; + return Ok(()); + } + + // 2. Public keys endpoint (for Minecraft 1.19+ profile signature verification) + if path == "/publickeys" || path == "/minecraftservices/publickeys" { + let key_b64 = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2ZEWcf6IB84Atm2pnE/Na19ipdSUpXjMYWaloNhP5ygsMB63i1T55RxaMeVoX/CLY1cFI14+BqgeHGYHOLyoVoLz1ylC1QvI99Q1fyl0caTD41K11T5l+EGs5gpepGbQ11tRhtTFNpZg016W83t9QMMqpVzSETfK65bu5jOeipvf9TAlXNCpRPZBXe12+kJ5H1t3H9NT2KMUpcYPER7pIeIMOgiUwCCBqlXVhoKqjTvplKmyaY/qP3zBdLDQ/+mA1i9SOM8529vi452g+ftPkUK+AP52KBb4MnmrwasnH9JdzmS8t2Kl17HYORZ6RpHAPlF4DEC7QDEhvJRLvf1NbQIDAQAB"; + let body = serde_json::json!({ + "profilePropertyKeys": [{"publicKey": key_b64}], + "playerCertificateKeys": [{"publicKey": key_b64}] + }).to_string(); + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(header.as_bytes()).await?; + return Ok(()); + } + + // 3. Profiles by names endpoint (bulk profile lookup) + if path == "/api/profiles/minecraft" || path == "/api/profiles/minecraft/" { + let mut profiles = Vec::new(); + if let Some(accounts) = REGISTERED_ACCOUNTS.get() { + let read = accounts.read(); + for reg in read.values() { + if !profiles.iter().any(|p: &serde_json::Value| p["id"] == reg.undashed_uuid) { + profiles.push(serde_json::json!({ + "id": reg.undashed_uuid, + "name": reg.username, + })); + } + } + } + let body = serde_json::to_string(&profiles).unwrap_or_else(|_| "[]".to_string()); + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(header.as_bytes()).await?; + return Ok(()); + } + + // 4. Profile request: /sessionserver/session/minecraft/profile/ or /session/minecraft/profile/ + let profile_uuid = path + .strip_prefix("/sessionserver/session/minecraft/profile/") + .or_else(|| path.strip_prefix("/session/minecraft/profile/")); + + if let Some(uuid_raw) = profile_uuid { + let uuid_str = uuid_raw.trim_matches('/'); + + // Check if this is one of our local accounts with a custom skin + if let Some(json_body) = build_profile_response(uuid_str, port).await { + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + json_body.len(), + json_body + ); + stream.write_all(header.as_bytes()).await?; + return Ok(()); + } + + // For other players on the server, check Mojang session server so official players keep their skins + if let Some(mojang_body) = fetch_mojang_profile(uuid_str).await { + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + mojang_body.len(), + mojang_body + ); + stream.write_all(header.as_bytes()).await?; + return Ok(()); + } + + // Offline / unknown player: return 204 No Content -> vanilla Minecraft renders default Steve/Alex + 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?; + return Ok(()); + } + + // 5. Texture request: /textures/.png + if let Some(texture_raw) = path.strip_prefix("/textures/") { + let texture_id = texture_raw.trim_matches('/').trim_end_matches(".png"); + if let Some(png_bytes) = load_skin_png(texture_id).await { + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + png_bytes.len() + ); + stream.write_all(header.as_bytes()).await?; + if method == "GET" { + stream.write_all(&png_bytes).await?; + } + return Ok(()); + } + } + + 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?; + Ok(()) +} + +pub fn to_dashed_uuid(s: &str) -> String { + let clean = s.replace('-', "").to_lowercase(); + if clean.len() == 32 { + format!( + "{}-{}-{}-{}-{}", + &clean[0..8], + &clean[8..12], + &clean[12..16], + &clean[16..20], + &clean[20..32] + ) + } else { + s.to_lowercase() + } +} + +pub fn to_undashed_uuid(s: &str) -> String { + s.replace('-', "").to_lowercase() +} + +async fn find_skin_path(uuid_or_name: &str) -> Option { + let dashed = to_dashed_uuid(uuid_or_name); + let undashed = to_undashed_uuid(uuid_or_name); + + if let Ok(p) = skin_file_path(&dashed) + && p.exists() + { + return Some(p); + } + if let Ok(p) = skin_file_path(&undashed) + && p.exists() + { + return Some(p); + } + if let Ok(p) = skin_file_path(uuid_or_name) + && p.exists() + { + 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() { + return Some(name_path); + } + } + + None +} + +async fn load_skin_png(uuid_or_name: &str) -> Option> { + let path = find_skin_path(uuid_or_name).await?; + polyio::read(&path).await.ok() +} + +async fn resolve_username(uuid_input: &str) -> String { + let dashed = to_dashed_uuid(uuid_input); + let undashed = to_undashed_uuid(uuid_input); + + if let Some(accounts) = REGISTERED_ACCOUNTS.get() { + let read = accounts.read(); + if let Some(reg) = read.get(&dashed).or_else(|| read.get(&undashed)) { + return reg.username.clone(); + } + } + + // Try reading from auth.json + if let Ok(auth_p) = oneclient_common::paths::auth_file() + && let Ok(content) = polyio::read_to_string(&auth_p).await + && let Ok(val) = serde_json::from_str::(&content) + && let Some(users) = val.get("users").and_then(|u| u.as_object()) + { + for (id, user) in users { + if (id == &dashed || id.replace('-', "").to_lowercase() == undashed) + && let Some(name) = user.get("username").and_then(|n| n.as_str()) + { + return name.to_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 undashed = to_undashed_uuid(uuid_input); + + let _skin_path = find_skin_path(uuid_input).await?; + 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"); + + let model_json = if is_slim { + r#","metadata":{"model":"slim"}"# + } else { + "" + }; + + let timestamp = chrono::Utc::now().timestamp_millis(); + let texture_payload = format!( + r#"{{"timestamp":{timestamp},"profileId":"{undashed}","profileName":"{username}","textures":{{"SKIN":{{"url":"{skin_url}"{model_json}}}}}}}"# + ); + + let encoded_textures = + base64::engine::general_purpose::STANDARD.encode(texture_payload.as_bytes()); + + let profile_json = if let Some(sig) = sign_sha1_with_rsa(encoded_textures.as_bytes()) { + format!( + r#"{{"id":"{undashed}","name":"{username}","properties":[{{"name":"textures","value":"{encoded_textures}","signature":"{sig}"}}]}}"# + ) + } else { + format!( + r#"{{"id":"{undashed}","name":"{username}","properties":[{{"name":"textures","value":"{encoded_textures}"}}]}}"# + ) + }; + + 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 undashed = to_undashed_uuid(uuid_input); + + let _skin_path = find_skin_path(uuid_input).await?; + 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"); + + let model_json = if is_slim { + r#","metadata":{"model":"slim"}"# + } else { + "" + }; + + let timestamp = chrono::Utc::now().timestamp_millis(); + let texture_payload = format!( + r#"{{"timestamp":{timestamp},"profileId":"{undashed}","profileName":"{username}","textures":{{"SKIN":{{"url":"{skin_url}"{model_json}}}}}}}"# + ); + + let encoded_textures = + base64::engine::general_purpose::STANDARD.encode(texture_payload.as_bytes()); + + let prop_json = if let Some(sig) = sign_sha1_with_rsa(encoded_textures.as_bytes()) { + format!( + r#"{{"textures":[{{"name":"textures","value":"{encoded_textures}","signature":"{sig}"}}]}}"# + ) + } else { + format!( + r#"{{"textures":[{{"name":"textures","value":"{encoded_textures}"}}]}}"# + ) + }; + + Some(prop_json) +} + +async fn fetch_mojang_profile(uuid_str: &str) -> Option { + let undashed = to_undashed_uuid(uuid_str); + if undashed.len() != 32 { + return None; + } + + let cache = MOJANG_CACHE.get_or_init(|| RwLock::new(HashMap::new())); + { + let read = cache.read(); + if let Some((resp, timestamp)) = read.get(&undashed) + && timestamp.elapsed() < Duration::from_secs(600) + { + return resp.clone(); + } + } + + let client = reqwest::Client::builder() + .timeout(Duration::from_millis(1500)) + .build() + .ok()?; + + let url = format!( + "https://sessionserver.mojang.com/session/minecraft/profile/{undashed}?unsigned=false" + ); + let result = match client.get(&url).send().await { + Ok(res) if res.status() == reqwest::StatusCode::OK => match res.text().await { + Ok(text) if !text.trim().is_empty() => Some(text), + _ => None, + }, + _ => None, + }; + + let mut write = cache.write(); + write.insert(undashed, (result.clone(), Instant::now())); + + result +} + +/// 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("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")), + ]; + + for target in targets { + if let Some(parent) = target.parent() { + let _ = polyio::create_dir_all(parent).await; + } + let _ = polyio::write(&target, &png_bytes).await; + } + + if let Ok(shared) = shared_minecraft_dir() + && shared != game_dir + { + let shared_targets = [ + 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() { + let _ = polyio::create_dir_all(parent).await; + } + let _ = polyio::write(&target, &png_bytes).await; + } + } + } +} + +/// Automatically writes CustomSkinLoader configuration so any modded instance +/// resolves the local custom skin cleanly and fetches other multiplayer skins. +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": "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": "SkinRestorer", + "type": "CustomSkinAPI", + "root": "https://api.skinrestorer.net/v1/profile/{USERNAME}" + } + ] +}"#; + let _ = polyio::create_dir_all(csl_dir).await; + let _ = polyio::write(&config_path, content.as_bytes()).await; +} + +/// Fully cleans up legacy jar patching and resource packs from previous versions. +/// Restores client.jar from pristine backup (.orig) and strips the old skin resource pack +/// from options.txt so no other players are ever rendered with the user's skin! +pub async fn cleanup_legacy_skin_injection(game_dir: &Path, client_jar: &Path) { + // 1. Restore client.jar if a .orig backup exists + let orig_jar = client_jar.with_extension("jar.orig"); + if orig_jar.exists() { + if let Err(e) = tokio::fs::copy(&orig_jar, client_jar).await { + tracing::warn!(error = %e, "failed to restore pristine client jar from backup"); + } else { + tracing::info!(jar = %client_jar.display(), "restored pristine vanilla client jar"); + let _ = tokio::fs::remove_file(&orig_jar).await; + } + } + + // 2. Remove legacy resource pack directories and zip files + let resourcepacks = game_dir.join("resourcepacks"); + let pack_dir = resourcepacks.join("onelauncher_skin"); + let pack_zip = resourcepacks.join("onelauncher_skin.zip"); + + if pack_dir.exists() { + let _ = polyio::remove_dir_all(&pack_dir).await; + } + if pack_zip.exists() { + let _ = polyio::remove_file(&pack_zip).await; + } + + // 3. Remove onelauncher_skin from options.txt + let options_txt = game_dir.join("options.txt"); + if options_txt.exists() { + strip_resource_pack_from_options(&options_txt, "onelauncher_skin").await; + } +} + +async fn strip_resource_pack_from_options(options_txt: &Path, pack_name: &str) { + let file_dir_entry = format!("file/{pack_name}"); + let file_zip_entry = format!("file/{pack_name}.zip"); + + let Ok(content) = polyio::read_to_string(options_txt).await else { + return; + }; + + let mut modified = false; + let mut lines: Vec = content.lines().map(String::from).collect(); + + for line in &mut lines { + if line.starts_with("resourcePacks:") { + let raw = line.trim_start_matches("resourcePacks:").trim(); + if let Ok(mut packs) = serde_json::from_str::>(raw) { + let initial_len = packs.len(); + packs.retain(|p| { + p != &file_dir_entry + && p != &file_zip_entry + && p != &format!("\"{file_dir_entry}\"") + && p != &format!("\"{file_zip_entry}\"") + }); + if packs.len() != initial_len { + modified = true; + *line = format!( + "resourcePacks:{}", + serde_json::to_string(&packs).unwrap_or_else(|_| "[\"vanilla\"]".to_string()) + ); + } + } + } else if line.starts_with("incompatibleResourcePacks:") { + let raw = line.trim_start_matches("incompatibleResourcePacks:").trim(); + if let Ok(mut packs) = serde_json::from_str::>(raw) { + let initial_len = packs.len(); + packs.retain(|p| { + p != &file_dir_entry + && p != &file_zip_entry + && p != &format!("\"{file_dir_entry}\"") + && p != &format!("\"{file_zip_entry}\"") + }); + if packs.len() != initial_len { + modified = true; + *line = format!( + "incompatibleResourcePacks:{}", + serde_json::to_string(&packs).unwrap_or_else(|_| "[]".to_string()) + ); + } + } + } + } + + if modified { + let new_content = lines.join("\n") + "\n"; + let _ = polyio::write(options_txt, new_content.as_bytes()).await; + tracing::info!(options = %options_txt.display(), "cleaned legacy skin pack from options.txt"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_uuid_conversion() { + let dashed = "c4436573-0477-3e11-97b7-6f7ef57f354f"; + let undashed = "c443657304773e1197b76f7ef57f354f"; + + assert_eq!(to_undashed_uuid(dashed), undashed); + assert_eq!(to_dashed_uuid(undashed), dashed); + assert_eq!(to_dashed_uuid(dashed), dashed); + assert_eq!(to_undashed_uuid(undashed), undashed); + } + + #[test] + fn test_register_launch_account() { + register_launch_account("c4436573-0477-3e11-97b7-6f7ef57f354f", "oiupoyt"); + let accounts = REGISTERED_ACCOUNTS.get().unwrap().read(); + assert!(accounts.contains_key("c4436573-0477-3e11-97b7-6f7ef57f354f")); + assert!(accounts.contains_key("c443657304773e1197b76f7ef57f354f")); + assert!(accounts.contains_key("oiupoyt")); + } + + #[tokio::test] + async fn test_prepare_authlib_injector() { + let res = prepare_authlib_injector().await; + assert!(res.is_ok()); + let jar_path = res.unwrap(); + assert!(jar_path.exists()); + let meta = tokio::fs::metadata(&jar_path).await.unwrap(); + assert!(meta.len() > 100_000); + } + + #[test] + fn test_sign_sha1_with_rsa() { + let sig = sign_sha1_with_rsa(b"hello world test"); + assert!(sig.is_some()); + let sig_str = sig.unwrap(); + assert!(!sig_str.is_empty()); + } + + #[tokio::test] + async fn test_skin_server_http_endpoints() { + let port = ensure_skin_server().await.expect("skin server starts"); + let client = reqwest::Client::new(); + + // 1. Test GET / + let res = client + .get(format!("http://127.0.0.1:{port}/")) + .send() + .await + .expect("GET / succeeds"); + assert_eq!(res.status(), reqwest::StatusCode::OK); + let root_json: serde_json::Value = res.json().await.expect("valid JSON"); + assert!(root_json.get("signaturePublickey").is_some()); + assert!(root_json.get("skinDomains").is_some()); + + // 2. Test GET /publickeys + let res = client + .get(format!("http://127.0.0.1:{port}/publickeys")) + .send() + .await + .expect("GET /publickeys succeeds"); + assert_eq!(res.status(), reqwest::StatusCode::OK); + let pk_json: serde_json::Value = res.json().await.expect("valid JSON"); + assert!(pk_json.get("profilePropertyKeys").is_some()); + + // 3. Test GET /sessionserver/session/minecraft/profile/ + // Unknown player must return 204 No Content so other multiplayer players never get user skin! + let res = client + .get(format!("http://127.0.0.1:{port}/sessionserver/session/minecraft/profile/00000000000000000000000000000000")) + .send() + .await + .expect("GET profile succeeds"); + assert_eq!(res.status(), reqwest::StatusCode::NO_CONTENT); + + // 4. Test registered user with a skin + let test_uuid = "12345678-1234-1234-1234-123456789abc"; + let test_undashed = "12345678123412341234123456789abc"; + let test_user = "TestPlayer"; + register_launch_account(test_uuid, test_user); + + if let Ok(dir) = skins_dir() { + let _ = polyio::create_dir_all(&dir).await; + let skin_p = dir.join(format!("{test_uuid}.png")); + // 1x1 png or dummy png + let dummy_png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15c4\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"; + let _ = polyio::write(&skin_p, dummy_png).await; + + let res = client + .get(format!("http://127.0.0.1:{port}/sessionserver/session/minecraft/profile/{test_undashed}")) + .send() + .await + .expect("GET registered profile succeeds"); + assert_eq!(res.status(), reqwest::StatusCode::OK); + let prof: serde_json::Value = res.json().await.expect("valid profile JSON"); + assert_eq!(prof["name"], test_user); + let props = prof["properties"].as_array().expect("properties array"); + assert!(!props.is_empty()); + assert_eq!(props[0]["name"], "textures"); + assert!(props[0]["value"].as_str().is_some()); + assert!(props[0]["signature"].as_str().is_some()); + + // Test GET /textures/.png + let res = client + .get(format!("http://127.0.0.1:{port}/textures/{test_undashed}.png")) + .send() + .await + .expect("GET texture succeeds"); + assert_eq!(res.status(), reqwest::StatusCode::OK); + assert_eq!(res.headers()["content-type"], "image/png"); + + let _ = polyio::remove_file(&skin_p).await; + } + } +}