diff --git a/packages/oneclient_common/src/paths.rs b/packages/oneclient_common/src/paths.rs index 563a2929..f111b47d 100644 --- a/packages/oneclient_common/src/paths.rs +++ b/packages/oneclient_common/src/paths.rs @@ -73,10 +73,6 @@ pub fn java_dir() -> PathsResult { Ok(launcher_dir()?.join("metadata").join("java")) } -pub fn authlib_injector_path() -> PathsResult { - Ok(launcher_dir()?.join("metadata").join("authlib-injector.jar")) -} - pub fn clusters_dir() -> PathsResult { Ok(launcher_dir()?.join("clusters")) } diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index 569a6ac6..faf76193 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -263,7 +263,7 @@ pub async fn launch_cluster( updated, )?; - let mut jvm_args = arguments::java_arguments( + let jvm_args = arguments::java_arguments( updated, arg_map.get(&ArgumentType::Jvm).map(Vec::as_slice), &natives, @@ -276,21 +276,9 @@ pub async fn launch_cluster( java.major, )?; - // Integrate Offline Skin System (Authlib-Injector + Local Skin Server + Mod Folder Sync) - let account_uuid_str = account.id.to_string(); - crate::game::skin_server::sync_offline_skins(&cwd, &account_uuid_str, &account.username).await; - - if let Ok(port) = crate::game::skin_server::ensure_skin_server().await { - if let Ok(injector_jar) = crate::game::skin_server::prepare_authlib_injector().await { - let agent_arg = format!( - "-javaagent:{}={}", - injector_jar.display(), - format!("http://127.0.0.1:{port}") - ); - tracing::info!(%agent_arg, "attaching authlib-injector for offline skin support"); - jvm_args.push(agent_arg); - } - } + // Inject custom skin as a resource pack that overrides the default Steve/Alex texture. + // This is zero-overhead: no agents, no servers — just files on disk that Minecraft reads. + inject_skin_resource_pack(&cwd, &account.id.to_string()).await; let mut mc_args = arguments::minecraft_arguments( updated, @@ -694,3 +682,132 @@ async fn run_hook(hook: Option<&str>, cwd: &Path) { tracing::warn!("hook '{hook}' failed: {err}"); } } + +/// Creates (or updates) a `onelauncher_skin` resource pack in the game directory that +/// overrides `assets/minecraft/textures/entity/player/wide/steve.png` (and the slim variant) +/// with the player's saved custom skin PNG. Also patches `options.txt` to enable the pack. +/// +/// If the player has no custom skin, the pack folder and `options.txt` entry are cleaned up. +async fn inject_skin_resource_pack(game_dir: &Path, uuid: &str) { + const PACK_NAME: &str = "onelauncher_skin"; + + let resourcepacks_dir = game_dir.join("resourcepacks"); + let pack_dir = resourcepacks_dir.join(PACK_NAME); + let textures_wide = pack_dir + .join("assets/minecraft/textures/entity/player/wide"); + let textures_slim = pack_dir + .join("assets/minecraft/textures/entity/player/slim"); + let options_txt = game_dir.join("options.txt"); + + // --- Resolve skin PNG + slim flag --- + let skin_path = match oneclient_common::paths::skin_file_path(uuid) { + Ok(p) => p, + Err(e) => { + tracing::warn!("could not resolve skin path for {uuid}: {e}"); + return; + } + }; + + if !skin_path.exists() { + // No custom skin — remove the pack dir and strip from options.txt so Minecraft + // falls back to its built-in default Steve/Alex. + if pack_dir.exists() { + let _ = polyio::remove_dir_all(&pack_dir).await; + patch_resource_packs_option(&options_txt, PACK_NAME, false).await; + } + return; + } + + let skin_bytes = match polyio::read(&skin_path).await { + Ok(b) => b, + Err(e) => { + tracing::warn!("failed to read custom skin: {e}"); + return; + } + }; + + let meta_path = match oneclient_common::paths::skin_meta_path(uuid) { + Ok(p) => p, + Err(_) => return, + }; + + #[derive(serde::Deserialize)] + struct SkinMeta { is_slim: bool } + let is_slim = polyio::read_to_string(&meta_path) + .await + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .map(|m| m.is_slim) + .unwrap_or(false); + + // --- Write resource pack --- + let _ = polyio::create_dir_all(&textures_wide).await; + let _ = polyio::create_dir_all(&textures_slim).await; + + // Write to both models so the skin works regardless of the UUID-based default. + let wide_png = textures_wide.join("steve.png"); + let slim_png = textures_slim.join("alex.png"); + if let Err(e) = polyio::write(&wide_png, &skin_bytes).await { + tracing::warn!("failed to write wide skin texture: {e}"); + return; + } + if let Err(e) = polyio::write(&slim_png, &skin_bytes).await { + tracing::warn!("failed to write slim skin texture: {e}"); + return; + } + + // pack.mcmeta — supported_formats covers all known MC releases + let mcmeta = r#"{"pack":{"pack_format":34,"description":"OneLauncher custom skin","supported_formats":{"min_inclusive":1,"max_inclusive":9999}}}"#; + let _ = polyio::write(pack_dir.join("pack.mcmeta"), mcmeta.as_bytes()).await; + + let _ = is_slim; // logged below; both textures are written regardless + tracing::debug!(uuid, is_slim, "skin resource pack written"); + + // --- Patch options.txt --- + patch_resource_packs_option(&options_txt, PACK_NAME, true).await; +} + +/// Reads `options.txt`, adds or removes `"file/"` from the `resourcePacks` list, +/// and writes it back. Safe to call even if `options.txt` does not yet exist. +async fn patch_resource_packs_option(options_txt: &Path, pack_name: &str, enable: bool) { + let file_entry = format!("\"file/{pack_name}\""); + + let content = polyio::read_to_string(options_txt) + .await + .unwrap_or_default(); + + let mut lines: Vec = content.lines().map(String::from).collect(); + + // Find existing resourcePacks line or remember we need to add one. + let mut found = false; + for line in &mut lines { + if !line.starts_with("resourcePacks:") { + continue; + } + found = true; + + // Parse the JSON array value after the colon. + let raw = line.trim_start_matches("resourcePacks:").trim(); + let mut packs: Vec = serde_json::from_str(raw).unwrap_or_default(); + + packs.retain(|p| p != &file_entry); + if enable { + packs.push(file_entry.clone()); + } + + *line = format!( + "resourcePacks:{}", + serde_json::to_string(&packs).unwrap_or_else(|_| "[]".to_string()) + ); + break; + } + + if !found && enable { + lines.push(format!("resourcePacks:[{file_entry}]")); + } + + let new_content = lines.join("\n") + "\n"; + if let Err(e) = polyio::write(options_txt, new_content.as_bytes()).await { + tracing::warn!("failed to patch options.txt: {e}"); + } +} diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index b1163904..77b3917b 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -6,7 +6,6 @@ mod process; mod reattach; mod session; mod shared_dir; -pub mod skin_server; mod tail; pub use analytics::{ diff --git a/packages/oneclient_core/src/game/skin_server.rs b/packages/oneclient_core/src/game/skin_server.rs deleted file mode 100644 index 144dcdb7..00000000 --- a/packages/oneclient_core/src/game/skin_server.rs +++ /dev/null @@ -1,314 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::sync::OnceLock; -use base64::Engine; -use oneclient_common::paths::{authlib_injector_path, shared_minecraft_dir, skin_file_path, skin_meta_path, skins_dir}; -use serde::{Deserialize, Serialize}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::{TcpListener, TcpStream}; - -const AUTHLIB_INJECTOR_URL: &str = - "https://github.com/yushijinhun/authlib-injector/releases/download/v1.2.5/authlib-injector-1.2.5.jar"; - -static SKIN_SERVER_PORT: OnceLock = OnceLock::new(); - -#[derive(Serialize, Deserialize, Debug, Clone)] -struct SkinMetadata { - pub is_slim: bool, -} - -/// Prepares the authlib-injector jar, downloading it if not present. -pub async fn prepare_authlib_injector() -> Result { - let jar_path = authlib_injector_path().map_err(|e| e.to_string())?; - if jar_path.exists() && jar_path.metadata().map(|m| m.len() > 10000).unwrap_or(false) { - return Ok(jar_path); - } - - if let Some(parent) = jar_path.parent() { - polyio::create_dir_all(parent) - .await - .map_err(|e| e.to_string())?; - } - - let client = reqwest::Client::builder() - .user_agent("OneLauncher-Cracked/2.2.3") - .build() - .map_err(|e| e.to_string())?; - - tracing::info!("downloading authlib-injector for offline skin support"); - let resp = client - .get(AUTHLIB_INJECTOR_URL) - .send() - .await - .map_err(|e| format!("Failed to download authlib-injector: {e}"))?; - - if !resp.status().is_success() { - return Err(format!( - "Failed to download authlib-injector: HTTP status {}", - resp.status() - )); - } - - let bytes = resp - .bytes() - .await - .map_err(|e| format!("Failed to read authlib-injector bytes: {e}"))?; - - polyio::write(&jar_path, &bytes) - .await - .map_err(|e| format!("Failed to write authlib-injector: {e}"))?; - - tracing::info!(path = %jar_path.display(), "authlib-injector ready"); - Ok(jar_path) -} - -/// Ensures the local skin server is running in the background and returns its bound port. -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 listener: {e}"))?; - - let port = listener - .local_addr() - .map_err(|e| e.to_string())? - .port(); - - let _ = SKIN_SERVER_PORT.set(port); - - tokio::spawn(async move { - tracing::info!(port, "local 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(std::time::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(""); - - if method != "GET" && method != "HEAD" { - 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); - - if path == "/" || path == "/api/yggdrasil" || path == "/api/yggdrasil/" { - let body = format!( - r#"{{"meta":{{"serverName":"OneLauncher-Cracked","implementationName":"onelauncher-skin-server","implementationVersion":"2.2.3"}},"skinDomains":["127.0.0.1","localhost"]}}"# - ); - let header = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - stream.write_all(header.as_bytes()).await?; - return Ok(()); - } - - if let Some(uuid_raw) = path.strip_prefix("/session/minecraft/profile/") { - let uuid_str = uuid_raw.trim_matches('/'); - 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\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - json_body.len(), - json_body - ); - stream.write_all(header.as_bytes()).await?; - } else { - 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(()); - } - - 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?; - } - } else { - let response = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; - stream.write_all(response.as_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(()) -} - -fn to_dashed_uuid(s: &str) -> String { - let clean = s.replace('-', ""); - if clean.len() == 32 { - format!( - "{}-{}-{}-{}-{}", - &clean[0..8], - &clean[8..12], - &clean[12..16], - &clean[16..20], - &clean[20..32] - ) - } else { - s.to_string() - } -} - -fn to_undashed_uuid(s: &str) -> String { - s.replace('-', "") -} - -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) { - if p.exists() { - return Some(p); - } - } - if let Ok(p) = skin_file_path(&undashed) { - if p.exists() { - return Some(p); - } - } - if let Ok(p) = skin_file_path(uuid_or_name) { - if p.exists() { - return Some(p); - } - } - - // Fallback: check if username matched - 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 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 skin_url = format!("http://127.0.0.1:{port}/textures/{undashed}.png"); - - let model_json = if is_slim { - r#","metadata":{"model":"slim"}"# - } else { - "" - }; - - let texture_payload = format!( - r#"{{"timestamp":1700000000000,"profileId":"{undashed}","profileName":"Player","textures":{{"SKIN":{{"url":"{skin_url}"{model_json}}}}}}}"# - ); - - let encoded_textures = - base64::engine::general_purpose::STANDARD.encode(texture_payload.as_bytes()); - - let profile_json = format!( - r#"{{"id":"{undashed}","name":"Player","properties":[{{"name":"textures","value":"{encoded_textures}"}}]}}"# - ); - - Some(profile_json) -} - -/// Synchronizes the custom skin to various mod directory layouts (CustomSkinLoader, 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 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("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 { - 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() { - let shared_targets = [ - shared.join("CustomSkinLoader").join("skins").join(format!("{account_username}.png")), - shared.join("config").join("offlineskins").join(format!("{account_username}.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; - } - } - } -}