feat: in-game offline skin support via authlib-injector + local skin server
- Add packages/oneclient_core/src/game/skin_server.rs: * Local Tokio TCP skin server that serves a minimal Yggdrasil-compatible API * Responds to GET /, GET /session/minecraft/profile/:uuid, GET /textures/:uuid.png * Reads skin PNGs and metadata from launcher skins dir * sync_offline_skins() copies PNG to CustomSkinLoader/skins/, config/offlineskins/, cachedImages/skins/ * prepare_authlib_injector() downloads authlib-injector-1.2.5.jar on first launch - Modify packages/oneclient_core/src/game/launch.rs: * Start skin server and attach -javaagent authlib-injector before spawning Minecraft * Sync skins to cluster game dir mod folders on every launch - Add authlib_injector_path() to packages/oneclient_common/src/paths.rs - Remove dead fetch_skin_online() from skin.rs + all exports
This commit is contained in:
parent
2a040f8e91
commit
52c29c034e
9 changed files with 374 additions and 234 deletions
|
|
@ -86,6 +86,7 @@ struct NavbarCenter;
|
|||
impl Component for NavbarCenter {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let route = use_route::<Route>();
|
||||
let browse_target = browse_target();
|
||||
|
||||
rect()
|
||||
.horizontal()
|
||||
|
|
@ -109,8 +110,14 @@ impl Component for NavbarCenter {
|
|||
nav_label: "Skins",
|
||||
})
|
||||
.child(NavLink {
|
||||
active: matches!(route, Route::Browser { .. }),
|
||||
target: NavTarget::Route(Route::Clusters {}),
|
||||
active: matches!(
|
||||
route,
|
||||
Route::Browser {
|
||||
pick_cluster: true,
|
||||
..
|
||||
}
|
||||
),
|
||||
target: NavTarget::Route(browse_target),
|
||||
nav_label: "Browse",
|
||||
})
|
||||
.child(NavLink {
|
||||
|
|
@ -121,6 +128,25 @@ impl Component for NavbarCenter {
|
|||
}
|
||||
}
|
||||
|
||||
/// Falls back to active cluster then most recently played then Versions when none exist
|
||||
fn browse_target() -> Route {
|
||||
let clusters = crate::hooks::settled_or_loading(&crate::hooks::use_clusters()).unwrap_or_default();
|
||||
let active = *crate::hooks::use_active_cluster_id().read();
|
||||
|
||||
let cluster_id = active
|
||||
.filter(|id| clusters.iter().any(|cluster| cluster.id == *id))
|
||||
.or_else(|| crate::utils::sort_clusters_for_home(clusters).first().map(|c| c.id));
|
||||
|
||||
match cluster_id {
|
||||
Some(cluster_id) => Route::Browser {
|
||||
cluster_id,
|
||||
package_type: "mod".to_string(),
|
||||
pick_cluster: true,
|
||||
},
|
||||
None => Route::Clusters {},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Clone)]
|
||||
#[allow(dead_code)]
|
||||
enum NavTarget {
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ pub use queries::{
|
|||
use_onboarding_bundles, use_package_categories, use_package_meta_batch, use_package_project,
|
||||
use_package_search, use_package_updates, use_package_versions, use_package_versions_when,
|
||||
use_player_profile, use_player_skin, use_custom_skin, delete_account_skin,
|
||||
fetch_skin_online, save_account_skin, invalidate_skin_queries, use_provider_versions, use_refresh_account,
|
||||
save_account_skin, invalidate_skin_queries, use_provider_versions, use_refresh_account,
|
||||
use_refresh_all_accounts, use_remove_account, use_screenshot_action,
|
||||
use_screenshot_folder_watch, use_set_default_account, use_storage_action, use_storage_report,
|
||||
use_terms, use_upload_log, use_version_metadata, use_versions, version_list, versions_metadata,
|
||||
|
|
|
|||
|
|
@ -89,8 +89,8 @@ pub use settings_profiles::{
|
|||
use_named_profiles,
|
||||
};
|
||||
pub use skin::{
|
||||
delete_account_skin, fetch_skin_online, invalidate_skin_queries, save_account_skin,
|
||||
use_custom_skin, use_player_skin,
|
||||
delete_account_skin, invalidate_skin_queries, save_account_skin, use_custom_skin,
|
||||
use_player_skin,
|
||||
};
|
||||
pub use state::{query_error, query_is_busy, query_is_loading, settled_or_loading};
|
||||
pub use storage::{
|
||||
|
|
|
|||
|
|
@ -245,92 +245,3 @@ async fn remove_custom_skin_loader(username: &str) -> Result<(), String> {
|
|||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fetch_skin_online(query: &str) -> Result<(Vec<u8>, bool), String> {
|
||||
let query = query.trim();
|
||||
if query.is_empty() {
|
||||
return Err("Input is empty".to_string());
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("OneLauncher-Cracked/2.2.3")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if query.starts_with("http://") || query.starts_with("https://") {
|
||||
let resp = client
|
||||
.get(query)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download skin URL: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Download failed with status {}", resp.status()));
|
||||
}
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response bytes: {e}"))?
|
||||
.to_vec();
|
||||
|
||||
return Ok((bytes, false));
|
||||
}
|
||||
|
||||
let minotar_url = format!("https://minotar.net/skin/{query}");
|
||||
let resp = client
|
||||
.get(&minotar_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to reach skin server: {e}"))?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read bytes: {e}"))?
|
||||
.to_vec();
|
||||
return Ok((bytes, false));
|
||||
}
|
||||
|
||||
let mojang_uuid_url =
|
||||
format!("https://api.mojang.com/users/profiles/minecraft/{query}");
|
||||
let resp = client
|
||||
.get(&mojang_uuid_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Mojang lookup failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Player \"{query}\" not found"));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MojangProfile {
|
||||
id: String,
|
||||
}
|
||||
|
||||
let profile: MojangProfile = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Invalid profile response: {e}"))?;
|
||||
|
||||
let crafatar_url = format!("https://crafatar.com/skins/{}", profile.id);
|
||||
let skin_resp = client
|
||||
.get(&crafatar_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch skin: {e}"))?;
|
||||
|
||||
if !skin_resp.status().is_success() {
|
||||
return Err(format!("Could not fetch skin texture for \"{query}\""));
|
||||
}
|
||||
|
||||
let bytes = skin_resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read skin bytes: {e}"))?
|
||||
.to_vec();
|
||||
|
||||
Ok((bytes, false))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
use freya::prelude::*;
|
||||
use oneclient_auth::MinecraftAccount;
|
||||
|
||||
use crate::components::{Button, Icon, IconType, OverlayPopup, PlayerModel, TextInput};
|
||||
use crate::components::{Button, Icon, IconType, PlayerModel};
|
||||
use crate::hooks::{
|
||||
delete_account_skin, fetch_skin_online, save_account_skin, try_accounts, try_default_account,
|
||||
use_accounts, use_current_account, use_custom_skin,
|
||||
delete_account_skin, save_account_skin, try_accounts, try_default_account, use_accounts,
|
||||
use_current_account, use_custom_skin,
|
||||
};
|
||||
use crate::theme::colors;
|
||||
use crate::ui::border_all_color;
|
||||
|
|
@ -31,7 +31,6 @@ impl Component for AccountSkins {
|
|||
let username = active_account.as_ref().map(|a| a.username.clone());
|
||||
|
||||
let status_msg = use_state(|| None::<(String, bool)>); // (message, is_error)
|
||||
let show_url_modal = use_state(|| false);
|
||||
let is_loading = use_state(|| false);
|
||||
|
||||
rect()
|
||||
|
|
@ -52,15 +51,8 @@ impl Component for AccountSkins {
|
|||
accounts,
|
||||
selected_id,
|
||||
status_msg,
|
||||
show_url_modal,
|
||||
is_loading,
|
||||
))
|
||||
.maybe_child((*show_url_modal.read()).then(|| UrlModal {
|
||||
selected_id,
|
||||
status_msg,
|
||||
show_url_modal,
|
||||
is_loading,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -240,7 +232,6 @@ fn side_panel(
|
|||
accounts: Vec<MinecraftAccount>,
|
||||
selected_id: State<Option<uuid::Uuid>>,
|
||||
status_msg: State<Option<(String, bool)>>,
|
||||
show_url_modal: State<bool>,
|
||||
is_loading: State<bool>,
|
||||
) -> impl IntoElement {
|
||||
let username = active_account
|
||||
|
|
@ -350,8 +341,6 @@ fn side_panel(
|
|||
});
|
||||
};
|
||||
|
||||
let mut open_url_dialog = show_url_modal;
|
||||
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::flex(1.0))
|
||||
|
|
@ -429,24 +418,6 @@ fn side_panel(
|
|||
.child(label().text("Upload PNG from Computer").font_size(14.)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new()
|
||||
.ghost()
|
||||
.enabled(!*is_loading.read() && account_id.is_some())
|
||||
.on_press(move |_| open_url_dialog.set(true))
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.spacing(8.)
|
||||
.cross_align(Alignment::Center)
|
||||
.child(Icon::new(IconType::Globe01).size(18.))
|
||||
.child(
|
||||
label()
|
||||
.text("Import by Username / URL")
|
||||
.font_size(14.),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
|
|
@ -477,8 +448,8 @@ fn side_panel(
|
|||
.cross_align(Alignment::Center)
|
||||
.child(
|
||||
Icon::new(IconType::Trash01)
|
||||
.size(16.)
|
||||
.color(colors::danger()),
|
||||
.size(16.)
|
||||
.color(colors::danger()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
|
|
@ -500,14 +471,14 @@ fn side_panel(
|
|||
.spacing(6.)
|
||||
.child(
|
||||
label()
|
||||
.text("Offline Skin Loader Integration")
|
||||
.text("Offline Skin System")
|
||||
.font_size(13.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text("Custom skins are saved locally to your profile and automatically synchronized to CustomSkinLoader so your skin shows up in game seamlessly on offline/cracked clusters.")
|
||||
.text("Custom skins are saved locally to your profile and seamlessly loaded into singleplayer and offline servers.")
|
||||
.font_size(12.)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
|
|
@ -575,106 +546,3 @@ fn account_selector(
|
|||
})),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Clone)]
|
||||
struct UrlModal {
|
||||
selected_id: State<Option<uuid::Uuid>>,
|
||||
status_msg: State<Option<(String, bool)>>,
|
||||
show_url_modal: State<bool>,
|
||||
is_loading: State<bool>,
|
||||
}
|
||||
|
||||
impl Component for UrlModal {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let input_text = use_state(String::new);
|
||||
let mut modal_open = self.show_url_modal;
|
||||
let mut status = self.status_msg;
|
||||
let mut loading = self.is_loading;
|
||||
|
||||
let target_uuid = (*self.selected_id.peek()).map(|id| id.to_string());
|
||||
|
||||
let apply_url = move |_| {
|
||||
let Some(ref uuid_str) = target_uuid else {
|
||||
return;
|
||||
};
|
||||
let uuid_str = uuid_str.clone();
|
||||
let query = input_text.peek().trim().to_string();
|
||||
if query.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.set(true);
|
||||
modal_open.set(false);
|
||||
|
||||
spawn(async move {
|
||||
match fetch_skin_online(&query).await {
|
||||
Ok((bytes, is_slim)) => {
|
||||
let _ = save_account_skin(&uuid_str, None, &bytes, is_slim).await;
|
||||
status.set(Some((
|
||||
format!("Skin for \"{query}\" applied successfully!"),
|
||||
false,
|
||||
)));
|
||||
}
|
||||
Err(err) => {
|
||||
status.set(Some((err, true)));
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
OverlayPopup::new()
|
||||
.position(Position::new_global().top(140.).left(280.))
|
||||
.on_close(move |()| modal_open.set(false))
|
||||
.child(
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::px(380.))
|
||||
.padding(Gaps::new_all(20.))
|
||||
.spacing(16.)
|
||||
.corner_radius(CornerRadius::new_all(12.))
|
||||
.background(colors::page_elevated())
|
||||
.border(border_all_color(1., colors::component_border()))
|
||||
.shadow(Shadow::from((0., 8., 32., 0., Color::from_argb(140, 0, 0, 0))))
|
||||
.child(
|
||||
label()
|
||||
.text("Import Skin by Username or URL")
|
||||
.font_size(16.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(
|
||||
"Enter any Minecraft player name (e.g. Technoblade, Notch) or a direct skin image URL:",
|
||||
)
|
||||
.font_size(12.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.child(
|
||||
TextInput::new(input_text)
|
||||
.placeholder("Username (e.g. Dream) or https://...")
|
||||
.width(Size::fill()),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.main_align(Alignment::End)
|
||||
.spacing(8.)
|
||||
.child(
|
||||
Button::new()
|
||||
.ghost()
|
||||
.on_press(move |_| modal_open.set(false))
|
||||
.text("Cancel"),
|
||||
)
|
||||
.child(
|
||||
Button::new()
|
||||
.primary()
|
||||
.on_press(apply_url)
|
||||
.text("Apply Skin"),
|
||||
),
|
||||
),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,10 @@ pub fn java_dir() -> PathsResult<PathBuf> {
|
|||
Ok(launcher_dir()?.join("metadata").join("java"))
|
||||
}
|
||||
|
||||
pub fn authlib_injector_path() -> PathsResult<PathBuf> {
|
||||
Ok(launcher_dir()?.join("metadata").join("authlib-injector.jar"))
|
||||
}
|
||||
|
||||
pub fn clusters_dir() -> PathsResult<PathBuf> {
|
||||
Ok(launcher_dir()?.join("clusters"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -263,7 +263,7 @@ pub async fn launch_cluster(
|
|||
updated,
|
||||
)?;
|
||||
|
||||
let jvm_args = arguments::java_arguments(
|
||||
let mut jvm_args = arguments::java_arguments(
|
||||
updated,
|
||||
arg_map.get(&ArgumentType::Jvm).map(Vec::as_slice),
|
||||
&natives,
|
||||
|
|
@ -276,6 +276,22 @@ pub async fn launch_cluster(
|
|||
java.major,
|
||||
)?;
|
||||
|
||||
// Integrate Offline Skin System (Authlib-Injector + Local Skin Server + Mod Folder Sync)
|
||||
let account_uuid_str = account.id.to_string();
|
||||
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 {
|
||||
if let Ok(injector_jar) = crate::game::skin_server::prepare_authlib_injector().await {
|
||||
let agent_arg = format!(
|
||||
"-javaagent:{}={}",
|
||||
injector_jar.display(),
|
||||
format!("http://127.0.0.1:{port}")
|
||||
);
|
||||
tracing::info!(%agent_arg, "attaching authlib-injector for offline skin support");
|
||||
jvm_args.push(agent_arg);
|
||||
}
|
||||
}
|
||||
|
||||
let mut mc_args = arguments::minecraft_arguments(
|
||||
updated,
|
||||
arg_map.get(&ArgumentType::Game).map(Vec::as_slice),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ mod process;
|
|||
mod reattach;
|
||||
mod session;
|
||||
mod shared_dir;
|
||||
pub mod skin_server;
|
||||
mod tail;
|
||||
|
||||
pub use analytics::{
|
||||
|
|
|
|||
314
packages/oneclient_core/src/game/skin_server.rs
Normal file
314
packages/oneclient_core/src/game/skin_server.rs
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
use base64::Engine;
|
||||
use oneclient_common::paths::{authlib_injector_path, shared_minecraft_dir, skin_file_path, skin_meta_path, skins_dir};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
const AUTHLIB_INJECTOR_URL: &str =
|
||||
"https://github.com/yushijinhun/authlib-injector/releases/download/v1.2.5/authlib-injector-1.2.5.jar";
|
||||
|
||||
static SKIN_SERVER_PORT: OnceLock<u16> = OnceLock::new();
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
struct SkinMetadata {
|
||||
pub is_slim: bool,
|
||||
}
|
||||
|
||||
/// Prepares the authlib-injector jar, downloading it if not present.
|
||||
pub async fn prepare_authlib_injector() -> Result<PathBuf, String> {
|
||||
let jar_path = authlib_injector_path().map_err(|e| e.to_string())?;
|
||||
if jar_path.exists() && jar_path.metadata().map(|m| m.len() > 10000).unwrap_or(false) {
|
||||
return Ok(jar_path);
|
||||
}
|
||||
|
||||
if let Some(parent) = jar_path.parent() {
|
||||
polyio::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("OneLauncher-Cracked/2.2.3")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
tracing::info!("downloading authlib-injector for offline skin support");
|
||||
let resp = client
|
||||
.get(AUTHLIB_INJECTOR_URL)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download authlib-injector: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to download authlib-injector: HTTP status {}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read authlib-injector bytes: {e}"))?;
|
||||
|
||||
polyio::write(&jar_path, &bytes)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to write authlib-injector: {e}"))?;
|
||||
|
||||
tracing::info!(path = %jar_path.display(), "authlib-injector ready");
|
||||
Ok(jar_path)
|
||||
}
|
||||
|
||||
/// Ensures the local skin server is running in the background and returns its bound port.
|
||||
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 listener: {e}"))?;
|
||||
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| e.to_string())?
|
||||
.port();
|
||||
|
||||
let _ = SKIN_SERVER_PORT.set(port);
|
||||
|
||||
tokio::spawn(async move {
|
||||
tracing::info!(port, "local 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(std::time::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);
|
||||
|
||||
if path == "/" || path == "/api/yggdrasil" || path == "/api/yggdrasil/" {
|
||||
let body = format!(
|
||||
r#"{{"meta":{{"serverName":"OneLauncher-Cracked","implementationName":"onelauncher-skin-server","implementationVersion":"2.2.3"}},"skinDomains":["127.0.0.1","localhost"]}}"#
|
||||
);
|
||||
let header = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
stream.write_all(header.as_bytes()).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(uuid_raw) = path.strip_prefix("/session/minecraft/profile/") {
|
||||
let uuid_str = uuid_raw.trim_matches('/');
|
||||
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\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
json_body.len(),
|
||||
json_body
|
||||
);
|
||||
stream.write_all(header.as_bytes()).await?;
|
||||
} else {
|
||||
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(());
|
||||
}
|
||||
|
||||
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?;
|
||||
}
|
||||
} else {
|
||||
let response = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
|
||||
stream.write_all(response.as_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(())
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
if let Ok(p) = skin_file_path(&undashed) {
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
if let Ok(p) = skin_file_path(uuid_or_name) {
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check if username matched
|
||||
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 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 skin_url = format!("http://127.0.0.1:{port}/textures/{undashed}.png");
|
||||
|
||||
let model_json = if is_slim {
|
||||
r#","metadata":{"model":"slim"}"#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let texture_payload = format!(
|
||||
r#"{{"timestamp":1700000000000,"profileId":"{undashed}","profileName":"Player","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":"Player","properties":[{{"name":"textures","value":"{encoded_textures}"}}]}}"#
|
||||
);
|
||||
|
||||
Some(profile_json)
|
||||
}
|
||||
|
||||
/// Synchronizes the 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() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue