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()