Add MojangAPI integration for skin and cape management

Introduces MojangAPI utility for handling skin uploads, cape management, and username changes via new backend endpoints. Updates Program.cs to support new IPC commands for skin selection, uploading, cape toggling, and username changes. Refactors frontend (logged.js, logged.html, logged.css) to provide UI for skin uploads, cape selection, and username changes, including new styles and blank cape asset. Refactors UserRecord to allow username mutation.
This commit is contained in:
Gilles Lazures 2026-01-24 17:11:59 +01:00
parent 2e2b126df8
commit 4ffe3b47c2
7 changed files with 417 additions and 39 deletions

View File

@ -49,10 +49,6 @@ class Program {
payload = "v1.0.0-alpha";
break;
case "launcher::profile":
payload = _authenticatedPlayer;
break;
case "window::close":
pw.Close();
break;
@ -76,21 +72,25 @@ class Program {
}
payload = result;
break;
case "auth::logout":
_authenticatedPlayer = null;
pw.Load("wwwroot/login.html");
WindowHelper.MakeLoginWindow(pw);
payload = true;
break;
case "dialog::error":
string title = jsonPayload.TryGetProperty("title", out var t) ? t.GetString()! : "Lentia";
string msg = jsonPayload.GetProperty("message").GetString()!;
pw.ShowMessage(title, msg);
payload = new { success = true };
break;
case "settings::read":
payload = SettingsManager.ReadSettings();
break;
case "settings::set":
if (jsonPayload.TryGetProperty("key", out var keyElement) && jsonPayload.TryGetProperty("value", out var valueElement)) {
string key = keyElement.GetString()!;
@ -112,6 +112,82 @@ class Program {
}
}
break;
case "dialog::selectSkin":
var imageFilter = new (string Name, string[] Extensions)[] {
("Portable Network Graphic Image", new[] { "*.png" })
};
string[] files = window.ShowOpenFile("Choisis ton plus beau skin mon reuf", null, false, imageFilter);
payload = new {
success = files != null && files.Length > 0,
path = (files != null && files.Length > 0) ? files[0] : null
};
break;
case "network::uploadSkin":
try {
string path = jsonPayload.GetProperty("path").GetString()!;
string variant = jsonPayload.GetProperty("variant").GetString()!;
string token = _authenticatedPlayer?.AccessToken!;
string skinResult = await MojangAPI.UploadSkinAsync(path, variant, token);
payload = new { success = true };
} catch (Exception ex) {
payload = new { success = false, error = ex.Message };
}
break;
case "network::getPlayer":
try {
var profileData = await MojangAPI.GetPlayerProfileAsync(_authenticatedPlayer!.AccessToken);
payload = profileData;
} catch (Exception ex) {
payload = new { success = false, error = ex.Message };
}
break;
case "network::hideCape":
try {
var hideCapeResult = await MojangAPI.HideCapeAsync(_authenticatedPlayer!.AccessToken);
payload = new { success = true, data = hideCapeResult };
} catch (Exception ex) {
payload = new { success = false, error = ex.Message };
}
break;
case "network::showCape":
try {
string token = _authenticatedPlayer?.AccessToken!;
string capeId = jsonPayload.GetProperty("capeId").GetString()!;
var showCapeResult = await MojangAPI.ShowCapeAsync(token, capeId);
payload = new { success = true, data = showCapeResult };
} catch (Exception ex) {
payload = new { success = false, error = ex.Message };
}
break;
case "network::changeUsername":
try {
string newName = jsonPayload.GetProperty("username").GetString()!;
string token = _authenticatedPlayer?.AccessToken!;
if (string.IsNullOrEmpty(token)) throw new Exception("Session expirée ou invalide.");
var updatedProfile = await MojangAPI.ChangeUsernameAsync(newName, token);
if (_authenticatedPlayer != null) {
_authenticatedPlayer.User.Username = newName;
}
payload = new { success = true, profile = updatedProfile };
} catch (Exception ex) {
payload = new { success = false, error = ex.Message };
}
break;
}
var response = new { requestId, payload };

View File

@ -18,11 +18,22 @@ public record AuthenticateResponse(
[property: JsonPropertyName("selectedProfile")] ProfileRecord SelectedProfile
);
public record UserRecord(
string Username,
List<UserProperty> Properties,
string? Id
);
public record UserRecord {
[JsonPropertyName("username")]
public string Username { get; set; }
[JsonPropertyName("properties")]
public List<UserProperty> Properties { get; init; }
[JsonPropertyName("id")]
public string? Id { get; init; }
public UserRecord(string Username, List<UserProperty> Properties, string? Id) {
this.Username = Username;
this.Properties = Properties;
this.Id = Id;
}
}
public record UserProperty(
string Name,

View File

@ -0,0 +1,99 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace Lentia.Utils;
public static class MojangAPI {
private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
public static async Task<string> UploadSkinAsync(string filePath, string variant, string token) {
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var content = new MultipartFormDataContent();
content.Add(new StringContent(variant), "variant");
byte[] fileBytes = await File.ReadAllBytesAsync(filePath);
var fileContent = new ByteArrayContent(fileBytes);
fileContent.Headers.ContentType = MediaTypeHeaderValue.Parse("image/png");
content.Add(fileContent, "file", Path.GetFileName(filePath));
var response = await client.PostAsync("https://yggdrasil.azures.fr/minecraftservices/minecraft/profile/skins", content);
if (response.IsSuccessStatusCode) {
return await response.Content.ReadAsStringAsync();
} else {
throw new Exception($"Erreur serveur: {response.StatusCode}");
}
}
public static async Task<object> GetPlayerProfileAsync(string accessToken) {
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
client.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
var response = await client.GetAsync("https://yggdrasil.azures.fr/minecraftservices/minecraft/profile");
if (response.IsSuccessStatusCode) {
string jsonResponse = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<object>(jsonResponse, _jsonOptions)!;
} else {
throw new Exception($"API Error: {response.StatusCode}");
}
}
public static async Task<object> HideCapeAsync(string accessToken) {
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var response = await client.DeleteAsync("https://yggdrasil.azures.fr/minecraftservices/minecraft/profile/capes/active");
if (response.IsSuccessStatusCode) {
string jsonResponse = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<object>(jsonResponse, _jsonOptions)!;
} else {
throw new Exception($"Échec du retrait de la cape : {response.StatusCode}");
}
}
public static async Task<object> ShowCapeAsync(string accessToken, string capeId) {
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var body = new { capeId = capeId };
string jsonBody = JsonSerializer.Serialize(body);
var content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
var response = await client.PutAsync("https://yggdrasil.azures.fr/minecraftservices/minecraft/profile/capes/active", content);
if (response.IsSuccessStatusCode) {
string jsonResponse = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<object>(jsonResponse, _jsonOptions)!;
} else {
throw new Exception($"Erreur lors de l'équipement de la cape : {response.StatusCode}");
}
}
public static async Task<object> ChangeUsernameAsync(string newName, string accessToken) {
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var url = $"https://yggdrasil.azures.fr/minecraftservices/minecraft/profile/name/{newName}";
var request = new HttpRequestMessage(HttpMethod.Put, url);
var response = await client.SendAsync(request);
if (response.IsSuccessStatusCode) {
string jsonResponse = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<object>(jsonResponse, _jsonOptions)!;
}
else {
throw new Exception($"Erreur lors du changement de pseudo (Code: {response.StatusCode})");
}
}
}

View File

@ -252,6 +252,11 @@ canvas#skin {
div.profile > section.cosmectics > article {
margin-bottom: 40px;
height: calc((100% / 2) - (103px + 10px));
}
div.profile > section.cosmectics > article.username {
height: 103px;
}
div.profile > section.cosmectics > article.username > div > button {
@ -273,4 +278,59 @@ div.profile > section.cosmectics > article.username > div > button:hover {
div.profile > section.cosmectics > article.username > div > input {
height: 38px;
border-radius: 5px;
}
div.profile > section.cosmectics > article.skin > div.skinUpload {
margin-top: 10px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: calc(100% - 20px);
border: 4px dashed #7c7c7c;
border-radius: 5px;
background-color: #1d1d1dce;
}
div.profile > section.cosmectics > article.skin > div.skinUpload > * {
filter: brightness(0.90);
font-weight: 500;
}
div.profile > section.cosmectics > article.capes > div.capes {
width: 100%;
height: calc(100% - 40px);
overflow-x: auto;
overflow-y: hidden;
border-radius: 5px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 10px;
padding: 10px;
}
div.profile > section.cosmectics > article.capes > div.capes > div.cape {
width: 100%;
aspect-ratio: 10 / 16;
background-color: #b6b6b61f;
border-radius: 3px;
position: relative;
cursor: pointer;
filter: brightness(0.75);
transition: .3s;
background-repeat: no-repeat;
image-rendering: pixelated;
background-size: 640% 200%;
background-position: 1.8% 6%;
}
div.profile > section.cosmectics > article.capes > div.capes > div.cape:hover {
filter: brightness(0.85);
}
div.profile > section.cosmectics > article.capes > div.capes > div.cape.active,
div.profile > section.cosmectics > article.capes > div.capes > div.cape.active:hover {
filter: brightness(1);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 B

View File

@ -1,16 +1,20 @@
const player = await system.call("launcher::profile")
const buttons = document.querySelectorAll("button[frame]")
const dynmapFrame = document.querySelector("article.frame.dynmap > iframe")
const capesSelector = document.querySelector("article.capes > div.capes")
const skinViewer = new skinview3d.SkinViewer({
let viewerInstance = new skinview3d.SkinViewer({
canvas: document.getElementById("skin"),
width: 390,
height: 490,
skin: "assets/img/debug_skin.png"
skin: "https://yggdrasil.azures.fr/textures/texture/steve.png"
})
skinViewer.animation = new skinview3d.IdleAnimation()
skinViewer.animation.speed = 1
viewerInstance.animation = new skinview3d.IdleAnimation()
viewerInstance.animation.speed = 1
function wait(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
function setActiveButton(frameIdentifier) {
for (const button of buttons) {
@ -27,22 +31,25 @@ function fixedTo(number, n) {
return Number.isInteger(result) ? result.toFixed(2) : result
}
function flattenSettings(obj, prefix = "") {
return Object.keys(obj).reduce((acc, k) => {
const pre = prefix.length ? prefix + ".": "";
if (typeof obj[k] === "object" && obj[k] !== null && !Array.isArray(obj[k])) {
Object.assign(acc, flattenSettings(obj[k], pre + k));
} else {
acc[pre + k] = obj[k];
}
return acc;
}, {});
window.getPlayer = async function getPlayer() {
const result = await system.call("network::getPlayer")
console.log(result)
if (result.error) {
console.error("Impossible de récupéré le profil")
return null
}
return result
}
window.profile = await getPlayer()
window.refreshProfile = async function refreshProfile() {
window.profile = await getPlayer()
}
window.setting = {}
window.setting.set = async function settingSet(key, value) {
console.log(key, value)
await system.call("settings::set", { key, value })
}
@ -83,30 +90,145 @@ window.initSettings = async function initSettings() {
window.initSkin = async function initSkin() {
const container = document.querySelector(".skinview3d")
if (container.clientWidth === 0 || container.clientHeight === 0) {
requestAnimationFrame(initSkin)
return
}
const skinViewer = new skinview3d.SkinViewer({
viewerInstance = new skinview3d.SkinViewer({
canvas: document.getElementById("skin"),
width: container.clientWidth,
height: container.clientHeight,
skin: "assets/img/debug_skin.png"
skin: `https://yggdrasil.azures.fr/textures/${profile.skins.find(skin => skin.state == "ACTIVE").url}`.replace(/\/+$/, "")
})
const activeCape = window.profile.capes.find(s => s.state === "ACTIVE") || null
const capeUrl = activeCape == null ? null : `https://yggdrasil.azures.fr/textures/${activeCape.url.replace(/^\//, "")}?t=${Date.now()}`
viewerInstance.animation = new skinview3d.IdleAnimation()
viewerInstance.nameTag = profile.name
viewerInstance.zoom = 0.7
viewerInstance.loadCape(capeUrl)
const ro = new ResizeObserver(() => {
skinViewer.width = container.clientWidth
skinViewer.height = container.clientHeight
viewerInstance.width = container.clientWidth
viewerInstance.height = container.clientHeight
})
skinViewer.animation = new skinview3d.IdleAnimation()
window.skinViewer = skinViewer
ro.observe(container)
}
initSkin()
initSettings()
window.selectSkin = async function selectSkin() {
const skin = await system.call("dialog::selectSkin")
if (!skin.success) {
return iziToast.error({ title: "Erreur", message: "Impossible d'envoyer le skin" })
}
return validateSkinSelection(skin.path)
}
window.validateSkinSelection = async function validateSkinSelection(localPath) {
const isSlim = confirm("Modèle Slim ?")
const variant = isSlim ? "slim" : "classic"
const result = await system.call("network::uploadSkin", {
path: localPath,
variant: variant
})
if (result.success && viewerInstance) {
await wait(500)
await refreshProfile()
const activeSkin = window.profile.skins.find(s => s.state === "ACTIVE")
const activeCape = window.profile.capes.find(s => s.state === "ACTIVE") || null
if (activeSkin) {
const skinUrl = `https://yggdrasil.azures.fr/textures/${activeSkin.url.replace(/^\//, "")}?t=${Date.now()}`
const capeUrl = activeCape == null ? null : `https://yggdrasil.azures.fr/textures/${activeCape.url.replace(/^\//, "")}?t=${Date.now()}`
await viewerInstance.loadSkin(skinUrl, {
model: variant.toLowerCase() === "slim" ? "slim" : "default"
})
await viewerInstance.loadCape(capeUrl)
}
}
}
window.initCapesSelector = async function initCapesSelector() {
capesSelector.innerHTML = ""
const blankCape = document.createElement("div")
blankCape.classList.add("cape")
blankCape.setAttribute("onclick", `hideCape()`)
blankCape.title = "Cacher ma cape"
capesSelector.appendChild(blankCape)
await viewerInstance.loadCape(null)
for (const cape of profile.capes) {
const $cape = document.createElement("div")
$cape.classList.add("cape")
$cape.setAttribute("title", cape.alias)
$cape.setAttribute("style", `background-image: url("https://yggdrasil.azures.fr/textures/${cape.url.replace(/^\//, "")}?t=${Date.now()}") !important;`)
$cape.setAttribute("onclick", `showCape("${cape.id}")`)
if (cape.state == "ACTIVE") {
$cape.classList.add("active")
await viewerInstance.loadCape(`https://yggdrasil.azures.fr/textures/${cape.url.replace(/^\//, "")}?t=${Date.now()}`)
}
capesSelector.appendChild($cape)
}
}
window.showCape = async function showCape(id) {
const result = await system.call("network::showCape", { capeId: id })
if (result.success) {
await refreshProfile()
await initCapesSelector()
if (typeof viewerInstance !== "undefined" && window.profile) {
const activeCape = window.profile.capes.find(c => c.state === "ACTIVE")
if (activeCape) {
const fullUrl = `https://yggdrasil.azures.fr/textures/${activeCape.url.replace(/^\//, "")}?t=${Date.now()}`
await viewerInstance.loadCape(fullUrl)
}
}
return result.data
} else {
alert("Erreur d'équipement : " + result.error)
}
}
window.hideCape = async function hideCape() {
const result = await system.call("network::hideCape")
if (result.success) {
await refreshProfile()
await initCapesSelector()
if (typeof viewerInstance !== "undefined") {
viewerInstance.loadCape(null)
}
return result.data
} else {
alert("Erreur lors du retrait : " + result.error)
}
}
window.changeUsername = async function changeUsername(newName) {
if (!newName || newName.length < 3) return alert("Pseudo trop court !");
const result = await system.call("network::changeUsername", {
username: newName
})
if (result.success) {
viewerInstance.nameTag = result.profile.name
window.profile = result.profile
} else {
alert("Erreur : " + result.error)
}
}
await initSkin()
await initSettings()
await initCapesSelector()
showFrame("profile")

View File

@ -43,11 +43,11 @@
Pseudo
</h2>
<p>
Tu veux changer de pseudo l'ami ? C'est ici
Tu veux changer de blaze mon frérot ? Ça se passe ici.
</p>
<div>
<input type="text" name="usernameChange" id="usernameChange" placeholder="Jeb_">
<button>
<button onclick="changeUsername(usernameChange.value)">
Valider
</button>
</div>
@ -57,8 +57,13 @@
Capes
</h2>
<p>
T'a froid ? Couvre toi un peu mon frère
Tu te sens nu ? Viens t'couvrir avec une cape mon reuf.
</p>
<div class="capes">
<div id="blank" class="cape">
</div>
</div>
</article>
<article class="skin">
<h2>
@ -67,6 +72,11 @@
<p>
Tu veux rafraîchir un peu ton style ? Par là
</p>
<div class="skinUpload" onclick="selectSkin()">
<h3>
Clique ici frérot
</h3>
</div>
</article>
</section>
</div>
@ -152,7 +162,7 @@
</aside>
</main>
<script type="module" crossorigin src="./assets/js/skinview3d.bundle.js"></script>
<script type="module" src="./assets/js/skinview3d.bundle.js"></script>
<script src="./assets/js/ipc.js"></script>
<script src="./assets/js/common.js"></script>
<script src="./assets/js/frames.js"></script>