From cea98e992362e2690a340150d0143bcf6fb154a0 Mon Sep 17 00:00:00 2001 From: oiupoyt Date: Sat, 29 Aug 2026 23:30:02 +0530 Subject: [PATCH] fix: browser-to-settings transition crash & full in-game skin asset coverage - Fix crash when transitioning from Browse to Settings: * Prevent UI thrashing by executing router settle in use_side_effect rather than during render * Add PartialEq/Eq to BrowserUiState and use use_side_effect_with_deps in Browser component - Fix in-game skin loading across all Minecraft versions: * Modern Minecraft (1.19.3 - 1.21.x) default player skin resolution covers all 9 character models (Steve, Alex, Ari, Efe, Kai, Makena, Noor, Sunny, Zuri) for both wide and slim model paths * Legacy Minecraft (1.8 - 1.19.2) entity texture paths (textures/entity/steve.png, alex.png) * Multi-mod directory synchronization: CustomSkinLoader, OfflineSkins, cachedImages for cluster game dir and shared .minecraft * Safe options.txt patching supporting both resourcePacks and incompatibleResourcePacks list formats --- .../oneclient_app/src/hooks/active_cluster.rs | 2 +- .../src/layout/animated_outlet.rs | 7 +- .../oneclient_app/src/layout/cluster_shell.rs | 7 +- .../src/view/app/browser/index/mod.rs | 20 +- packages/oneclient_core/src/game/launch.rs | 194 ++++++++++++------ 5 files changed, 147 insertions(+), 83 deletions(-) diff --git a/packages/oneclient_app/src/hooks/active_cluster.rs b/packages/oneclient_app/src/hooks/active_cluster.rs index 54627eb8..cf617d3b 100644 --- a/packages/oneclient_app/src/hooks/active_cluster.rs +++ b/packages/oneclient_app/src/hooks/active_cluster.rs @@ -53,7 +53,7 @@ pub fn use_link_confirm() -> State> { consume_root_context::().0 } -#[derive(Clone)] +#[derive(Clone, PartialEq, Eq)] pub struct BrowserUiState { pub query: String, pub provider: ProviderId, diff --git a/packages/oneclient_app/src/layout/animated_outlet.rs b/packages/oneclient_app/src/layout/animated_outlet.rs index 4ea329e3..628ad21a 100644 --- a/packages/oneclient_app/src/layout/animated_outlet.rs +++ b/packages/oneclient_app/src/layout/animated_outlet.rs @@ -103,14 +103,13 @@ impl Component for AnimatedAppOutlet { use_side_effect_with_deps(&anim_finished, move |&finished| { if finished { + if matches!(&*router.peek(), AnimatedRouterContext::FromTo(_, _)) { + router.write().settle(); + } Platform::get().send(UserEvent::RequestRedraw); } }); - if anim_finished && matches!(&*router.peek(), AnimatedRouterContext::FromTo(_, _)) { - router.write().settle(); - } - let kind = match &*router.read() { AnimatedRouterContext::FromTo(from, to) => enter_kind(from, to), AnimatedRouterContext::In(_) => Enter::None, diff --git a/packages/oneclient_app/src/layout/cluster_shell.rs b/packages/oneclient_app/src/layout/cluster_shell.rs index 062d6ae8..5edbd453 100644 --- a/packages/oneclient_app/src/layout/cluster_shell.rs +++ b/packages/oneclient_app/src/layout/cluster_shell.rs @@ -203,14 +203,13 @@ impl Component for ClusterContentOutlet { use_side_effect_with_deps(&anim_finished, move |&finished| { if finished { + if matches!(&*router.peek(), AnimatedRouterContext::FromTo(_, _)) { + router.write().settle(); + } Platform::get().send(UserEvent::RequestRedraw); } }); - if anim_finished && matches!(&*router.peek(), AnimatedRouterContext::FromTo(_, _)) { - router.write().settle(); - } - const CONTENT_DX: f32 = 44.; let p = if anim_finished { 1.0 diff --git a/packages/oneclient_app/src/view/app/browser/index/mod.rs b/packages/oneclient_app/src/view/app/browser/index/mod.rs index a5e38650..7f8c8bd4 100644 --- a/packages/oneclient_app/src/view/app/browser/index/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs @@ -92,14 +92,22 @@ impl Component for Browser { { let mut store = store; let key = state_key.clone(); - use_side_effect(move || { + let deps = ( + query.read().clone(), + *provider.read(), + selected_categories.read().clone(), + *page.read(), + ); + use_side_effect_with_deps(&deps, move |(q, prov, cats, p)| { let snapshot = BrowserUiState { - query: query.read().clone(), - provider: *provider.read(), - categories: selected_categories.read().clone(), - page: *page.read(), + query: q.clone(), + provider: *prov, + categories: cats.clone(), + page: *p, }; - store.write().insert(key.clone(), snapshot); + if store.peek().get(&key) != Some(&snapshot) { + store.write().insert(key.clone(), snapshot); + } }); } diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index faf76193..3d63ef95 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -276,9 +276,9 @@ pub async fn launch_cluster( java.major, )?; - // 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; + // Inject custom skin as a resource pack that overrides default character textures + // across all Minecraft versions, plus syncs to mod directories (CustomSkinLoader, etc.) + inject_skin_resource_pack(&cwd, &account.id.to_string(), &account.username).await; let mut mc_args = arguments::minecraft_arguments( updated, @@ -683,23 +683,31 @@ async fn run_hook(hook: Option<&str>, cwd: &Path) { } } +const ALL_DEFAULT_SKIN_FILENAMES: &[&str] = &[ + "steve.png", + "alex.png", + "ari.png", + "efe.png", + "kai.png", + "makena.png", + "noor.png", + "sunny.png", + "zuri.png", +]; + /// 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. +/// overrides all default player textures across all Minecraft versions (Steve, Alex, Ari, Efe, Kai, Makena, Noor, Sunny, Zuri) +/// with the player's saved custom skin PNG. Also patches `options.txt` to enable the pack and syncs to mod folders. /// /// 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) { +async fn inject_skin_resource_pack(game_dir: &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 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 --- + // --- Resolve skin PNG --- let skin_path = match oneclient_common::paths::skin_file_path(uuid) { Ok(p) => p, Err(e) => { @@ -708,17 +716,27 @@ async fn inject_skin_resource_pack(game_dir: &Path, uuid: &str) { } }; - 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. + 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 — remove the pack dir and strip from options.txt 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 { + let skin_bytes = match polyio::read(&valid_skin_path).await { Ok(b) => b, Err(e) => { tracing::warn!("failed to read custom skin: {e}"); @@ -726,48 +744,75 @@ async fn inject_skin_resource_pack(game_dir: &Path, uuid: &str) { } }; - let meta_path = match oneclient_common::paths::skin_meta_path(uuid) { - Ok(p) => p, - Err(_) => return, - }; + // 1. 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"); - #[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; + let _ = polyio::create_dir_all(&textures_player).await; + let _ = polyio::create_dir_all(&textures_entity).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; + // Overwrite all 9 modern characters for both wide and slim, and generic player directory + for name in ALL_DEFAULT_SKIN_FILENAMES { + let _ = polyio::write(textures_wide.join(name), &skin_bytes).await; + let _ = polyio::write(textures_slim.join(name), &skin_bytes).await; + let _ = polyio::write(textures_player.join(name), &skin_bytes).await; } - // 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}}}"#; + // Overwrite legacy entity textures for 1.8 - 1.19 + let _ = polyio::write(textures_entity.join("steve.png"), &skin_bytes).await; + let _ = polyio::write(textures_entity.join("alex.png"), &skin_bytes).await; + + // pack.mcmeta with universal format compatibility + 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"); + // 2. Also 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; + } - // --- Patch options.txt --- + // Also sync to shared .minecraft if distinct from game_dir + if let Ok(shared) = oneclient_common::paths::shared_minecraft_dir() { + if shared != 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; + } + } + } + + tracing::info!(uuid, username, "skin resource pack and mod skins ready"); + + // 3. Patch options.txt to ensure the resource pack is enabled patch_resource_packs_option(&options_txt, PACK_NAME, true).await; } -/// Reads `options.txt`, adds or removes `"file/"` from the `resourcePacks` list, +/// Reads `options.txt`, adds or removes `"file/"` from the `resourcePacks` and `incompatibleResourcePacks` 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}\""); @@ -778,32 +823,45 @@ async fn patch_resource_packs_option(options_txt: &Path, pack_name: &str, enable 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; + let mut found_rp = false; + let mut found_incomp = false; + for line in &mut lines { - if !line.starts_with("resourcePacks:") { - continue; + 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_entry); + if enable { + packs.insert(0, "vanilla".to_string()); + packs.push(file_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_entry); + if enable { + packs.push(file_entry.clone()); + } + *line = format!( + "incompatibleResourcePacks:{}", + serde_json::to_string(&packs).unwrap_or_else(|_| "[]".to_string()) + ); } - 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}]")); + if !found_rp && enable { + lines.push(format!("resourcePacks:[\"vanilla\",{file_entry}]")); + } + if !found_incomp && enable { + lines.push(format!("incompatibleResourcePacks:[{file_entry}]")); } let new_content = lines.join("\n") + "\n";