feat(skins): integrate proper isolated offline skin system via Authlib-Injector and eliminate multiplayer skin cloning (2.5.1-c2)
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
991a85c589
commit
700b11a3ce
7 changed files with 608 additions and 470 deletions
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -122,6 +122,10 @@ pub fn java_dir() -> PathsResult<PathBuf> {
|
|||
Ok(data_dir()?.join("metadata").join("java"))
|
||||
}
|
||||
|
||||
pub fn authlib_injector_path() -> PathsResult<PathBuf> {
|
||||
Ok(data_dir()?.join("metadata").join("authlib-injector.jar"))
|
||||
}
|
||||
|
||||
pub fn clusters_dir() -> PathsResult<PathBuf> {
|
||||
Ok(data_dir()?.join("clusters"))
|
||||
}
|
||||
|
|
|
|||
BIN
packages/oneclient_core/assets/authlib-injector.jar
Normal file
BIN
packages/oneclient_core/assets/authlib-injector.jar
Normal file
Binary file not shown.
|
|
@ -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<String> {
|
|||
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, 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<String> = 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/<name>"` 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<String> = 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<String> = 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<String> = 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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
568
packages/oneclient_core/src/game/skin_server.rs
Normal file
568
packages/oneclient_core/src/game/skin_server.rs
Normal file
|
|
@ -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<String>, Instant);
|
||||
type MojangCacheMap = HashMap<String, MojangCacheEntry>;
|
||||
|
||||
static AUTHLIB_INJECTOR_BYTES: &[u8] = include_bytes!("../../assets/authlib-injector.jar");
|
||||
static SKIN_SERVER_PORT: OnceLock<u16> = OnceLock::new();
|
||||
static REGISTERED_ACCOUNTS: OnceLock<RwLock<HashMap<String, AccountRegistration>>> = OnceLock::new();
|
||||
static MOJANG_CACHE: OnceLock<RwLock<MojangCacheMap>> = 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<PathBuf, String> {
|
||||
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<u16, String> {
|
||||
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/<uuid>
|
||||
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/<id>.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<PathBuf> {
|
||||
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<Vec<u8>> {
|
||||
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::<serde_json::Value>(&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<String> {
|
||||
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::<SkinMetadata>(&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<String> {
|
||||
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<String> = 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::<Vec<String>>(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::<Vec<String>>(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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue