feat(skins): integrate client-side offline skin mod and isolate player textures

This commit is contained in:
katR 2026-09-23 06:37:28 +05:30
parent 017c9a85f9
commit 7bb797eb16
4 changed files with 167 additions and 62 deletions

View file

@ -220,22 +220,32 @@ pub async fn delete_account_skin(uuid: &str, username: Option<&str>) -> Result<(
async fn sync_custom_skin_loader(username: &str, skin_png: &[u8]) -> Result<(), String> {
if let Ok(mc_dir) = shared_minecraft_dir() {
let csl_skins_dir = mc_dir.join("CustomSkinLoader").join("skins");
let _ = polyio::create_dir_all(&csl_skins_dir).await;
let target = csl_skins_dir.join(format!("{username}.png"));
let _ = polyio::write(&target, skin_png).await;
let cached_dir = mc_dir.join("cachedImages").join("skins");
let _ = polyio::create_dir_all(&cached_dir).await;
let _ = polyio::write(&cached_dir.join(format!("{username}.png")), skin_png).await;
let cfg_dir = mc_dir.join("config").join("offlineskins");
let _ = polyio::create_dir_all(&cfg_dir).await;
let _ = polyio::write(&cfg_dir.join(format!("{username}.png")), skin_png).await;
}
Ok(())
}
async fn remove_custom_skin_loader(username: &str) -> Result<(), String> {
if let Ok(mc_dir) = shared_minecraft_dir() {
let target = mc_dir
.join("CustomSkinLoader")
let target1 = mc_dir
.join("cachedImages")
.join("skins")
.join(format!("{username}.png"));
if target.exists() {
let _ = polyio::remove_file(&target).await;
if target1.exists() {
let _ = polyio::remove_file(&target1).await;
}
let target2 = mc_dir
.join("config")
.join("offlineskins")
.join(format!("{username}.png"));
if target2.exists() {
let _ = polyio::remove_file(&target2).await;
}
}
Ok(())

Binary file not shown.

View file

@ -267,6 +267,20 @@ async fn start(
tracing::warn!(cluster_id, error = %err, "failed to materialize cluster content");
}
let mods_dir = paths::cluster_mods_dir(&cluster.folder_name)?;
if account.is_offline() {
crate::game::skin_server::disable_incompatible_offline_mods(&[mods_dir.clone(), cwd.join("mods")]).await;
if cluster.mc_loader == oneclient_common::domain::GameLoader::Fabric {
if let Ok(mod_jar) = crate::game::skin_server::prepare_offline_skins_mod().await {
let target_mod = mods_dir.join("offlineskins-fabric.jar");
if !target_mod.exists() {
let _ = polyio::copy(&mod_jar, &target_mod).await;
}
}
}
}
if !dedicated {
crate::game::link_cluster_logs(&cluster, &cwd).await;
}
@ -302,7 +316,6 @@ async fn start(
java.major,
)?;
let mods_dir = paths::cluster_mods_dir(&cluster.folder_name)?;
if let Some(arg) = crate::game::mods_folder_argument(
cluster.mc_loader,
loader_version_id,
@ -323,6 +336,11 @@ async fn start(
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 Some(parent) = mods_dir.parent()
&& parent != cwd
{
crate::game::skin_server::sync_offline_skins(parent, &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
@ -334,13 +352,9 @@ async fn start(
);
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(

View file

@ -19,6 +19,7 @@ type MojangCacheEntry = (Option<String>, Instant);
type MojangCacheMap = HashMap<String, MojangCacheEntry>;
static AUTHLIB_INJECTOR_BYTES: &[u8] = include_bytes!("../../assets/authlib-injector.jar");
static OFFLINE_SKINS_MOD_BYTES: &[u8] = include_bytes!("../../assets/offlineskins-fabric.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();
@ -98,6 +99,34 @@ pub async fn prepare_authlib_injector() -> Result<PathBuf, String> {
Ok(jar_path)
}
/// Ensures the offline skins Fabric mod is extracted to disk from embedded bytes.
pub async fn prepare_offline_skins_mod() -> Result<PathBuf, String> {
let metadata = oneclient_common::paths::data_dir()
.map_err(|e| e.to_string())?
.join("metadata");
let mod_path = metadata.join("offlineskins-fabric.jar");
let is_valid = if let Ok(meta) = tokio::fs::metadata(&mod_path).await {
meta.len() > 100_000
} else {
false
};
if !is_valid {
if let Some(parent) = mod_path.parent() {
polyio::create_dir_all(parent)
.await
.map_err(|e| e.to_string())?;
}
polyio::write(&mod_path, OFFLINE_SKINS_MOD_BYTES)
.await
.map_err(|e| e.to_string())?;
tracing::info!(path = %mod_path.display(), "extracted embedded offlineskins mod jar");
}
Ok(mod_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() {
@ -153,6 +182,8 @@ async fn handle_connection(mut stream: TcpStream, port: u16) -> Result<(), std::
let method = parts.next().unwrap_or("");
let uri = parts.next().unwrap_or("");
tracing::info!(method, uri, "skin_server incoming request");
if method != "GET" && method != "HEAD" && method != "POST" {
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?;
@ -279,7 +310,7 @@ async fn handle_connection(mut stream: TcpStream, port: u16) -> Result<(), std::
}
pub fn to_dashed_uuid(s: &str) -> String {
let clean = s.replace('-', "");
let clean = s.replace('-', "").to_lowercase();
if clean.len() == 32 {
format!(
"{}-{}-{}-{}-{}",
@ -290,12 +321,12 @@ pub fn to_dashed_uuid(s: &str) -> String {
&clean[20..32]
)
} else {
s.to_string()
s.to_lowercase()
}
}
pub fn to_undashed_uuid(s: &str) -> String {
s.replace('-', "")
s.replace('-', "").to_lowercase()
}
async fn find_skin_path(uuid_or_name: &str) -> Option<PathBuf> {
@ -318,6 +349,32 @@ async fn find_skin_path(uuid_or_name: &str) -> Option<PathBuf> {
return Some(p);
}
if let Some(accounts) = REGISTERED_ACCOUNTS.get() {
let read = accounts.read();
if let Some(reg) = read
.get(&dashed)
.or_else(|| read.get(&undashed))
.or_else(|| read.get(&uuid_or_name.to_lowercase()))
{
if let Ok(p) = skin_file_path(&reg.uuid)
&& p.exists()
{
return Some(p);
}
if let Ok(p) = skin_file_path(&reg.undashed_uuid)
&& p.exists()
{
return Some(p);
}
if let Ok(dir) = skins_dir() {
let name_path = dir.join(format!("{}.png", reg.username));
if name_path.exists() {
return Some(name_path);
}
}
}
}
if let Ok(dir) = skins_dir() {
let name_path = dir.join(format!("{uuid_or_name}.png"));
if name_path.exists() {
@ -351,7 +408,7 @@ async fn resolve_username(uuid_input: &str) -> String {
&& let Some(users) = val.get("users").and_then(|u| u.as_object())
{
for (id, user) in users {
if (id == &dashed || id.replace('-', "") == undashed)
if (id == &dashed || id.replace('-', "").to_lowercase() == undashed)
&& let Some(name) = user.get("username").and_then(|n| n.as_str())
{
return name.to_string();
@ -362,27 +419,27 @@ async fn resolve_username(uuid_input: &str) -> String {
"Player".to_string()
}
async fn read_skin_metadata(uuid_or_name: &str) -> bool {
let dashed = to_dashed_uuid(uuid_or_name);
let undashed = to_undashed_uuid(uuid_or_name);
let lower = uuid_or_name.to_lowercase();
for key in [&dashed, &undashed, &lower] {
if let Ok(meta_p) = skin_meta_path(key)
&& meta_p.exists()
&& let Ok(s) = polyio::read_to_string(&meta_p).await
&& let Ok(m) = serde_json::from_str::<SkinMetadata>(&s)
{
return m.is_slim;
}
}
false
}
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 is_slim = read_skin_metadata(uuid_input).await;
let username = resolve_username(uuid_input).await;
let skin_url = format!("http://127.0.0.1:{port}/textures/{undashed}.png");
@ -410,31 +467,43 @@ async fn build_profile_response(uuid_input: &str, port: u16) -> Option<String> {
)
};
tracing::info!(uuid = %uuid_input, username = %username, is_slim, "built profile response with signed skin");
Some(profile_json)
}
/// Disables mods like `secureskins` and `customskinloader` that interfere with offline skin support or cause startup crashes.
pub async fn disable_incompatible_offline_mods(dirs: &[PathBuf]) {
for dir in dirs {
if let Ok(mut entries) = tokio::fs::read_dir(dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name().to_string_lossy().to_string();
let lower = name.to_lowercase();
if (lower.contains("secureskins") || lower.contains("customskinloader")) && name.ends_with(".jar") {
let path = entry.path();
let disabled = path.with_extension("jar.disabled");
tracing::info!(path = %path.display(), "disabling incompatible offline skin mod");
let _ = tokio::fs::remove_file(&disabled).await;
let _ = tokio::fs::rename(&path, &disabled).await;
}
}
}
}
// Clean up residual CustomSkinLoader core jars that break modern Fabric / Sponge Mixin
if let Ok(shared) = shared_minecraft_dir() {
let csl_core = shared.join("CustomSkinLoader").join("Core");
if csl_core.exists() {
let _ = tokio::fs::remove_dir_all(&csl_core).await;
}
}
}
/// Builds the `--userProperties` JSON map for launch arguments with signed textures.
pub async fn build_user_properties(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 is_slim = read_skin_metadata(uuid_input).await;
let username = resolve_username(uuid_input).await;
let skin_url = format!("http://127.0.0.1:{port}/textures/{undashed}.png");
@ -503,19 +572,20 @@ async fn fetch_mojang_profile(uuid_str: &str) -> Option<String> {
result
}
/// Synchronizes custom skin to various mod directory layouts (CustomSkinLoader, OfflineSkins, etc.)
/// Synchronizes custom skin to mod directory layouts (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 undashed = to_undashed_uuid(account_uuid);
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("cachedImages").join("skins").join(format!("{account_username}.png")),
game_dir.join("cachedImages").join("skins").join("uuid").join(format!("{account_uuid}.png")),
game_dir.join("cachedImages").join("skins").join("uuid").join(format!("{undashed}.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 {
@ -529,8 +599,11 @@ pub async fn sync_offline_skins(
&& shared != game_dir
{
let shared_targets = [
shared.join("CustomSkinLoader").join("skins").join(format!("{account_username}.png")),
shared.join("cachedImages").join("skins").join(format!("{account_username}.png")),
shared.join("cachedImages").join("skins").join("uuid").join(format!("{account_uuid}.png")),
shared.join("cachedImages").join("skins").join("uuid").join(format!("{undashed}.png")),
shared.join("config").join("offlineskins").join(format!("{account_username}.png")),
shared.join("config").join("offlineskins").join(format!("{account_uuid}.png")),
];
for target in shared_targets {
if let Some(parent) = target.parent() {
@ -548,20 +621,28 @@ 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",
"enableTransparentSkin": true,
"loadlist": [
{
"name": "LocalSkin",
"type": "CustomSkinLoader",
"root": "CustomSkinLoader/skins/{USERNAME}.png"
"type": "Legacy",
"skin": "LocalSkin/skins/{USERNAME}.png",
"cape": "LocalSkin/capes/{USERNAME}.png",
"elytra": "LocalSkin/elytras/{USERNAME}.png",
"model": "auto"
},
{
"name": "LocalSkinRoot",
"type": "Legacy",
"skin": "skins/{USERNAME}.png",
"cape": "capes/{USERNAME}.png",
"elytra": "elytras/{USERNAME}.png",
"model": "auto"
},
{
"name": "Mojang",
"type": "Mojang"
},
{
"name": "Ely.by",
"type": "ElyBy"
},
{
"name": "SkinRestorer",
"type": "CustomSkinAPI",