diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d21b5f79..d449c48d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ on: tag: description: 'Release Tag (e.g. v2.5.1)' required: false - default: 'v2.5.1-c1' + default: 'v2.5.1-c2' type: string permissions: @@ -138,7 +138,7 @@ jobs: elif [ -n "${{ inputs.tag }}" ]; then echo "tag=${{ inputs.tag }}" >> $GITHUB_OUTPUT else - echo "tag=v2.5.1-c1" >> $GITHUB_OUTPUT + echo "tag=v2.5.1-c2" >> $GITHUB_OUTPUT fi - name: Clean up previous release if exists @@ -155,12 +155,12 @@ jobs: name: "OneLauncher-Cracked ${{ steps.release_tag.outputs.tag }}" body: | ### What's New in ${{ steps.release_tag.outputs.tag }} - - **Rebased onto Official Upstream 2.5.1**: Includes all upstream 2.5.1 updates (Process hook error handling and execution reporting, optimized bundled packages sync after launch, auto-refresh of stale package metadata, asset optimizations). - - **Guaranteed Offline Game Launch**: Completely eliminated the upstream check in `account_for_launch` that blocked offline accounts from launching without Microsoft accounts. Offline accounts can launch directly with 0 Microsoft accounts required. - - **Custom Offline Skins & Targeted Multiplayer Injection**: In-launcher 3D skin changer, slim/classic model toggle, per-account Java UUID hash targeting preventing multiplayer skin cloning, and automatic CustomSkinLoader config generation. - - **Offline / Cracked Accounts**: Full offline account support in onboarding and settings with inline username editing. - - **Integrated Auto-Updater**: Native background update checks and direct GitHub Releases binary downloads. - - **UI Bug Fixes**: Fixed filter popover blur/text ghosting in the package manager. + - **Proper Isolated Offline Skin System (Authlib-Injector)**: Replaced legacy jar/pack character replacement with an embedded, zero-network-dependency Authlib-Injector session provider. Your skin is attached strictly and exclusively to your player profile, completely eliminating the bug where other players or NPCs on offline servers would receive your skin. + - **Vanilla & Multi-server Integrity**: Other offline players remain authentic vanilla Steve/Alex, while online players on hybrid servers display their legitimate Mojang skins via transparent caching session proxying. + - **Automatic Legacy Cleanup**: Restores pristine `client.jar` and removes stale skin resource packs from `options.txt` on launch. + - **Guaranteed Offline Game Launch**: Direct offline launching with 0 Microsoft accounts required. + - **In-Launcher 3D Skin Changer**: Full 3D skin preview, slim/classic arm model toggle, local file import, and online skin downloads. + - **Rebased on Upstream 2.5.1**: All upstream 2.5.1 features, hook error reporting, and metadata self-healing. draft: false prerelease: false generate_release_notes: false diff --git a/Cargo.toml b/Cargo.toml index 6f00f40b..16751442 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ license = "GPL-3.0-only" readme = "README.md" repository = "https://github.com/oiupoyt/OneLauncher-Cracked" rust-version = "1.97" -version = "2.5.1-c1" +version = "2.5.1-c2" [workspace.dependencies] # core diff --git a/packages/oneclient_common/src/paths.rs b/packages/oneclient_common/src/paths.rs index d537982f..2da602e7 100644 --- a/packages/oneclient_common/src/paths.rs +++ b/packages/oneclient_common/src/paths.rs @@ -122,6 +122,10 @@ 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/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/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index 96059bfa..0bf05a09 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -313,8 +313,32 @@ async fn start( jvm_args.push(arg); } - // Replace default Minecraft skin textures inside client jar, write resource packs, and sync mod folders - inject_skin(&cwd, &client_jar, &account.id.to_string(), &account.username).await; + // 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; + + // 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 Ok(port) = crate::game::skin_server::ensure_skin_server().await + && let Ok(injector_jar) = crate::game::skin_server::prepare_authlib_injector().await + { + 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.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( updated, @@ -812,462 +836,3 @@ fn stderr_detail(bytes: &[u8]) -> Option { clipped.push('…'); Some(clipped) } -/// Computes Java's exact 32-bit signed integer `uuid.hashCode()`. -fn java_uuid_hash(uuid: &uuid::Uuid) -> i32 { - let (most_sig, least_sig) = uuid.as_u64_pair(); - let hilo = most_sig ^ least_sig; - ((hilo >> 32) as i32) ^ (hilo as i32) -} - -/// Computes the exact Minecraft character slot(s) for the player's offline UUID. -/// Matches vanilla Minecraft's `DefaultPlayerSkin.getDefaultSkin(uuid)` in both -/// legacy (1.8-1.19.2) and modern (1.19.3+) versions, ensuring only the player's -/// exact slot is patched while leaving other default character slots untouched in multiplayer. -fn target_skin_paths_for_account(uuid_str: &str, username: &str) -> (Vec, String, String) { - let offline_uuid = if let Ok(u) = uuid::Uuid::parse_str(uuid_str) { - u - } else { - oneclient_auth::offline_uuid(username) - }; - - let hash = java_uuid_hash(&offline_uuid); - - // 1. Legacy Minecraft 1.8 - 1.19.2: ((hash & 1) != 0) ? alex : steve - let is_legacy_alex = (hash & 1) != 0; - let legacy_char = if is_legacy_alex { "alex.png" } else { "steve.png" }; - - // 2. Modern Minecraft 1.20.2+ (9 default skins) - let modern_9 = match hash.rem_euclid(9) { - 0 => ("wide", "steve.png"), - 1 => ("slim", "alex.png"), - 2 => ("slim", "ari.png"), - 3 => ("wide", "efe.png"), - 4 => ("wide", "kai.png"), - 5 => ("wide", "makena.png"), - 6 => ("slim", "noor.png"), - 7 => ("wide", "sunny.png"), - _ => ("slim", "zuri.png"), - }; - - // 3. Modern Minecraft 1.19.3 - 1.20.1 (8 default skins) - let modern_8 = match hash.rem_euclid(8) { - 0 => ("wide", "steve.png"), - 1 => ("slim", "alex.png"), - 2 => ("slim", "ari.png"), - 3 => ("wide", "efe.png"), - 4 => ("wide", "kai.png"), - 5 => ("wide", "makena.png"), - 6 => ("slim", "noor.png"), - _ => ("wide", "sunny.png"), - }; - - let mut paths = std::collections::HashSet::new(); - - // Legacy paths - paths.insert(format!("assets/minecraft/textures/entity/{legacy_char}")); - paths.insert(format!("assets/minecraft/textures/entity/player/{legacy_char}")); - - // Modern 9-character paths - paths.insert(format!("assets/minecraft/textures/entity/player/{}/{}", modern_9.0, modern_9.1)); - paths.insert(format!("assets/minecraft/textures/entity/player/{}", modern_9.1)); - - // Modern 8-character paths - paths.insert(format!("assets/minecraft/textures/entity/player/{}/{}", modern_8.0, modern_8.1)); - paths.insert(format!("assets/minecraft/textures/entity/player/{}", modern_8.1)); - - let path_list: Vec = paths.into_iter().collect(); - (path_list, modern_9.1.to_string(), legacy_char.to_string()) -} - -/// Replaces the default character textures inside the Minecraft client `.jar`, -/// creates targeted resource packs, and syncs custom skins and configs to mod directories. -async fn inject_skin(game_dir: &Path, client_jar: &Path, uuid: &str, username: &str) { - const PACK_NAME: &str = "onelauncher_skin"; - - let resourcepacks_dir = game_dir.join("resourcepacks"); - let pack_dir = resourcepacks_dir.join(PACK_NAME); - let pack_zip = resourcepacks_dir.join(format!("{PACK_NAME}.zip")); - let options_txt = game_dir.join("options.txt"); - - // --- Resolve skin PNG --- - 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; - } - }; - - let undashed = uuid.replace('-', ""); - let alt_skin_path = oneclient_common::paths::skin_file_path(&undashed).unwrap_or_default(); - - let resolved_skin_path = if skin_path.exists() { - Some(skin_path) - } else if alt_skin_path.exists() { - Some(alt_skin_path) - } else { - None - }; - - let Some(valid_skin_path) = resolved_skin_path else { - // No custom skin — restore untouched client jar, remove resource pack files, and clean options.txt - patch_client_jar_skins(client_jar, None, &[]).await; - if pack_dir.exists() { - let _ = polyio::remove_dir_all(&pack_dir).await; - } - if pack_zip.exists() { - let _ = polyio::remove_file(&pack_zip).await; - } - patch_resource_packs_option(&options_txt, PACK_NAME, false).await; - return; - }; - - let skin_bytes = match polyio::read(&valid_skin_path).await { - Ok(b) => b, - Err(e) => { - tracing::warn!("failed to read custom skin: {e}"); - return; - } - }; - - let (target_paths, modern_char, legacy_char) = target_skin_paths_for_account(uuid, username); - - // 1. Directly patch the client jar for this player's exact character slot - patch_client_jar_skins(client_jar, Some(&skin_bytes), &target_paths).await; - - // 2. Clean previous resource pack directory to avoid stale files - if pack_dir.exists() { - let _ = polyio::remove_dir_all(&pack_dir).await; - } - - // Write Resource Pack directory structure - 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 textures_player = pack_dir.join("assets/minecraft/textures/entity/player"); - let textures_entity = pack_dir.join("assets/minecraft/textures/entity"); - - let _ = polyio::create_dir_all(&textures_wide).await; - let _ = polyio::create_dir_all(&textures_slim).await; - let _ = polyio::create_dir_all(&textures_player).await; - let _ = polyio::create_dir_all(&textures_entity).await; - - let _ = polyio::write(textures_wide.join(&modern_char), &skin_bytes).await; - let _ = polyio::write(textures_slim.join(&modern_char), &skin_bytes).await; - let _ = polyio::write(textures_player.join(&modern_char), &skin_bytes).await; - let _ = polyio::write(textures_player.join(&legacy_char), &skin_bytes).await; - let _ = polyio::write(textures_entity.join(&legacy_char), &skin_bytes).await; - - 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; - - // 3. Write zipped resource pack for loaders that only read .zip files - write_skin_zip_resource_pack(&pack_zip, &skin_bytes, &target_paths).await; - - // 4. Sync to mod directories (CustomSkinLoader, OfflineSkins, etc.) in game_dir - let mod_targets = [ - game_dir.join("CustomSkinLoader").join("skins").join(format!("{username}.png")), - game_dir.join("CustomSkinLoader").join("skins").join(format!("{uuid}.png")), - game_dir.join("CustomSkinLoader").join("skins").join(format!("{undashed}.png")), - game_dir.join("config").join("offlineskins").join(format!("{username}.png")), - game_dir.join("config").join("offlineskins").join(format!("{uuid}.png")), - game_dir.join("config").join("offlineskins").join(format!("{undashed}.png")), - game_dir.join("cachedImages").join("skins").join(format!("{uuid}.png")), - game_dir.join("cachedImages").join("skins").join(format!("{undashed}.png")), - ]; - for target in mod_targets { - if let Some(parent) = target.parent() { - let _ = polyio::create_dir_all(parent).await; - } - let _ = polyio::write(&target, &skin_bytes).await; - } - - // Configure CustomSkinLoader so it properly loads the user's skin and fetches other multiplayer skins - write_custom_skin_loader_config(&game_dir.join("CustomSkinLoader")).await; - write_custom_skin_loader_config(&game_dir.join("config").join("CustomSkinLoader")).await; - - // Also sync to shared .minecraft if distinct from game_dir - if let Some(shared) = oneclient_common::paths::shared_minecraft_dir().ok().filter(|s| s != game_dir) { - let shared_targets = [ - shared.join("CustomSkinLoader").join("skins").join(format!("{username}.png")), - shared.join("CustomSkinLoader").join("skins").join(format!("{uuid}.png")), - shared.join("config").join("offlineskins").join(format!("{username}.png")), - shared.join("config").join("offlineskins").join(format!("{uuid}.png")), - ]; - for target in shared_targets { - if let Some(parent) = target.parent() { - let _ = polyio::create_dir_all(parent).await; - } - let _ = polyio::write(&target, &skin_bytes).await; - } - write_custom_skin_loader_config(&shared.join("CustomSkinLoader")).await; - } - - tracing::info!(uuid, username, modern_char, legacy_char, "targeted skin direct injection, resource packs, and mod skins ready"); - - // 5. Patch options.txt to ensure the resource pack is enabled - patch_resource_packs_option(&options_txt, PACK_NAME, true).await; -} - -/// Automatically writes CustomSkinLoader configuration to prioritize local custom skin -/// and resolve other online/offline multiplayer skins cleanly. -async fn write_custom_skin_loader_config(csl_dir: &Path) { - let config_path = csl_dir.join("CustomSkinLoader.json"); - let content = r#"{ - "version": "14.14", - "loadlist": [ - { - "name": "LocalSkin", - "type": "CustomSkinLoader", - "root": "CustomSkinLoader/skins/{USERNAME}.png" - }, - { - "name": "Mojang", - "type": "Mojang" - }, - { - "name": "Ely.by", - "type": "ElyBy" - }, - { - "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; -} - -/// Directly modifies only the player's model character textures inside the Minecraft client `.jar`, -/// preserving opposite-model default characters in their original pristine state. -async fn patch_client_jar_skins(client_jar: &Path, skin_bytes: Option<&[u8]>, target_paths: &[String]) { - use futures_lite::AsyncReadExt; - - if !client_jar.exists() { - return; - } - - let orig_jar = client_jar.with_extension("jar.orig"); - - // Ensure pristine backup exists - if !orig_jar.exists() - && let Err(e) = tokio::fs::copy(client_jar, &orig_jar).await - { - tracing::warn!(error = %e, "failed to backup client jar for skin replacement"); - return; - } - - let Some(skin_bytes) = skin_bytes else { - // Restore clean vanilla jar - if orig_jar.exists() { - let _ = tokio::fs::copy(&orig_jar, client_jar).await; - } - return; - }; - - // Always patch from the original pristine backup so we don't accumulate or leave wrong skins - if let Err(e) = tokio::fs::copy(&orig_jar, client_jar).await { - tracing::warn!(error = %e, "failed to restore pristine client jar before skin injection"); - } - - let jar_data = match polyio::read(&orig_jar).await { - Ok(data) => data, - Err(e) => { - tracing::warn!(error = %e, "failed to read backup client jar"); - return; - } - }; - - let reader = match async_zip::base::read::mem::ZipFileReader::new(jar_data).await { - Ok(r) => r, - Err(e) => { - tracing::warn!(error = %e, "failed to parse client jar as zip"); - return; - } - }; - - let temp_jar = client_jar.with_extension("jar.tmp"); - let out_file = match tokio::fs::File::create(&temp_jar).await { - Ok(f) => f, - Err(e) => { - tracing::warn!(error = %e, "failed to create temporary client jar"); - return; - } - }; - - let mut writer = async_zip::tokio::write::ZipFileWriter::with_tokio(out_file); - let mut written_skins = std::collections::HashSet::new(); - - let entries = reader.file().entries(); - for index in 0..entries.len() { - let entry = match entries.get(index) { - Some(e) => e, - None => continue, - }; - - let entry_filename = entry.filename().as_str().unwrap_or_default().to_string(); - - if target_paths.contains(&entry_filename) { - let builder = async_zip::ZipEntryBuilder::new( - entry_filename.clone().into(), - async_zip::Compression::Deflate, - ); - if writer.write_entry_whole(builder, skin_bytes).await.is_ok() { - written_skins.insert(entry_filename); - } - } else { - let mut entry_reader = match reader.reader_without_entry(index).await { - Ok(r) => r, - Err(_) => continue, - }; - let mut buf = Vec::new(); - if entry_reader.read_to_end(&mut buf).await.is_ok() { - let compression = if entry.compression() == async_zip::Compression::Stored { - async_zip::Compression::Stored - } else { - async_zip::Compression::Deflate - }; - let builder = async_zip::ZipEntryBuilder::new( - entry_filename.into(), - compression, - ); - let _ = writer.write_entry_whole(builder, &buf).await; - } - } - } - - // Ensure all target paths exist in the jar - for path in target_paths { - if !written_skins.contains(path) { - let builder = async_zip::ZipEntryBuilder::new( - path.clone().into(), - async_zip::Compression::Deflate, - ); - let _ = writer.write_entry_whole(builder, skin_bytes).await; - } - } - - if let Err(e) = writer.close().await { - tracing::warn!(error = %e, "failed to finalize patched client jar zip"); - let _ = tokio::fs::remove_file(&temp_jar).await; - return; - } - - if let Err(e) = tokio::fs::rename(&temp_jar, client_jar).await { - tracing::warn!(error = %e, "failed to rename patched client jar"); - let _ = tokio::fs::remove_file(&temp_jar).await; - } else { - tracing::info!(jar = %client_jar.display(), "successfully replaced model default skins inside client jar"); - } -} - -/// Creates a targeted zipped `.zip` resource pack file. -async fn write_skin_zip_resource_pack(zip_path: &Path, skin_bytes: &[u8], target_paths: &[String]) { - let out_file = match tokio::fs::File::create(zip_path).await { - Ok(f) => f, - Err(_) => return, - }; - let mut writer = async_zip::tokio::write::ZipFileWriter::with_tokio(out_file); - - let mcmeta = r#"{"pack":{"pack_format":34,"description":"OneLauncher Custom Skin","supported_formats":{"min_inclusive":1,"max_inclusive":9999}}}"#; - let mcmeta_builder = async_zip::ZipEntryBuilder::new("pack.mcmeta".to_string().into(), async_zip::Compression::Stored); - let _ = writer.write_entry_whole(mcmeta_builder, mcmeta.as_bytes()).await; - - for path in target_paths { - let b = async_zip::ZipEntryBuilder::new(path.clone().into(), async_zip::Compression::Deflate); - let _ = writer.write_entry_whole(b, skin_bytes).await; - } - - let _ = writer.close().await; -} - -/// Reads `options.txt`, adds or removes `"file/"` from the `resourcePacks` and `incompatibleResourcePacks` list, -/// and writes it back. Correctly serializes clean JSON without escaped quotes. -async fn patch_resource_packs_option(options_txt: &Path, pack_name: &str, enable: bool) { - let file_dir_entry = format!("file/{pack_name}"); - let file_zip_entry = format!("file/{pack_name}.zip"); - - let content = polyio::read_to_string(options_txt) - .await - .unwrap_or_default(); - - let mut lines: Vec = content.lines().map(String::from).collect(); - - let mut found_rp = false; - let mut found_incomp = false; - - for line in &mut lines { - if line.starts_with("resourcePacks:") { - found_rp = true; - let raw = line.trim_start_matches("resourcePacks:").trim(); - let mut packs: Vec = serde_json::from_str(raw).unwrap_or_default(); - packs.retain(|p| { - p != "vanilla" - && p != &file_dir_entry - && p != &file_zip_entry - && p != &format!("\"{file_dir_entry}\"") - && p != &format!("\"{file_zip_entry}\"") - }); - if enable { - packs.insert(0, "vanilla".to_string()); - packs.push(file_dir_entry.clone()); - packs.push(file_zip_entry.clone()); - } else { - packs.insert(0, "vanilla".to_string()); - } - *line = format!( - "resourcePacks:{}", - serde_json::to_string(&packs).unwrap_or_else(|_| "[\"vanilla\"]".to_string()) - ); - } else if line.starts_with("incompatibleResourcePacks:") { - found_incomp = true; - let raw = line.trim_start_matches("incompatibleResourcePacks:").trim(); - let mut packs: Vec = serde_json::from_str(raw).unwrap_or_default(); - packs.retain(|p| { - p != &file_dir_entry - && p != &file_zip_entry - && p != &format!("\"{file_dir_entry}\"") - && p != &format!("\"{file_zip_entry}\"") - }); - if enable { - packs.push(file_dir_entry.clone()); - packs.push(file_zip_entry.clone()); - } - *line = format!( - "incompatibleResourcePacks:{}", - serde_json::to_string(&packs).unwrap_or_else(|_| "[]".to_string()) - ); - } - } - - if !found_rp && enable { - let entries = vec!["vanilla", &file_dir_entry, &file_zip_entry]; - let default_rp = serde_json::to_string(&entries).unwrap_or_default(); - lines.push(format!("resourcePacks:{default_rp}")); - } - if !found_incomp && enable { - let entries = vec![&file_dir_entry, &file_zip_entry]; - let default_incomp = serde_json::to_string(&entries).unwrap_or_default(); - lines.push(format!("incompatibleResourcePacks:{default_incomp}")); - } - - 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}"); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_target_skin_paths_for_account() { - let (paths, modern, legacy) = target_skin_paths_for_account("c4436573-0477-3e11-97b7-6f7ef57f354f", "oiupoyt"); - assert!(!paths.is_empty()); - assert!(!modern.is_empty()); - assert!(!legacy.is_empty()); - assert!(paths.iter().any(|p| p.contains(&legacy))); - } -} diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index d9c59cdb..b951ad77 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -10,6 +10,7 @@ mod reattach; mod session; mod shared_dir; mod tail; +pub mod skin_server; pub use analytics::{ Analytics, DayPlaytime, Persona, PlaytimeStats, ServerStat, WEEKDAY_LABELS, aggregate_servers, 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..80323497 --- /dev/null +++ b/packages/oneclient_core/src/game/skin_server.rs @@ -0,0 +1,568 @@ +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}; + +type MojangCacheEntry = (Option, Instant); +type MojangCacheMap = HashMap; + +static AUTHLIB_INJECTOR_BYTES: &[u8] = include_bytes!("../../assets/authlib-injector.jar"); +static SKIN_SERVER_PORT: OnceLock = OnceLock::new(); +static REGISTERED_ACCOUNTS: OnceLock>> = OnceLock::new(); +static MOJANG_CACHE: OnceLock> = OnceLock::new(); + +#[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) +} + +/// 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(""); + + 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); + + // 1. Root / Yggdrasil API metadata + if path == "/" || path == "/api/yggdrasil" || path == "/api/yggdrasil/" { + let body = format!( + r#"{{"meta":{{"serverName":"OneLauncher-Cracked","implementationName":"onelauncher-skin-server","implementationVersion":"{}"}},"skinDomains":["127.0.0.1","localhost"]}}"#, + env!("CARGO_PKG_VERSION") + ); + 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. Profile request: /session/minecraft/profile/ + if let Some(uuid_raw) = path.strip_prefix("/session/minecraft/profile/") { + 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(()); + } + + // 3. 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('-', ""); + if clean.len() == 32 { + format!( + "{}-{}-{}-{}-{}", + &clean[0..8], + &clean[8..12], + &clean[12..16], + &clean[16..20], + &clean[20..32] + ) + } else { + s.to_string() + } +} + +pub 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) + && 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 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('-', "") == undashed) + && let Some(name) = user.get("username").and_then(|n| n.as_str()) + { + return name.to_string(); + } + } + } + + "Player".to_string() +} + +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 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 = format!( + r#"{{"id":"{undashed}","name":"{username}","properties":[{{"name":"textures","value":"{encoded_textures}"}}]}}"# + ); + + Some(profile_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().is_success() => res.text().await.ok(), + _ => None, + }; + + let mut write = cache.write(); + write.insert(undashed, (result.clone(), Instant::now())); + + result +} + +/// Synchronizes 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() + && shared != game_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; + } + } + } +} + +/// 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", + "loadlist": [ + { + "name": "LocalSkin", + "type": "CustomSkinLoader", + "root": "CustomSkinLoader/skins/{USERNAME}.png" + }, + { + "name": "Mojang", + "type": "Mojang" + }, + { + "name": "Ely.by", + "type": "ElyBy" + }, + { + "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); + } +}