feat(skins): directly replace default textures in client jar, add zip resource pack, and fix options.txt formatting
Some checks are pending
Release Multiplatform Binaries / build (deb,appimage, linux-x86_64, ubuntu-22.04, x86_64-unknown-linux-gnu) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-arm64, macos-14, aarch64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-x86_64, macos-14, x86_64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (nsis, windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Waiting to run
Release Multiplatform Binaries / create-release (push) Blocked by required conditions
Some checks are pending
Release Multiplatform Binaries / build (deb,appimage, linux-x86_64, ubuntu-22.04, x86_64-unknown-linux-gnu) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-arm64, macos-14, aarch64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-x86_64, macos-14, x86_64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (nsis, windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Waiting to run
Release Multiplatform Binaries / create-release (push) Blocked by required conditions
This commit is contained in:
parent
f125ac2d0b
commit
05dac19dd2
1 changed files with 224 additions and 26 deletions
|
|
@ -276,9 +276,8 @@ pub async fn launch_cluster(
|
|||
java.major,
|
||||
)?;
|
||||
|
||||
// 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;
|
||||
// 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;
|
||||
|
||||
let mut mc_args = arguments::minecraft_arguments(
|
||||
updated,
|
||||
|
|
@ -695,16 +694,39 @@ const ALL_DEFAULT_SKIN_FILENAMES: &[&str] = &[
|
|||
"zuri.png",
|
||||
];
|
||||
|
||||
/// Creates (or updates) a `onelauncher_skin` resource pack in the game directory that
|
||||
/// 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, username: &str) {
|
||||
const DEFAULT_SKIN_PATHS_IN_JAR: &[&str] = &[
|
||||
"assets/minecraft/textures/entity/player/wide/steve.png",
|
||||
"assets/minecraft/textures/entity/player/wide/alex.png",
|
||||
"assets/minecraft/textures/entity/player/wide/ari.png",
|
||||
"assets/minecraft/textures/entity/player/wide/efe.png",
|
||||
"assets/minecraft/textures/entity/player/wide/kai.png",
|
||||
"assets/minecraft/textures/entity/player/wide/makena.png",
|
||||
"assets/minecraft/textures/entity/player/wide/noor.png",
|
||||
"assets/minecraft/textures/entity/player/wide/sunny.png",
|
||||
"assets/minecraft/textures/entity/player/wide/zuri.png",
|
||||
"assets/minecraft/textures/entity/player/slim/steve.png",
|
||||
"assets/minecraft/textures/entity/player/slim/alex.png",
|
||||
"assets/minecraft/textures/entity/player/slim/ari.png",
|
||||
"assets/minecraft/textures/entity/player/slim/efe.png",
|
||||
"assets/minecraft/textures/entity/player/slim/kai.png",
|
||||
"assets/minecraft/textures/entity/player/slim/makena.png",
|
||||
"assets/minecraft/textures/entity/player/slim/noor.png",
|
||||
"assets/minecraft/textures/entity/player/slim/sunny.png",
|
||||
"assets/minecraft/textures/entity/player/slim/zuri.png",
|
||||
"assets/minecraft/textures/entity/player/steve.png",
|
||||
"assets/minecraft/textures/entity/player/alex.png",
|
||||
"assets/minecraft/textures/entity/steve.png",
|
||||
"assets/minecraft/textures/entity/alex.png",
|
||||
];
|
||||
|
||||
/// Replaces default Minecraft character textures directly inside the Minecraft client `.jar`,
|
||||
/// creates directory and `.zip` resource packs, and syncs custom skins 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 ---
|
||||
|
|
@ -728,11 +750,15 @@ async fn inject_skin_resource_pack(game_dir: &Path, uuid: &str, username: &str)
|
|||
};
|
||||
|
||||
let Some(valid_skin_path) = resolved_skin_path else {
|
||||
// No custom skin — remove the pack dir and strip from options.txt
|
||||
// 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;
|
||||
patch_resource_packs_option(&options_txt, PACK_NAME, false).await;
|
||||
}
|
||||
if pack_zip.exists() {
|
||||
let _ = polyio::remove_file(&pack_zip).await;
|
||||
}
|
||||
patch_resource_packs_option(&options_txt, PACK_NAME, false).await;
|
||||
return;
|
||||
};
|
||||
|
||||
|
|
@ -744,7 +770,10 @@ async fn inject_skin_resource_pack(game_dir: &Path, uuid: &str, username: &str)
|
|||
}
|
||||
};
|
||||
|
||||
// 1. Write Resource Pack directory structure
|
||||
// 1. Directly patch the client jar so default textures in-game match the skin
|
||||
patch_client_jar_skins(client_jar, Some(&skin_bytes)).await;
|
||||
|
||||
// 2. 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");
|
||||
|
|
@ -755,22 +784,22 @@ async fn inject_skin_resource_pack(game_dir: &Path, uuid: &str, username: &str)
|
|||
let _ = polyio::create_dir_all(&textures_player).await;
|
||||
let _ = polyio::create_dir_all(&textures_entity).await;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// 2. Also sync to mod directories (CustomSkinLoader, OfflineSkins, etc.) in game_dir
|
||||
// 3. Write zipped resource pack for loaders that only read .zip files
|
||||
write_skin_zip_resource_pack(&pack_zip, &skin_bytes).await;
|
||||
|
||||
// 4. 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")),
|
||||
|
|
@ -804,16 +833,168 @@ async fn inject_skin_resource_pack(game_dir: &Path, uuid: &str, username: &str)
|
|||
}
|
||||
}
|
||||
|
||||
tracing::info!(uuid, username, "skin resource pack and mod skins ready");
|
||||
tracing::info!(uuid, username, "skin direct injection, resource packs, and mod skins ready");
|
||||
|
||||
// 3. Patch options.txt to ensure the resource pack is enabled
|
||||
// 5. Patch options.txt to ensure the resource pack is enabled
|
||||
patch_resource_packs_option(&options_txt, PACK_NAME, true).await;
|
||||
}
|
||||
|
||||
/// Directly modifies default Steve, Alex, Ari, etc. player texture entries inside the Minecraft client `.jar`.
|
||||
async fn patch_client_jar_skins(client_jar: &Path, skin_bytes: Option<&[u8]>) {
|
||||
use futures_lite::AsyncReadExt;
|
||||
|
||||
if !client_jar.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let orig_jar = client_jar.with_extension("jar.orig");
|
||||
|
||||
// If no custom skin, restore the unpatched original jar if available
|
||||
let Some(skin_bytes) = skin_bytes else {
|
||||
if orig_jar.exists() {
|
||||
let _ = tokio::fs::copy(&orig_jar, client_jar).await;
|
||||
let _ = tokio::fs::remove_file(&orig_jar).await;
|
||||
tracing::info!(jar = %client_jar.display(), "restored original pristine client jar");
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
// Backup untouched original jar if not already backed up
|
||||
if !orig_jar.exists()
|
||||
&& let Err(e) = tokio::fs::copy(client_jar, &orig_jar).await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to backup client jar before skin injection");
|
||||
return;
|
||||
}
|
||||
|
||||
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 DEFAULT_SKIN_PATHS_IN_JAR.contains(&entry_filename.as_str()) {
|
||||
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 modern character paths exist in the jar
|
||||
for path in DEFAULT_SKIN_PATHS_IN_JAR {
|
||||
if !written_skins.contains(*path) {
|
||||
let builder = async_zip::ZipEntryBuilder::new(
|
||||
(*path).to_string().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 default skins inside client jar");
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a zipped `.zip` resource pack file.
|
||||
async fn write_skin_zip_resource_pack(zip_path: &Path, skin_bytes: &[u8]) {
|
||||
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 &name in ALL_DEFAULT_SKIN_FILENAMES {
|
||||
let b1 = async_zip::ZipEntryBuilder::new(format!("assets/minecraft/textures/entity/player/wide/{name}").into(), async_zip::Compression::Deflate);
|
||||
let _ = writer.write_entry_whole(b1, skin_bytes).await;
|
||||
|
||||
let b2 = async_zip::ZipEntryBuilder::new(format!("assets/minecraft/textures/entity/player/slim/{name}").into(), async_zip::Compression::Deflate);
|
||||
let _ = writer.write_entry_whole(b2, skin_bytes).await;
|
||||
|
||||
let b3 = async_zip::ZipEntryBuilder::new(format!("assets/minecraft/textures/entity/player/{name}").into(), async_zip::Compression::Deflate);
|
||||
let _ = writer.write_entry_whole(b3, skin_bytes).await;
|
||||
}
|
||||
|
||||
let b_steve = async_zip::ZipEntryBuilder::new("assets/minecraft/textures/entity/steve.png".to_string().into(), async_zip::Compression::Deflate);
|
||||
let _ = writer.write_entry_whole(b_steve, skin_bytes).await;
|
||||
|
||||
let b_alex = async_zip::ZipEntryBuilder::new("assets/minecraft/textures/entity/alex.png".to_string().into(), async_zip::Compression::Deflate);
|
||||
let _ = writer.write_entry_whole(b_alex, skin_bytes).await;
|
||||
|
||||
let _ = writer.close().await;
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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_entry = format!("\"file/{pack_name}\"");
|
||||
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
|
||||
|
|
@ -829,10 +1010,17 @@ async fn patch_resource_packs_option(options_txt: &Path, pack_name: &str, enable
|
|||
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);
|
||||
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_entry.clone());
|
||||
packs.push(file_dir_entry.clone());
|
||||
packs.push(file_zip_entry.clone());
|
||||
} else {
|
||||
packs.insert(0, "vanilla".to_string());
|
||||
}
|
||||
|
|
@ -844,9 +1032,15 @@ async fn patch_resource_packs_option(options_txt: &Path, pack_name: &str, enable
|
|||
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);
|
||||
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_entry.clone());
|
||||
packs.push(file_dir_entry.clone());
|
||||
packs.push(file_zip_entry.clone());
|
||||
}
|
||||
*line = format!(
|
||||
"incompatibleResourcePacks:{}",
|
||||
|
|
@ -856,10 +1050,14 @@ async fn patch_resource_packs_option(options_txt: &Path, pack_name: &str, enable
|
|||
}
|
||||
|
||||
if !found_rp && enable {
|
||||
lines.push(format!("resourcePacks:[\"vanilla\",{file_entry}]"));
|
||||
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 {
|
||||
lines.push(format!("incompatibleResourcePacks:[{file_entry}]"));
|
||||
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";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue