diff --git a/patches/0010-Manage-Ely.by-skins-from-the-skin-page.patch b/patches/0010-Manage-Ely.by-skins-from-the-skin-page.patch new file mode 100644 index 0000000..c22e133 --- /dev/null +++ b/patches/0010-Manage-Ely.by-skins-from-the-skin-page.patch @@ -0,0 +1,1448 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Modrinth Enhanced +Date: Tue, 15 Sep 2026 08:49:07 +0200 +Subject: [PATCH] Manage Ely.by skins from the skin page + +An Ely.by account got the Microsoft skin page, which can do nothing with +it. It now gets the account's skins on Ely.by in the same grid: pick one +and apply it, add one from a file (which uploads it to the account), +switch one between the wide and slim model, or delete it. + +Ely.by has no API for this. Its skin system only reads and its OAuth +scopes stop at account info, so the website's own calls are the only way, +and they want the website's session. The launcher makes them from a hidden +window on ely.by and reads each call's outcome back from the window's +address fragment, since a remote page cannot answer over IPC. The website +only shows itself when a call is refused for want of a session: the page +asks the player to sign in there, opens the sign-in, and makes the change +once they have. The window is destroyed after every call and its cookies +keep the sign-in, where a hidden window left open would keep the launcher +running after its main window is closed. + +Reads - the account's catalogue, the worn skin, the textures - go through +Rust, because ely.by sends no CORS headers. + +The website calls and the fallback Steve texture come from Noctrinth +(https://github.com/Everelsu/Noctrinth). +--- + .../src/components/ui/skin/ElySkinModal.vue | 148 ++++++ + .../src/helpers/ely-fallback-skin.ts | 3 + + apps/app-frontend/src/helpers/ely_skins.ts | 77 +++ + apps/app-frontend/src/pages/Skins.vue | 464 +++++++++++++++++- + apps/app/build.rs | 17 + + apps/app/capabilities/plugins.json | 3 +- + apps/app/src/api/ely_skins.rs | 368 ++++++++++++++ + apps/app/src/api/mod.rs | 1 + + apps/app/src/main.rs | 1 + + packages/app-lib/src/api/ely_skins.rs | 127 +++++ + packages/app-lib/src/api/mod.rs | 1 + + packages/app-lib/src/state/minecraft_auth.rs | 4 +- + 12 files changed, 1201 insertions(+), 13 deletions(-) + create mode 100644 apps/app-frontend/src/components/ui/skin/ElySkinModal.vue + create mode 100644 apps/app-frontend/src/helpers/ely-fallback-skin.ts + create mode 100644 apps/app-frontend/src/helpers/ely_skins.ts + create mode 100644 apps/app/src/api/ely_skins.rs + create mode 100644 packages/app-lib/src/api/ely_skins.rs + +diff --git a/apps/app-frontend/src/components/ui/skin/ElySkinModal.vue b/apps/app-frontend/src/components/ui/skin/ElySkinModal.vue +new file mode 100644 +index 0000000..382ad1c +--- /dev/null ++++ b/apps/app-frontend/src/components/ui/skin/ElySkinModal.vue +@@ -0,0 +1,148 @@ ++ ++ ++ ++ +diff --git a/apps/app-frontend/src/helpers/ely-fallback-skin.ts b/apps/app-frontend/src/helpers/ely-fallback-skin.ts +new file mode 100644 +index 0000000..4c47bde +--- /dev/null ++++ b/apps/app-frontend/src/helpers/ely-fallback-skin.ts +@@ -0,0 +1,3 @@ ++/** Steve, for an Ely.by account without a skin of its own. Taken from Noctrinth. */ ++export const ELY_FALLBACK_SKIN = ++ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAE5klEQVR4Xu1aMWsUQRgVVJCAoIIgglaJBG2UGIKCOU0hJHZKijRBsAnaWSjaiNjESgtTpT0bmxQWNvkJ+U9n3ube8ubNt7e5xOzdxX3wmNmZb/fmvflmdtm9M2dqcOfmxR44N325KFnn8ecXDwbSrzdxoODO7auleNTdgHfP5hKeKgNUsJff1zqZeBJ9fr2Jg4vW9Ef5Xxiga9+XAkRWLYFTYYDOuGYDxIMU6psf2r6tL54OA3wPUEIkBFeVfr2Jg4r2DRGk2OWZSwnZ7tcbO2hqU9j0tQtJyjMLlGqCnqPX0WtEcSh9PI0jGiBIIVpX8V7yvEg04/z6aPPxNA4V4QNm2/ar5d7vD+u9P1/eFOWvt2tF2/2ZK1ksyGO2aZ0msM3H0zg4IB2oziSEQjCEUzyJvkhUdE3WGc9jH0/j0IGxxC0M4iACQoHnj76WolEHcIwYtOGc6Fo0NDJoLAzwGaIgJWf89eO7BTUDlBTlM6/7gxqCYx9P4+BAUHLmeQ//8XKxfKDBre390nzBpRtTSQxvecwEvaYb4Mc+nsbBpzYV7yaghGiYwJKiaZCey+vpk+Gt61MJ2e7jGTke3vvaUy4sLBScnZ0t6PEZ9vbK5aHGog19fn2nXy7Dzk6vZLebLNHiN44LHxCF0wiPz7AvUsWrCSdhgO5H+B0PHxo+oKNkwEkbMLW7mxkwVhlwoktgX3S0BI6cAU/mt3sgfhzl04c/S7KNJan957a2EurMYFCl+P7Aq+LBpF2FiuCSbPv0KaWfUwcKPKwB2g9isOc3NwuWgvpCmZbeHsUnIr2N7S4ehOiNjQPSAI2rwyCBnh3eD0LI2Y8fC6Je/nC/9DaPdxPcrEwQ2T3ImNAAPacOgwRWGaB1iiHrBHl8IoyiVISnuJPi1QRlHVwgj6M27SNdUDLjZoDOfmRAtva7B5scyQ1Vy4h6juvN4Ab4DEeG6LGndGlAX4gb4PGeIaX4/rHe4nTH1zYVrfHF5luHyABfBoP6KYr0FNZZ9dgo3s+NRKkRkQFad70ZKKxKYJ0BGKSKKTJAZ5TZ0Bfk8aEBEh8ZoEaoASg9xvVm8PRW4UqPIylIxVG4i2OMxyUGiHjE6Ky6OBqgRniM682wsrLSA31zczLOqaIiM1Sgx5YGiGgVD6pYFxeZ4P2uNwMebzudTiJqdXU1E4oYj0Vdl4gapplDIyjMY3T5aAwyscoAzQw3QGNdbwY+41OcC6TwKA51NYDUZcSSM+rxvtcwhu0qxkWCfmv0GNfbokWLFi1atJgc4AVoFf3xmU+dSUyLFi1atGjRokVT8FdpQ39c1XeE+/S3QB4+dnADhv68bgbwTc+hX3GNGm7AUTOAj7qaARNpwLEyYL+uGTCWS0Df/vpLTu/TfsZkHzODbwS6JDKOGocRyWzwPjD5wqsGDDJB20YNNwClG6D9dQaUH0uqBLsho4YLdJHer22FAXXf9+uoWdI3xt8bVPGfGOjieRy1aR/p3/b9Q4d/8FCiz8fTOKoE+2xrv8arGL3f687vt8Kxui1GAuuWgNINGCRc+1j38TQOF4gyMiAyB4xS3YW7AUofT+Pw9PeZ1vZomURr3EUOygofT+Pgl2QKrKJ/bifrxFcJHxsD+LlcRQ3z/4LIABfsx9rm42kc0f8GVCCFR3GoQ0jVPuBZEfX5eIbFX3srPNN8aUvJAAAAAElFTkSuQmCC' +diff --git a/apps/app-frontend/src/helpers/ely_skins.ts b/apps/app-frontend/src/helpers/ely_skins.ts +new file mode 100644 +index 0000000..a0eb40d +--- /dev/null ++++ b/apps/app-frontend/src/helpers/ely_skins.ts +@@ -0,0 +1,77 @@ ++/** ++ * Ely.by skins for the skin page. ++ * ++ * Reads go to Ely.by through Rust, since ely.by sends no CORS headers. Changes ++ * run on the website in a hidden window and come back as an outcome, where ++ * `signed_out` means the website wants the player signed in there first. ++ */ ++import { arrayBufferToBase64 } from '@modrinth/utils' ++import { invoke } from '@tauri-apps/api/core' ++ ++export interface ElyUploadedSkin { ++ id: number ++ skin_url: string ++ is_slim: boolean ++} ++ ++export type ElyOutcome = ++ | { status: 'done' } ++ | { status: 'signed_out' } ++ | { status: 'failed'; message: string } ++ ++export async function listElySkins(username: string): Promise { ++ return await invoke('plugin:ely-skins|ely_list_skins', { username }) ++} ++ ++export async function getElyCurrentSkinUrl(username: string): Promise { ++ return await invoke('plugin:ely-skins|ely_current_skin_url', { username }) ++} ++ ++const textures = new Map>() ++ ++/** ++ * A texture as a data URL, which a canvas can draw without being tainted. ++ * ++ * Catalogue textures are named after their contents and cached. A cape lives at ++ * the player's name and can change, so it is not. ++ */ ++export function getElyTexture(url: string, cache = true): Promise { ++ const key = url.replace(/^https?:/, '') ++ const cached = cache ? textures.get(key) : undefined ++ if (cached) return cached ++ ++ const texture = invoke('plugin:ely-skins|ely_get_texture', { url }).then( ++ (bytes) => `data:image/png;base64,${arrayBufferToBase64(new Uint8Array(bytes))}`, ++ ) ++ ++ if (cache) { ++ textures.set(key, texture) ++ texture.catch(() => textures.delete(key)) ++ } ++ ++ return texture ++} ++ ++/** Shows the website's sign-in. Resolves to whether the player signed in. */ ++export async function signInToEly(): Promise { ++ return await invoke('plugin:ely-skins|ely_sign_in') ++} ++ ++export async function wearElySkin(skinId: number): Promise { ++ return await invoke('plugin:ely-skins|ely_wear_skin', { skinId }) ++} ++ ++/** Uploads a PNG to the account's catalogue and wears it. */ ++export async function uploadElySkin(texture: Uint8Array): Promise { ++ return await invoke('plugin:ely-skins|ely_upload_skin', { ++ texture: arrayBufferToBase64(texture), ++ }) ++} ++ ++export async function setElySkinModel(skinId: number, slim: boolean): Promise { ++ return await invoke('plugin:ely-skins|ely_edit_skin', { skinId, slim }) ++} ++ ++export async function removeElySkin(skinId: number): Promise { ++ return await invoke('plugin:ely-skins|ely_remove_skin', { skinId }) ++} +diff --git a/apps/app-frontend/src/pages/Skins.vue b/apps/app-frontend/src/pages/Skins.vue +index 8240b8b..bcd79ec 100644 +--- a/apps/app-frontend/src/pages/Skins.vue ++++ b/apps/app-frontend/src/pages/Skins.vue +@@ -4,6 +4,7 @@ import { + EditIcon, + EyeIcon, + InfoIcon, ++ LogInIcon, + RotateCounterClockwiseIcon, + ShirtIcon, + SpinnerIcon, +@@ -31,14 +32,29 @@ import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } + import EarsModIcon from '@/assets/skins/ears-mod.png' + import type AccountsCard from '@/components/ui/AccountsCard.vue' + import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue' ++import ElySkinModal from '@/components/ui/skin/ElySkinModal.vue' + import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue' + import { useAppSettings } from '@/composables/use-app-settings.ts' + import { handleSevereError } from '@/composables/use-error.js' + import { trackEvent } from '@/helpers/analytics' + import { check_reachable, get_default_user, login as login_flow, users } from '@/helpers/auth' + import { cleanupUnusedPreviews } from '@/helpers/rendering/skin-previews' +-import type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts' ++import { ELY_FALLBACK_SKIN } from '@/helpers/ely-fallback-skin' + import { ++ type ElyOutcome, ++ type ElyUploadedSkin, ++ getElyCurrentSkinUrl, ++ getElyTexture, ++ listElySkins, ++ removeElySkin, ++ setElySkinModel, ++ signInToEly, ++ uploadElySkin, ++ wearElySkin, ++} from '@/helpers/ely_skins' ++import type { Cape, Skin, SkinModel, SkinTextureUrl } from '@/helpers/skins.ts' ++import { ++ determineModelType, + equip_skin, + filterDefaultSkins, + filterSavedSkins, +@@ -200,6 +216,27 @@ const messages = defineMessages({ + id: 'app.skins.sign-in.button', + defaultMessage: 'Sign in to Microsoft', + }, ++ elySkinsEmpty: { ++ id: 'app.skins.ely.empty', ++ defaultMessage: 'No skins on your Ely.by account yet. Skins you add here are uploaded to it.', ++ }, ++ elySignInTitle: { ++ id: 'app.skins.ely.sign-in.title', ++ defaultMessage: 'Sign in to the Ely.by website', ++ }, ++ elySignInDescription: { ++ id: 'app.skins.ely.sign-in.description', ++ defaultMessage: ++ 'Only the Ely.by website can change skins, so it needs you signed in there once. A window opens for it and closes by itself when you are done.', ++ }, ++ elySignInButton: { ++ id: 'app.skins.ely.sign-in.button', ++ defaultMessage: 'Sign in', ++ }, ++ elyFailedTitle: { ++ id: 'app.skins.ely.failed.title', ++ defaultMessage: 'Ely.by did not take the change', ++ }, + }) + + const editSkinModal = useTemplateRef('editSkinModal') +@@ -335,10 +372,12 @@ const skinTexture = computedAsync(async () => { + const capeTexture = computed(() => currentCape.value?.texture) + const skinVariant = computed(() => selectedSkin.value?.variant) + const skinNametag = computed(() => (appSettings.hideNametagSkinsPage ? undefined : username.value)) +-const isSkinManagementReadOnly = computed( +- () => +- !!currentUser.value && +- (offline.value || (authServerQuery.isError.value && !authServerQuery.isLoading.value)), ++const isSkinManagementReadOnly = computed(() => ++ // Mojang's auth server has nothing to do with an Ely.by account. ++ elyAccount.value ++ ? offline.value || elyBusy.value ++ : !!currentUser.value && ++ (offline.value || (authServerQuery.isError.value && !authServerQuery.isLoading.value)), + ) + const hasPendingSkinChange = computed( + () => !skinsMatch(selectedSkin.value, originalSelectedSkin.value), +@@ -861,7 +900,8 @@ function isSkinFileDrag(event: DragEvent) { + } + + function isPositionOverAddSkinButton(position: { x: number; y: number }) { +- const element = skinSectionList.value?.getAddSkinButtonElement() ++ // Only one of the two grids is mounted at a time. ++ const element = (skinSectionList.value ?? elySkinSectionList.value)?.getAddSkinButtonElement() + + if (!element) { + return false +@@ -973,6 +1013,11 @@ async function setupAddSkinDragDropListener() { + async function processSkinFileBuffer(buffer: Uint8Array | ArrayBuffer) { + if (isSkinManagementReadOnly.value) return + ++ if (elyAccount.value) { ++ await openElyAdd(new Uint8Array(buffer)) ++ return ++ } ++ + const fakeEvent = new MouseEvent('click') + const originalSkinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(buffer) + try { +@@ -987,6 +1032,308 @@ async function processSkinFileBuffer(buffer: Uint8Array | ArrayBuffer) { + } + } + ++/* ++ * Ely.by accounts. Ely.by keeps an account's skins in its public catalogue and ++ * has no API to change one, so the grid shows that catalogue, and changes are ++ * made on the website from a hidden window (see helpers/ely_skins.ts). ++ */ ++const elyAccount = computed(() => (currentUser.value?.ely ? currentUser.value : undefined)) ++const elyUsername = computed(() => elyAccount.value?.profile?.name) ++ ++const elyEditModal = useTemplateRef>('elyEditModal') ++const elySkinSectionList = useTemplateRef('elySkinSectionList') ++const elySignInModal = ref() ++const elyDeleteModal = ref() ++ ++const elyCatalogue = ref([]) ++const elyTextures = ref>({}) ++const elyWornUrl = ref(null) ++const elyWornTexture = ref('') ++const elyWornModel = ref('CLASSIC') ++const elyCape = ref() ++const elyLoading = ref(false) ++const elyBusy = ref(false) ++const elyPending = ref(null) ++/** The texture waiting in the add dialog. */ ++const elyNewTexture = ref(null) ++const elyEditing = ref(null) ++const elyToDelete = ref(null) ++/** The change to make again once the player has signed in to the website. */ ++let elyRetry: (() => Promise) | null = null ++ ++/** Ely.by hands out the same address over http and https. */ ++function sameElyUrl(a?: string | null, b?: string | null) { ++ return !!a && !!b && a.replace(/^https?:/, '') === b.replace(/^https?:/, '') ++} ++ ++const elySkins = computed(() => ++ elyCatalogue.value.map((skin) => ({ ++ // Previews are kept by key, so the key changes once the texture is in. ++ texture_key: `ely:${skin.id}:${elyTextures.value[skin.id] ? 'loaded' : 'pending'}`, ++ variant: skin.is_slim ? 'SLIM' : 'CLASSIC', ++ texture: elyTextures.value[skin.id] ?? ELY_FALLBACK_SKIN, ++ source: 'custom_external', ++ is_equipped: sameElyUrl(skin.skin_url, elyWornUrl.value), ++ })), ++) ++ ++const elyPreviewTexture = computed( ++ () => elyPending.value?.texture ?? (elyWornTexture.value || ELY_FALLBACK_SKIN), ++) ++const elyPreviewVariant = computed(() => elyPending.value?.variant ?? elyWornModel.value) ++ ++function elyId(skin: Skin) { ++ return Number(skin.texture_key.split(':')[1]) ++} ++ ++function isElySkinSelected(skin: Skin) { ++ return elyPending.value ? elyPending.value.texture_key === skin.texture_key : skin.is_equipped ++} ++ ++function isElySkinActive(skin: Skin) { ++ return !!elyPending.value && skin.is_equipped ++} ++ ++function selectElySkin(skin: Skin) { ++ if (isSkinManagementReadOnly.value) return ++ elyPending.value = skin.is_equipped ? null : skin ++} ++ ++async function loadAccountSkins() { ++ if (elyAccount.value) { ++ await loadElySkins() ++ } else { ++ await loadCapes() ++ await loadSkins() ++ } ++} ++ ++async function loadElySkins() { ++ const name = elyUsername.value ++ if (!name) return ++ ++ elyLoading.value = true ++ try { ++ const [catalogue, worn] = await Promise.all([listElySkins(name), getElyCurrentSkinUrl(name)]) ++ elyCatalogue.value = catalogue ++ elyWornUrl.value = worn ++ } catch (error) { ++ handleError(error as Error) ++ } finally { ++ elyLoading.value = false ++ } ++ ++ void loadElyWorn(name) ++ ++ // Tiles fill in as their textures arrive. ++ for (const skin of elyCatalogue.value) { ++ if (elyTextures.value[skin.id]) continue ++ getElyTexture(skin.skin_url) ++ .then((texture) => { ++ elyTextures.value = { ...elyTextures.value, [skin.id]: texture } ++ }) ++ .catch(() => {}) ++ } ++} ++ ++async function loadElyWorn(name: string) { ++ const worn = elyWornUrl.value ++ const listed = elyCatalogue.value.find((skin) => sameElyUrl(skin.skin_url, worn)) ++ ++ try { ++ elyWornTexture.value = worn ? await getElyTexture(worn) : ELY_FALLBACK_SKIN ++ // Ely.by's own flag decides the model in game. A skin worn from someone ++ // else's catalogue is not listed, so its texture has to tell. ++ elyWornModel.value = listed ++ ? listed.is_slim ++ ? 'SLIM' ++ : 'CLASSIC' ++ : worn ++ ? await determineModelType(elyWornTexture.value) ++ : 'CLASSIC' ++ } catch { ++ elyWornTexture.value = ELY_FALLBACK_SKIN ++ } ++ ++ elyCape.value = await getElyTexture( ++ `https://skinsystem.ely.by/cloaks/${encodeURIComponent(name)}.png`, ++ false, ++ ).catch(() => undefined) ++} ++ ++/** ++ * Makes a change on the website. When the website wants the player signed in ++ * first, asks for that and makes the change again once they are. ++ */ ++async function runOnEly(change: () => Promise, after: () => Promise) { ++ elyBusy.value = true ++ try { ++ const outcome = await change() ++ if (outcome.status === 'signed_out') { ++ elyRetry = () => runOnEly(change, after) ++ elySignInModal.value?.show() ++ } else if (outcome.status === 'failed') { ++ addNotification({ ++ type: 'error', ++ title: formatMessage(messages.elyFailedTitle), ++ text: outcome.message, ++ }) ++ } else { ++ await after() ++ } ++ } catch (error) { ++ handleError(error as Error) ++ } finally { ++ elyBusy.value = false ++ } ++} ++ ++async function signInToElyAndRetry() { ++ const retry = elyRetry ++ elyRetry = null ++ ++ elyBusy.value = true ++ try { ++ if ((await signInToEly()) && retry) { ++ await retry() ++ } ++ } catch (error) { ++ handleError(error as Error) ++ } finally { ++ elyBusy.value = false ++ } ++} ++ ++function sleep(ms: number) { ++ return new Promise((resolve) => setTimeout(resolve, ms)) ++} ++ ++/** Ely.by's skin system takes a few seconds to show what the website changed. */ ++async function waitForElyWorn(isDone: (worn: string | null) => boolean) { ++ const name = elyUsername.value ++ if (!name) return ++ ++ for (let attempt = 0; attempt < 15; attempt++) { ++ const worn = await getElyCurrentSkinUrl(name).catch(() => elyWornUrl.value) ++ if (isDone(worn)) return ++ await sleep(1500) ++ } ++} ++ ++/** The catalogue listing lags behind a model change the same way. */ ++async function waitForElyModel(id: number, slim: boolean) { ++ const name = elyUsername.value ++ if (!name) return ++ ++ for (let attempt = 0; attempt < 10; attempt++) { ++ const catalogue = await listElySkins(name).catch(() => []) ++ if (catalogue.find((skin) => skin.id === id)?.is_slim === slim) return ++ await sleep(1500) ++ } ++} ++ ++function applyElySkin() { ++ const skin = elyPending.value ++ if (!skin || isSkinManagementReadOnly.value) return ++ ++ const id = elyId(skin) ++ const target = elyCatalogue.value.find((entry) => entry.id === id)?.skin_url ++ void runOnEly( ++ () => wearElySkin(id), ++ async () => { ++ await waitForElyWorn((worn) => sameElyUrl(worn, target)) ++ elyPending.value = null ++ await loadElySkins() ++ }, ++ ) ++} ++ ++async function openElyAdd(bytes: Uint8Array) { ++ const texture = `data:image/png;base64,${arrayBufferToBase64(bytes)}` ++ const model = await determineModelType(texture).catch(() => 'CLASSIC' as const) ++ ++ elyNewTexture.value = bytes ++ elyEditing.value = null ++ elyEditModal.value?.open(undefined, 'new', texture, model) ++} ++ ++function editElySkin(skin: Skin, event: MouseEvent) { ++ if (isSkinManagementReadOnly.value) return ++ ++ elyNewTexture.value = null ++ elyEditing.value = skin ++ elyEditModal.value?.open(event, 'edit', skin.texture, skin.variant) ++} ++ ++function changeElyModel(id: number, slim: boolean) { ++ return runOnEly( ++ () => setElySkinModel(id, slim), ++ async () => { ++ await waitForElyModel(id, slim) ++ if (elyPending.value && elyId(elyPending.value) === id) { ++ elyPending.value = null ++ } ++ await loadElySkins() ++ }, ++ ) ++} ++ ++function saveElyModal(slim: boolean) { ++ const bytes = elyNewTexture.value ++ const editing = elyEditing.value ++ elyNewTexture.value = null ++ elyEditing.value = null ++ elyEditModal.value?.hide() ++ ++ if (editing) { ++ void changeElyModel(elyId(editing), slim) ++ return ++ } ++ ++ if (!bytes) return ++ ++ const before = elyWornUrl.value ++ void runOnEly( ++ () => uploadElySkin(bytes), ++ async () => { ++ await waitForElyWorn((worn) => !!worn && !sameElyUrl(worn, before)) ++ elyPending.value = null ++ await loadElySkins() ++ ++ // Ely.by picks the model itself when a texture arrives. ++ const uploaded = elyCatalogue.value.find((skin) => sameElyUrl(skin.skin_url, elyWornUrl.value)) ++ if (uploaded && uploaded.is_slim !== slim) { ++ await changeElyModel(uploaded.id, slim) ++ } ++ }, ++ ) ++} ++ ++function confirmDeleteElySkin(skin: Skin) { ++ if (isSkinManagementReadOnly.value) return ++ ++ elyToDelete.value = skin ++ elyDeleteModal.value?.show() ++} ++ ++function deleteElySkin() { ++ const skin = elyToDelete.value ++ elyToDelete.value = null ++ if (!skin) return ++ ++ const id = elyId(skin) ++ void runOnEly( ++ () => removeElySkin(id), ++ async () => { ++ if (elyPending.value && elyId(elyPending.value) === id) { ++ elyPending.value = null ++ } ++ // The listing still has it for a while. ++ elyCatalogue.value = elyCatalogue.value.filter((entry) => entry.id !== id) ++ }, ++ ) ++} ++ + watch( + () => selectedSkin.value?.cape_id, + () => {}, +@@ -1045,8 +1392,8 @@ async function checkUserChanges() { + if (defaultId !== currentUserId.value) { + await accountsCard.value?.refreshValues() + await loadCurrentUser() +- await loadCapes() +- await loadSkins() ++ elyPending.value = null ++ await loadAccountSkins() + } + } catch (error) { + if (currentUser.value && error instanceof Error) { +@@ -1055,8 +1402,8 @@ async function checkUserChanges() { + } + } + +-await Promise.all([loadCapes(), loadCurrentUser()]) +-await loadSkins() ++await loadCurrentUser() ++await loadAccountSkins() + + +