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
This commit is contained in:
katR 2026-08-29 23:30:02 +05:30
parent de9416573a
commit cea98e9923
5 changed files with 147 additions and 83 deletions

View file

@ -53,7 +53,7 @@ pub fn use_link_confirm() -> State<Option<String>> {
consume_root_context::<LinkConfirmState>().0
}
#[derive(Clone)]
#[derive(Clone, PartialEq, Eq)]
pub struct BrowserUiState {
pub query: String,
pub provider: ProviderId,

View file

@ -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,

View file

@ -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

View file

@ -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);
}
});
}

View file

@ -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::<SkinMeta>(&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/<name>"` from the `resourcePacks` list,
/// Reads `options.txt`, adds or removes `"file/<name>"` 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<String> = 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<String> = 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<String> = 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<String> = 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";