diff --git a/src/main/Program.cs b/src/main/Program.cs index 6df7de7..ab4aa34 100644 --- a/src/main/Program.cs +++ b/src/main/Program.cs @@ -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 }; diff --git a/src/main/core/auth/Yggdrasil.cs b/src/main/core/auth/Yggdrasil.cs index 7f972f0..f5e0c09 100644 --- a/src/main/core/auth/Yggdrasil.cs +++ b/src/main/core/auth/Yggdrasil.cs @@ -18,11 +18,22 @@ public record AuthenticateResponse( [property: JsonPropertyName("selectedProfile")] ProfileRecord SelectedProfile ); -public record UserRecord( - string Username, - List Properties, - string? Id -); +public record UserRecord { + [JsonPropertyName("username")] + public string Username { get; set; } + + [JsonPropertyName("properties")] + public List Properties { get; init; } + + [JsonPropertyName("id")] + public string? Id { get; init; } + + public UserRecord(string Username, List Properties, string? Id) { + this.Username = Username; + this.Properties = Properties; + this.Id = Id; + } +} public record UserProperty( string Name, diff --git a/src/main/utils/MojangAPI.cs b/src/main/utils/MojangAPI.cs new file mode 100644 index 0000000..3a26d37 --- /dev/null +++ b/src/main/utils/MojangAPI.cs @@ -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 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 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(jsonResponse, _jsonOptions)!; + } else { + throw new Exception($"API Error: {response.StatusCode}"); + } + } + + public static async Task 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(jsonResponse, _jsonOptions)!; + } else { + throw new Exception($"Échec du retrait de la cape : {response.StatusCode}"); + } + } + + public static async Task 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(jsonResponse, _jsonOptions)!; + } else { + throw new Exception($"Erreur lors de l'équipement de la cape : {response.StatusCode}"); + } + } + + public static async Task 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(jsonResponse, _jsonOptions)!; + } + else { + throw new Exception($"Erreur lors du changement de pseudo (Code: {response.StatusCode})"); + } + } +} \ No newline at end of file diff --git a/wwwroot/assets/css/logged.css b/wwwroot/assets/css/logged.css index 81b03d7..64abd41 100644 --- a/wwwroot/assets/css/logged.css +++ b/wwwroot/assets/css/logged.css @@ -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); } \ No newline at end of file diff --git a/wwwroot/assets/img/blankCape.png b/wwwroot/assets/img/blankCape.png new file mode 100644 index 0000000..8c1fb87 Binary files /dev/null and b/wwwroot/assets/img/blankCape.png differ diff --git a/wwwroot/assets/js/logged.js b/wwwroot/assets/js/logged.js index 9a8e704..8f78c5b 100644 --- a/wwwroot/assets/js/logged.js +++ b/wwwroot/assets/js/logged.js @@ -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") \ No newline at end of file diff --git a/wwwroot/logged.html b/wwwroot/logged.html index bf3d116..57a5393 100644 --- a/wwwroot/logged.html +++ b/wwwroot/logged.html @@ -43,11 +43,11 @@ Pseudo

- Tu veux changer de pseudo l'ami ? C'est ici + Tu veux changer de blaze mon frérot ? Ça se passe ici.

-
@@ -57,8 +57,13 @@ Capes

- T'a froid ? Couvre toi un peu mon frère + Tu te sens nu ? Viens t'couvrir avec une cape mon reuf.

+
+
+ +
+

@@ -67,6 +72,11 @@

Tu veux rafraîchir un peu ton style ? Par là

+
+

+ Clique ici frérot +

+

@@ -152,7 +162,7 @@ - +