diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs index a6942497..2d13b785 100644 --- a/packages/oneclient_app/src/components/navbar/app_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -86,6 +86,7 @@ struct NavbarCenter; impl Component for NavbarCenter { fn render(&self) -> impl IntoElement { let route = use_route::(); + let browse_target = browse_target(); rect() .horizontal() @@ -109,8 +110,14 @@ impl Component for NavbarCenter { nav_label: "Skins", }) .child(NavLink { - active: matches!(route, Route::Browser { .. }), - target: NavTarget::Route(Route::Clusters {}), + active: matches!( + route, + Route::Browser { + pick_cluster: true, + .. + } + ), + target: NavTarget::Route(browse_target), nav_label: "Browse", }) .child(NavLink { @@ -121,6 +128,25 @@ impl Component for NavbarCenter { } } +/// Falls back to active cluster then most recently played then Versions when none exist +fn browse_target() -> Route { + let clusters = crate::hooks::settled_or_loading(&crate::hooks::use_clusters()).unwrap_or_default(); + let active = *crate::hooks::use_active_cluster_id().read(); + + let cluster_id = active + .filter(|id| clusters.iter().any(|cluster| cluster.id == *id)) + .or_else(|| crate::utils::sort_clusters_for_home(clusters).first().map(|c| c.id)); + + match cluster_id { + Some(cluster_id) => Route::Browser { + cluster_id, + package_type: "mod".to_string(), + pick_cluster: true, + }, + None => Route::Clusters {}, + } +} + #[derive(PartialEq, Clone)] #[allow(dead_code)] enum NavTarget { diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index 81846324..cb9a6609 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -48,7 +48,7 @@ pub use queries::{ use_onboarding_bundles, use_package_categories, use_package_meta_batch, use_package_project, use_package_search, use_package_updates, use_package_versions, use_package_versions_when, use_player_profile, use_player_skin, use_custom_skin, delete_account_skin, - fetch_skin_online, save_account_skin, invalidate_skin_queries, use_provider_versions, use_refresh_account, + save_account_skin, invalidate_skin_queries, use_provider_versions, use_refresh_account, use_refresh_all_accounts, use_remove_account, use_screenshot_action, use_screenshot_folder_watch, use_set_default_account, use_storage_action, use_storage_report, use_terms, use_upload_log, use_version_metadata, use_versions, version_list, versions_metadata, diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index 0c4ebb3d..0a27de45 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -89,8 +89,8 @@ pub use settings_profiles::{ use_named_profiles, }; pub use skin::{ - delete_account_skin, fetch_skin_online, invalidate_skin_queries, save_account_skin, - use_custom_skin, use_player_skin, + delete_account_skin, invalidate_skin_queries, save_account_skin, use_custom_skin, + use_player_skin, }; pub use state::{query_error, query_is_busy, query_is_loading, settled_or_loading}; pub use storage::{ diff --git a/packages/oneclient_app/src/hooks/queries/skin.rs b/packages/oneclient_app/src/hooks/queries/skin.rs index f79d8584..d7b00329 100644 --- a/packages/oneclient_app/src/hooks/queries/skin.rs +++ b/packages/oneclient_app/src/hooks/queries/skin.rs @@ -245,92 +245,3 @@ async fn remove_custom_skin_loader(username: &str) -> Result<(), String> { } Ok(()) } - -pub async fn fetch_skin_online(query: &str) -> Result<(Vec, bool), String> { - let query = query.trim(); - if query.is_empty() { - return Err("Input is empty".to_string()); - } - - let client = reqwest::Client::builder() - .user_agent("OneLauncher-Cracked/2.2.3") - .build() - .map_err(|e| e.to_string())?; - - if query.starts_with("http://") || query.starts_with("https://") { - let resp = client - .get(query) - .send() - .await - .map_err(|e| format!("Failed to download skin URL: {e}"))?; - - if !resp.status().is_success() { - return Err(format!("Download failed with status {}", resp.status())); - } - - let bytes = resp - .bytes() - .await - .map_err(|e| format!("Failed to read response bytes: {e}"))? - .to_vec(); - - return Ok((bytes, false)); - } - - let minotar_url = format!("https://minotar.net/skin/{query}"); - let resp = client - .get(&minotar_url) - .send() - .await - .map_err(|e| format!("Failed to reach skin server: {e}"))?; - - if resp.status().is_success() { - let bytes = resp - .bytes() - .await - .map_err(|e| format!("Failed to read bytes: {e}"))? - .to_vec(); - return Ok((bytes, false)); - } - - let mojang_uuid_url = - format!("https://api.mojang.com/users/profiles/minecraft/{query}"); - let resp = client - .get(&mojang_uuid_url) - .send() - .await - .map_err(|e| format!("Mojang lookup failed: {e}"))?; - - if !resp.status().is_success() { - return Err(format!("Player \"{query}\" not found")); - } - - #[derive(Deserialize)] - struct MojangProfile { - id: String, - } - - let profile: MojangProfile = resp - .json() - .await - .map_err(|e| format!("Invalid profile response: {e}"))?; - - let crafatar_url = format!("https://crafatar.com/skins/{}", profile.id); - let skin_resp = client - .get(&crafatar_url) - .send() - .await - .map_err(|e| format!("Failed to fetch skin: {e}"))?; - - if !skin_resp.status().is_success() { - return Err(format!("Could not fetch skin texture for \"{query}\"")); - } - - let bytes = skin_resp - .bytes() - .await - .map_err(|e| format!("Failed to read skin bytes: {e}"))? - .to_vec(); - - Ok((bytes, false)) -} diff --git a/packages/oneclient_app/src/view/app/skins.rs b/packages/oneclient_app/src/view/app/skins.rs index 3d028b23..4bd8dd42 100644 --- a/packages/oneclient_app/src/view/app/skins.rs +++ b/packages/oneclient_app/src/view/app/skins.rs @@ -1,10 +1,10 @@ use freya::prelude::*; use oneclient_auth::MinecraftAccount; -use crate::components::{Button, Icon, IconType, OverlayPopup, PlayerModel, TextInput}; +use crate::components::{Button, Icon, IconType, PlayerModel}; use crate::hooks::{ - delete_account_skin, fetch_skin_online, save_account_skin, try_accounts, try_default_account, - use_accounts, use_current_account, use_custom_skin, + delete_account_skin, save_account_skin, try_accounts, try_default_account, use_accounts, + use_current_account, use_custom_skin, }; use crate::theme::colors; use crate::ui::border_all_color; @@ -31,7 +31,6 @@ impl Component for AccountSkins { let username = active_account.as_ref().map(|a| a.username.clone()); let status_msg = use_state(|| None::<(String, bool)>); // (message, is_error) - let show_url_modal = use_state(|| false); let is_loading = use_state(|| false); rect() @@ -52,15 +51,8 @@ impl Component for AccountSkins { accounts, selected_id, status_msg, - show_url_modal, is_loading, )) - .maybe_child((*show_url_modal.read()).then(|| UrlModal { - selected_id, - status_msg, - show_url_modal, - is_loading, - })) } } @@ -240,7 +232,6 @@ fn side_panel( accounts: Vec, selected_id: State>, status_msg: State>, - show_url_modal: State, is_loading: State, ) -> impl IntoElement { let username = active_account @@ -350,8 +341,6 @@ fn side_panel( }); }; - let mut open_url_dialog = show_url_modal; - rect() .vertical() .width(Size::flex(1.0)) @@ -429,24 +418,6 @@ fn side_panel( .child(label().text("Upload PNG from Computer").font_size(14.)), ), ) - .child( - Button::new() - .ghost() - .enabled(!*is_loading.read() && account_id.is_some()) - .on_press(move |_| open_url_dialog.set(true)) - .child( - rect() - .horizontal() - .spacing(8.) - .cross_align(Alignment::Center) - .child(Icon::new(IconType::Globe01).size(18.)) - .child( - label() - .text("Import by Username / URL") - .font_size(14.), - ), - ), - ) .child( rect() .horizontal() @@ -477,8 +448,8 @@ fn side_panel( .cross_align(Alignment::Center) .child( Icon::new(IconType::Trash01) - .size(16.) - .color(colors::danger()), + .size(16.) + .color(colors::danger()), ) .child( label() @@ -500,14 +471,14 @@ fn side_panel( .spacing(6.) .child( label() - .text("Offline Skin Loader Integration") + .text("Offline Skin System") .font_size(13.) .font_weight(FontWeight::SEMI_BOLD) .color(colors::fg_primary()), ) .child( label() - .text("Custom skins are saved locally to your profile and automatically synchronized to CustomSkinLoader so your skin shows up in game seamlessly on offline/cracked clusters.") + .text("Custom skins are saved locally to your profile and seamlessly loaded into singleplayer and offline servers.") .font_size(12.) .color(colors::fg_secondary()), ), @@ -575,106 +546,3 @@ fn account_selector( })), ) } - -#[derive(PartialEq, Clone)] -struct UrlModal { - selected_id: State>, - status_msg: State>, - show_url_modal: State, - is_loading: State, -} - -impl Component for UrlModal { - fn render(&self) -> impl IntoElement { - let input_text = use_state(String::new); - let mut modal_open = self.show_url_modal; - let mut status = self.status_msg; - let mut loading = self.is_loading; - - let target_uuid = (*self.selected_id.peek()).map(|id| id.to_string()); - - let apply_url = move |_| { - let Some(ref uuid_str) = target_uuid else { - return; - }; - let uuid_str = uuid_str.clone(); - let query = input_text.peek().trim().to_string(); - if query.is_empty() { - return; - } - - loading.set(true); - modal_open.set(false); - - spawn(async move { - match fetch_skin_online(&query).await { - Ok((bytes, is_slim)) => { - let _ = save_account_skin(&uuid_str, None, &bytes, is_slim).await; - status.set(Some(( - format!("Skin for \"{query}\" applied successfully!"), - false, - ))); - } - Err(err) => { - status.set(Some((err, true))); - } - } - loading.set(false); - }); - }; - - OverlayPopup::new() - .position(Position::new_global().top(140.).left(280.)) - .on_close(move |()| modal_open.set(false)) - .child( - rect() - .vertical() - .width(Size::px(380.)) - .padding(Gaps::new_all(20.)) - .spacing(16.) - .corner_radius(CornerRadius::new_all(12.)) - .background(colors::page_elevated()) - .border(border_all_color(1., colors::component_border())) - .shadow(Shadow::from((0., 8., 32., 0., Color::from_argb(140, 0, 0, 0)))) - .child( - label() - .text("Import Skin by Username or URL") - .font_size(16.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), - ) - .child( - label() - .text( - "Enter any Minecraft player name (e.g. Technoblade, Notch) or a direct skin image URL:", - ) - .font_size(12.) - .color(colors::fg_secondary()), - ) - .child( - TextInput::new(input_text) - .placeholder("Username (e.g. Dream) or https://...") - .width(Size::fill()), - ) - .child( - rect() - .horizontal() - .main_align(Alignment::End) - .spacing(8.) - .child( - Button::new() - .ghost() - .on_press(move |_| modal_open.set(false)) - .text("Cancel"), - ) - .child( - Button::new() - .primary() - .on_press(apply_url) - .text("Apply Skin"), - ), - ), - ) - .into_element() - } -} diff --git a/packages/oneclient_common/src/paths.rs b/packages/oneclient_common/src/paths.rs index f111b47d..563a2929 100644 --- a/packages/oneclient_common/src/paths.rs +++ b/packages/oneclient_common/src/paths.rs @@ -73,6 +73,10 @@ 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 b5817dff..569a6ac6 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 jvm_args = arguments::java_arguments( + let mut jvm_args = arguments::java_arguments( updated, arg_map.get(&ArgumentType::Jvm).map(Vec::as_slice), &natives, @@ -276,6 +276,22 @@ 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); + } + } + let mut mc_args = arguments::minecraft_arguments( updated, arg_map.get(&ArgumentType::Game).map(Vec::as_slice), diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index 77b3917b..b1163904 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -6,6 +6,7 @@ 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 new file mode 100644 index 00000000..144dcdb7 --- /dev/null +++ b/packages/oneclient_core/src/game/skin_server.rs @@ -0,0 +1,314 @@ +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; + } + } + } +}