diff --git a/Cargo.lock b/Cargo.lock index 60565594f..5f58d5a52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8095,6 +8095,7 @@ dependencies = [ "tokio-stream", "tracing", "url", + "uuid 1.17.0", "webpki-roots 0.26.11", ] @@ -8177,6 +8178,7 @@ dependencies = [ "stringprep", "thiserror 2.0.12", "tracing", + "uuid 1.17.0", "whoami", ] @@ -8216,6 +8218,7 @@ dependencies = [ "stringprep", "thiserror 2.0.12", "tracing", + "uuid 1.17.0", "whoami", ] @@ -8242,6 +8245,7 @@ dependencies = [ "thiserror 2.0.12", "tracing", "url", + "uuid 1.17.0", ] [[package]] @@ -9004,11 +9008,13 @@ dependencies = [ "async-walkdir", "async_zip", "base64 0.22.1", + "bytemuck", "bytes", "chardetng", "chrono", "daedalus", "dashmap", + "data-url", "dirs", "discord-rich-presence", "dunce", @@ -9019,17 +9025,20 @@ dependencies = [ "fs4", "futures", "hashlink", + "heck 0.5.0", "hickory-resolver", "indicatif", "notify", "notify-debouncer-mini", "p256", "paste", + "png", "quartz_nbt", "quick-xml 0.37.5", "rand 0.8.5", "regex", "reqwest", + "rgb", "serde", "serde_ini", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index b06dab88c..d0101db0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ async-tungstenite = { version = "0.29.1", default-features = false, features = [ async-walkdir = "2.1.0" base64 = "0.22.1" bitflags = "2.9.1" +bytemuck = "1.23.0" bytes = "1.10.1" censor = "0.3.0" chardetng = "0.1.17" @@ -47,6 +48,7 @@ color-thief = "0.2.2" console-subscriber = "0.4.1" daedalus = { path = "packages/daedalus" } dashmap = "6.1.0" +data-url = "0.3.1" deadpool-redis = "0.21.1" dirs = "6.0.0" discord-rich-presence = "0.2.5" @@ -61,6 +63,7 @@ fs4 = { version = "0.13.1", default-features = false } futures = { version = "0.3.31", default-features = false } futures-util = "0.3.31" hashlink = "0.10.0" +heck = "0.5.0" hex = "0.4.3" hickory-resolver = "0.25.2" hmac = "0.12.1" @@ -90,6 +93,7 @@ notify = { version = "8.0.0", default-features = false } notify-debouncer-mini = { version = "0.6.0", default-features = false } p256 = "0.13.2" paste = "1.0.15" +png = "0.17.16" prometheus = "0.14.0" quartz_nbt = "0.2.9" quick-xml = "0.37.5" @@ -98,6 +102,7 @@ rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9 redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32 regex = "1.11.1" reqwest = { version = "0.12.20", default-features = false } +rgb = "0.8.50" rust_decimal = { version = "1.37.2", features = [ "serde-with-float", "serde-with-str", diff --git a/apps/app-frontend/.prettierignore b/apps/app-frontend/.prettierignore index 581edad3d..0cb3e84e5 100644 --- a/apps/app-frontend/.prettierignore +++ b/apps/app-frontend/.prettierignore @@ -1 +1,2 @@ **/dist +*.gltf diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index 6fccb56ce..0f2d7892c 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -25,12 +25,15 @@ "@tauri-apps/plugin-os": "^2.2.1", "@tauri-apps/plugin-updater": "^2.7.1", "@tauri-apps/plugin-window-state": "^2.2.2", + "@types/three": "^0.172.0", "@vintl/vintl": "^4.4.1", + "@vueuse/core": "^11.1.0", "dayjs": "^1.11.10", "floating-vue": "^5.2.2", "ofetch": "^1.3.4", "pinia": "^2.1.7", "posthog-js": "^1.158.2", + "three": "^0.172.0", "vite-svg-loader": "^5.1.0", "vue": "^3.5.13", "vue-multiselect": "3.0.0", diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 5673661d4..17b2b5198 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -1,8 +1,9 @@ diff --git a/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue b/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue new file mode 100644 index 000000000..c6c25080e --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue @@ -0,0 +1,143 @@ + + + + + + Change cape + + + + + + + + + + + + + + + + + None + + + + + + + + + + + Select + + + + + + Cancel + + + + + diff --git a/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue b/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue new file mode 100644 index 000000000..818922eff --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue @@ -0,0 +1,140 @@ + + + + Upload skin texture + + + + + Select skin texture file + + + Drag and drop or click here to browse + + + + + + + + diff --git a/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts b/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts new file mode 100644 index 000000000..75729c5aa --- /dev/null +++ b/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts @@ -0,0 +1,353 @@ +import * as THREE from 'three' +import type { Skin, Cape } from '../skins' +import { get_normalized_skin_texture, determineModelType } from '../skins' +import { reactive } from 'vue' +import { setupSkinModel, disposeCaches } from '@modrinth/utils' +import { skinPreviewStorage } from '../storage/skin-preview-storage' + +export interface RenderResult { + forwards: string + backwards: string +} + +class BatchSkinRenderer { + private renderer: THREE.WebGLRenderer + private readonly scene: THREE.Scene + private readonly camera: THREE.PerspectiveCamera + private currentModel: THREE.Group | null = null + + constructor(width: number = 360, height: number = 504) { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + + this.renderer = new THREE.WebGLRenderer({ + canvas: canvas, + antialias: true, + alpha: true, + preserveDrawingBuffer: true, + }) + + this.renderer.outputColorSpace = THREE.SRGBColorSpace + this.renderer.toneMapping = THREE.NoToneMapping + this.renderer.toneMappingExposure = 10.0 + this.renderer.setClearColor(0x000000, 0) + this.renderer.setSize(width, height) + + this.scene = new THREE.Scene() + this.camera = new THREE.PerspectiveCamera(20, width / height, 0.4, 1000) + + const ambientLight = new THREE.AmbientLight(0xffffff, 2) + const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2) + directionalLight.castShadow = true + directionalLight.position.set(2, 4, 3) + this.scene.add(ambientLight) + this.scene.add(directionalLight) + } + + public async renderSkin( + textureUrl: string, + modelUrl: string, + capeUrl?: string, + capeModelUrl?: string, + ): Promise { + await this.setupModel(modelUrl, textureUrl, capeModelUrl, capeUrl) + + const headPart = this.currentModel!.getObjectByName('Head') + let lookAtTarget: [number, number, number] + + if (headPart) { + const headPosition = new THREE.Vector3() + headPart.getWorldPosition(headPosition) + lookAtTarget = [headPosition.x, headPosition.y - 0.3, headPosition.z] + } else { + throw new Error("Failed to find 'Head' object in model.") + } + + const frontCameraPos: [number, number, number] = [-1.3, 1, 6.3] + const backCameraPos: [number, number, number] = [-1.3, 1, -2.5] + + const forwards = await this.renderView(frontCameraPos, lookAtTarget) + const backwards = await this.renderView(backCameraPos, lookAtTarget) + + return { forwards, backwards } + } + + private async renderView( + cameraPosition: [number, number, number], + lookAtPosition: [number, number, number], + ): Promise { + this.camera.position.set(...cameraPosition) + this.camera.lookAt(...lookAtPosition) + + this.renderer.render(this.scene, this.camera) + + return new Promise((resolve, reject) => { + this.renderer.domElement.toBlob((blob) => { + if (blob) { + const url = URL.createObjectURL(blob) + resolve(url) + } else { + reject(new Error('Failed to create blob from canvas')) + } + }, 'image/png') + }) + } + + private async setupModel( + modelUrl: string, + textureUrl: string, + capeModelUrl?: string, + capeUrl?: string, + ): Promise { + if (this.currentModel) { + this.scene.remove(this.currentModel) + } + + const { model } = await setupSkinModel(modelUrl, textureUrl, capeModelUrl, capeUrl) + + const group = new THREE.Group() + group.add(model) + group.position.set(0, 0.3, 1.95) + group.scale.set(0.8, 0.8, 0.8) + + this.scene.add(group) + this.currentModel = group + } + + public dispose(): void { + this.renderer.dispose() + disposeCaches() + } +} + +function getModelUrlForVariant(variant: string): string { + switch (variant) { + case 'SLIM': + return '/src/assets/models/slim_player.gltf' + case 'CLASSIC': + case 'UNKNOWN': + default: + return '/src/assets/models/classic_player.gltf' + } +} + +export const map = reactive(new Map()) +export const headMap = reactive(new Map()) +const DEBUG_MODE = false + +export async function cleanupUnusedPreviews(skins: Skin[]): Promise { + const validKeys = new Set() + const validHeadKeys = new Set() + + for (const skin of skins) { + const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}` + const headKey = `${skin.texture_key}-head` + validKeys.add(key) + validHeadKeys.add(headKey) + } + + try { + await skinPreviewStorage.cleanupInvalidKeys(validKeys) + await skinPreviewStorage.cleanupInvalidKeys(validHeadKeys) + } catch (error) { + console.warn('Failed to cleanup unused skin previews:', error) + } +} + +export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64): Promise { + return new Promise((resolve, reject) => { + const img = new Image() + img.crossOrigin = 'anonymous' + + img.onload = () => { + try { + const sourceCanvas = document.createElement('canvas') + const sourceCtx = sourceCanvas.getContext('2d') + + if (!sourceCtx) { + throw new Error('Could not get 2D context from source canvas') + } + + sourceCanvas.width = img.width + sourceCanvas.height = img.height + + sourceCtx.drawImage(img, 0, 0) + + const outputCanvas = document.createElement('canvas') + const outputCtx = outputCanvas.getContext('2d') + + if (!outputCtx) { + throw new Error('Could not get 2D context from output canvas') + } + + outputCanvas.width = size + outputCanvas.height = size + + outputCtx.imageSmoothingEnabled = false + + const headImageData = sourceCtx.getImageData(8, 8, 8, 8) + + const headCanvas = document.createElement('canvas') + const headCtx = headCanvas.getContext('2d') + + if (!headCtx) { + throw new Error('Could not get 2D context from head canvas') + } + + headCanvas.width = 8 + headCanvas.height = 8 + headCtx.putImageData(headImageData, 0, 0) + + outputCtx.drawImage(headCanvas, 0, 0, 8, 8, 0, 0, size, size) + + const hatImageData = sourceCtx.getImageData(40, 8, 8, 8) + + const hatCanvas = document.createElement('canvas') + const hatCtx = hatCanvas.getContext('2d') + + if (!hatCtx) { + throw new Error('Could not get 2D context from hat canvas') + } + + hatCanvas.width = 8 + hatCanvas.height = 8 + hatCtx.putImageData(hatImageData, 0, 0) + + const hatPixels = hatImageData.data + let hasHat = false + + for (let i = 3; i < hatPixels.length; i += 4) { + if (hatPixels[i] > 0) { + hasHat = true + break + } + } + + if (hasHat) { + outputCtx.drawImage(hatCanvas, 0, 0, 8, 8, 0, 0, size, size) + } + + outputCanvas.toBlob((blob) => { + if (blob) { + resolve(blob) + } else { + reject(new Error('Failed to create blob from canvas')) + } + }, 'image/png') + } catch (error) { + reject(error) + } + } + + img.onerror = () => { + reject(new Error('Failed to load skin texture image')) + } + + img.src = skinUrl + }) +} + +async function generateHeadRender(skin: Skin): Promise { + const headKey = `${skin.texture_key}-head` + + if (headMap.has(headKey)) { + if (DEBUG_MODE) { + const url = headMap.get(headKey)! + URL.revokeObjectURL(url) + headMap.delete(headKey) + } else { + return headMap.get(headKey)! + } + } + + try { + const cached = await skinPreviewStorage.retrieve(headKey) + if (cached && typeof cached === 'string') { + headMap.set(headKey, cached) + return cached + } + } catch (error) { + console.warn('Failed to retrieve cached head render:', error) + } + + const skinUrl = await get_normalized_skin_texture(skin) + const headBlob = await generatePlayerHeadBlob(skinUrl, 64) + const headUrl = URL.createObjectURL(headBlob) + + headMap.set(headKey, headUrl) + + try { + await skinPreviewStorage.store(headKey, headUrl) + } catch (error) { + console.warn('Failed to store head render in persistent storage:', error) + } + + return headUrl +} + +export async function getPlayerHeadUrl(skin: Skin): Promise { + return await generateHeadRender(skin) +} + +export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise { + const renderer = new BatchSkinRenderer() + const capeModelUrl = '/src/assets/models/cape.gltf' + + try { + for (const skin of skins) { + const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}` + + if (map.has(key)) { + if (DEBUG_MODE) { + const result = map.get(key)! + URL.revokeObjectURL(result.forwards) + URL.revokeObjectURL(result.backwards) + map.delete(key) + } else continue + } + + try { + const cached = await skinPreviewStorage.retrieve(key) + if (cached) { + map.set(key, cached) + continue + } + } catch (error) { + console.warn('Failed to retrieve cached skin preview:', error) + } + + let variant = skin.variant + if (variant === 'UNKNOWN') { + try { + variant = await determineModelType(skin.texture) + } catch (error) { + console.error(`Failed to determine model type for skin ${key}:`, error) + variant = 'CLASSIC' + } + } + + const modelUrl = getModelUrlForVariant(variant) + const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id) + const renderResult = await renderer.renderSkin( + await get_normalized_skin_texture(skin), + modelUrl, + cape?.texture, + capeModelUrl, + ) + + map.set(key, renderResult) + + try { + await skinPreviewStorage.store(key, renderResult) + } catch (error) { + console.warn('Failed to store skin preview in persistent storage:', error) + } + + await generateHeadRender(skin) + } + } finally { + renderer.dispose() + await cleanupUnusedPreviews(skins) + } +} diff --git a/apps/app-frontend/src/helpers/settings.ts b/apps/app-frontend/src/helpers/settings.ts index 2988d34d8..c256575a4 100644 --- a/apps/app-frontend/src/helpers/settings.ts +++ b/apps/app-frontend/src/helpers/settings.ts @@ -37,6 +37,7 @@ export type AppSettings = { theme: ColorTheme default_page: 'home' | 'library' collapsed_navigation: boolean + hide_nametag_skins_page: boolean advanced_rendering: boolean native_decorations: boolean toggle_sidebar: boolean diff --git a/apps/app-frontend/src/helpers/skins.ts b/apps/app-frontend/src/helpers/skins.ts new file mode 100644 index 000000000..40c48d63c --- /dev/null +++ b/apps/app-frontend/src/helpers/skins.ts @@ -0,0 +1,163 @@ +import { invoke } from '@tauri-apps/api/core' +import { handleError } from '@/store/notifications' +import { arrayBufferToBase64 } from '@modrinth/utils' + +export interface Cape { + id: string + name: string + texture: string + is_default: boolean + is_equipped: boolean +} + +export type SkinModel = 'CLASSIC' | 'SLIM' | 'UNKNOWN' +export type SkinSource = 'default' | 'custom_external' | 'custom' + +export interface Skin { + texture_key: string + name?: string + variant: SkinModel + cape_id?: string + texture: string + source: SkinSource + is_equipped: boolean +} + +export const DEFAULT_MODEL_SORTING = ['Steve', 'Alex'] as string[] + +export const DEFAULT_MODELS: Record = { + Steve: 'CLASSIC', + Alex: 'SLIM', + Zuri: 'CLASSIC', + Sunny: 'CLASSIC', + Noor: 'SLIM', + Makena: 'SLIM', + Kai: 'CLASSIC', + Efe: 'SLIM', + Ari: 'CLASSIC', +} + +export function filterSavedSkins(list: Skin[]) { + const customSkins = list.filter((s) => s.source !== 'default') + fixUnknownSkins(customSkins).catch(handleError) + return customSkins +} + +export async function determineModelType(texture: string): Promise<'SLIM' | 'CLASSIC'> { + return new Promise((resolve, reject) => { + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d') + + if (!context) { + return reject(new Error('Failed to create canvas rendering context.')) + } + + const image = new Image() + image.crossOrigin = 'anonymous' + image.src = texture + + image.onload = () => { + canvas.width = image.width + canvas.height = image.height + + context.drawImage(image, 0, 0) + + const armX = 44 + const armY = 16 + const armWidth = 4 + const armHeight = 12 + + const imageData = context.getImageData(armX, armY, armWidth, armHeight).data + + for (let y = 0; y < armHeight; y++) { + const alphaIndex = (3 + y * armWidth) * 4 + 3 + if (imageData[alphaIndex] !== 0) { + resolve('CLASSIC') + return + } + } + + canvas.remove() + resolve('SLIM') + } + + image.onerror = () => { + canvas.remove() + reject(new Error('Failed to load the image.')) + } + }) +} + +export async function fixUnknownSkins(list: Skin[]) { + const unknownSkins = list.filter((s) => s.variant === 'UNKNOWN') + for (const unknownSkin of unknownSkins) { + unknownSkin.variant = await determineModelType(unknownSkin.texture) + } +} + +export function filterDefaultSkins(list: Skin[]) { + return list + .filter((s) => s.source === 'default' && (!s.name || s.variant === DEFAULT_MODELS[s.name])) + .sort((a, b) => { + const aIndex = a.name ? DEFAULT_MODEL_SORTING.indexOf(a.name) : -1 + const bIndex = b.name ? DEFAULT_MODEL_SORTING.indexOf(b.name) : -1 + return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex) + }) +} + +export async function get_available_capes(): Promise { + return invoke('plugin:minecraft-skins|get_available_capes', {}) +} + +export async function get_available_skins(): Promise { + return invoke('plugin:minecraft-skins|get_available_skins', {}) +} + +export async function add_and_equip_custom_skin( + textureBlob: Uint8Array, + variant: SkinModel, + capeOverride?: Cape, +): Promise { + await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', { + textureBlob, + variant, + capeOverride, + }) +} + +export async function set_default_cape(cape?: Cape): Promise { + await invoke('plugin:minecraft-skins|set_default_cape', { + cape, + }) +} + +export async function equip_skin(skin: Skin): Promise { + await invoke('plugin:minecraft-skins|equip_skin', { + skin, + }) +} + +export async function remove_custom_skin(skin: Skin): Promise { + await invoke('plugin:minecraft-skins|remove_custom_skin', { + skin, + }) +} + +export async function get_normalized_skin_texture(skin: Skin): Promise { + const data = await normalize_skin_texture(skin.texture) + const base64 = arrayBufferToBase64(data) + return `data:image/png;base64,${base64}` +} + +export async function normalize_skin_texture(texture: Uint8Array | string): Promise { + return await invoke('plugin:minecraft-skins|normalize_skin_texture', { texture }) +} + +export async function unequip_skin(): Promise { + await invoke('plugin:minecraft-skins|unequip_skin') +} + +export async function get_dragged_skin_data(path: string): Promise { + const data = await invoke('plugin:minecraft-skins|get_dragged_skin_data', { path }) + return new Uint8Array(data) +} diff --git a/apps/app-frontend/src/helpers/storage/skin-preview-storage.ts b/apps/app-frontend/src/helpers/storage/skin-preview-storage.ts new file mode 100644 index 000000000..2e4990850 --- /dev/null +++ b/apps/app-frontend/src/helpers/storage/skin-preview-storage.ts @@ -0,0 +1,118 @@ +import type { RenderResult } from '../rendering/batch-skin-renderer' + +interface StoredPreview { + forwards: Blob + backwards: Blob + timestamp: number +} + +export class SkinPreviewStorage { + private dbName = 'skin-previews' + private version = 1 + private db: IDBDatabase | null = null + + async init(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.version) + + request.onerror = () => reject(request.error) + request.onsuccess = () => { + this.db = request.result + resolve() + } + + request.onupgradeneeded = () => { + const db = request.result + if (!db.objectStoreNames.contains('previews')) { + db.createObjectStore('previews') + } + } + }) + } + + async store(key: string, result: RenderResult): Promise { + if (!this.db) await this.init() + + const forwardsBlob = await fetch(result.forwards).then((r) => r.blob()) + const backwardsBlob = await fetch(result.backwards).then((r) => r.blob()) + + const transaction = this.db!.transaction(['previews'], 'readwrite') + const store = transaction.objectStore('previews') + + const storedPreview: StoredPreview = { + forwards: forwardsBlob, + backwards: backwardsBlob, + timestamp: Date.now(), + } + + return new Promise((resolve, reject) => { + const request = store.put(storedPreview, key) + + request.onsuccess = () => resolve() + request.onerror = () => reject(request.error) + }) + } + + async retrieve(key: string): Promise { + if (!this.db) await this.init() + + const transaction = this.db!.transaction(['previews'], 'readonly') + const store = transaction.objectStore('previews') + + return new Promise((resolve, reject) => { + const request = store.get(key) + + request.onsuccess = () => { + const result = request.result as StoredPreview | undefined + + if (!result) { + resolve(null) + return + } + + const forwards = URL.createObjectURL(result.forwards) + const backwards = URL.createObjectURL(result.backwards) + resolve({ forwards, backwards }) + } + request.onerror = () => reject(request.error) + }) + } + + async cleanupInvalidKeys(validKeys: Set): Promise { + if (!this.db) await this.init() + + const transaction = this.db!.transaction(['previews'], 'readwrite') + const store = transaction.objectStore('previews') + let deletedCount = 0 + + return new Promise((resolve, reject) => { + const request = store.openCursor() + + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result + + if (cursor) { + const key = cursor.primaryKey as string + + if (!validKeys.has(key)) { + const deleteRequest = cursor.delete() + deleteRequest.onsuccess = () => { + deletedCount++ + } + deleteRequest.onerror = () => { + console.warn('Failed to delete invalid entry:', key) + } + } + + cursor.continue() + } else { + resolve(deletedCount) + } + } + + request.onerror = () => reject(request.error) + }) + } +} + +export const skinPreviewStorage = new SkinPreviewStorage() diff --git a/apps/app-frontend/src/pages/Index.vue b/apps/app-frontend/src/pages/Index.vue index e5c1e0689..3eba1ba68 100644 --- a/apps/app-frontend/src/pages/Index.vue +++ b/apps/app-frontend/src/pages/Index.vue @@ -10,6 +10,7 @@ import dayjs from 'dayjs' import { get_search_results } from '@/helpers/cache.js' import type { SearchResult } from '@modrinth/utils' import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue' +import type { GameInstance } from '@/helpers/types' const route = useRoute() const breadcrumbs = useBreadcrumbs() @@ -82,13 +83,15 @@ async function refreshFeaturedProjects() { await fetchInstances() await refreshFeaturedProjects() -const unlistenProfile = await profile_listener(async (e) => { - await fetchInstances() +const unlistenProfile = await profile_listener( + async (e: { event: string; profile_path_id: string }) => { + await fetchInstances() - if (e.event === 'added' || e.event === 'created' || e.event === 'removed') { - await refreshFeaturedProjects() - } -}) + if (e.event === 'added' || e.event === 'created' || e.event === 'removed') { + await refreshFeaturedProjects() + } + }, +) onUnmounted(() => { unlistenProfile() @@ -97,8 +100,8 @@ onUnmounted(() => { - Welcome back! - Welcome to Modrinth App! + Welcome back! + Welcome to Modrinth App! +import { + EditIcon, + ExcitedRinthbot, + LogInIcon, + PlusIcon, + SpinnerIcon, + TrashIcon, + UpdatedIcon, +} from '@modrinth/assets' +import { + Button, + ButtonStyled, + ConfirmModal, + SkinButton, + SkinLikeTextButton, + SkinPreviewRenderer, +} from '@modrinth/ui' +import { computedAsync } from '@vueuse/core' +import type { Ref } from 'vue' +import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue' +import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue' +import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue' +import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue' +import { handleError, useNotifications } from '@/store/notifications' +import type { Cape, Skin } from '@/helpers/skins.ts' +import { + normalize_skin_texture, + equip_skin, + filterDefaultSkins, + filterSavedSkins, + get_available_capes, + get_available_skins, + get_normalized_skin_texture, + remove_custom_skin, + set_default_cape, +} from '@/helpers/skins.ts' +import { get as getSettings } from '@/helpers/settings.ts' +import { get_default_user, login as login_flow, users } from '@/helpers/auth' +import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts' +import { generateSkinPreviews, map } from '@/helpers/rendering/batch-skin-renderer.ts' +import { handleSevereError } from '@/store/error' +import { trackEvent } from '@/helpers/analytics' +import type AccountsCard from '@/components/ui/AccountsCard.vue' +import { arrayBufferToBase64 } from '@modrinth/utils' + +const editSkinModal = useTemplateRef('editSkinModal') +const selectCapeModal = useTemplateRef('selectCapeModal') +const uploadSkinModal = useTemplateRef('uploadSkinModal') + +const notifications = useNotifications() + +const settings = ref(await getSettings()) +const skins = ref([]) +const capes = ref([]) + +const accountsCard = inject('accountsCard') as Ref +const currentUser = ref(undefined) +const currentUserId = ref(undefined) + +const username = computed(() => currentUser.value?.profile?.name ?? undefined) +const selectedSkin = ref(null) +const defaultCape = ref() + +const originalSelectedSkin = ref(null) +const originalDefaultCape = ref() + +const savedSkins = computed(() => filterSavedSkins(skins.value)) +const defaultSkins = computed(() => filterDefaultSkins(skins.value)) + +const currentCape = computed(() => { + if (selectedSkin.value?.cape_id) { + const overrideCape = capes.value.find((c) => c.id === selectedSkin.value?.cape_id) + if (overrideCape) { + return overrideCape + } + } + return defaultCape.value +}) + +const skinTexture = computedAsync(async () => { + if (selectedSkin.value?.texture) { + return await get_normalized_skin_texture(selectedSkin.value) + } else { + return '' + } +}) +const capeTexture = computed(() => currentCape.value?.texture) +const skinVariant = computed(() => selectedSkin.value?.variant) +const skinNametag = computed(() => + settings.value.hide_nametag_skins_page ? undefined : username.value, +) + +let userCheckInterval: number | null = null + +const deleteSkinModal = ref() +const skinToDelete = ref(null) + +function confirmDeleteSkin(skin: Skin) { + skinToDelete.value = skin + deleteSkinModal.value?.show() +} + +async function deleteSkin() { + if (!skinToDelete.value) return + await remove_custom_skin(skinToDelete.value).catch(handleError) + await loadSkins() + skinToDelete.value = null +} + +async function loadCapes() { + try { + capes.value = (await get_available_capes()) ?? [] + defaultCape.value = capes.value.find((c) => c.is_equipped) + originalDefaultCape.value = defaultCape.value + } catch (error) { + if (currentUser.value) { + handleError(error) + } + } +} + +async function loadSkins() { + try { + skins.value = (await get_available_skins()) ?? [] + generateSkinPreviews(skins.value, capes.value) + selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null + originalSelectedSkin.value = selectedSkin.value + } catch (error) { + if (currentUser.value) { + handleError(error) + } + } +} + +async function changeSkin(newSkin: Skin) { + const previousSkin = selectedSkin.value + const previousSkinsList = [...skins.value] + + skins.value = skins.value.map((skin) => { + return { + ...skin, + is_equipped: skin.texture_key === newSkin.texture_key, + } + }) + + selectedSkin.value = skins.value.find((s) => s.texture_key === newSkin.texture_key) || null + + try { + await equip_skin(newSkin) + if (accountsCard.value) { + await accountsCard.value.refreshValues() + } + } catch (error) { + selectedSkin.value = previousSkin + skins.value = previousSkinsList + + if ((error as { message?: string })?.message?.includes('429 Too Many Requests')) { + notifications.addNotification({ + type: 'error', + title: 'Slow down!', + text: "You're changing your skin too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again.", + }) + } else { + handleError(error) + } + } +} + +async function handleCapeSelected(cape: Cape | undefined) { + const previousDefaultCape = defaultCape.value + const previousCapesList = [...capes.value] + + capes.value = capes.value.map((c) => ({ + ...c, + is_equipped: cape ? c.id === cape.id : false, + })) + + defaultCape.value = cape ? capes.value.find((c) => c.id === cape.id) : undefined + + try { + await set_default_cape(cape) + } catch (error) { + defaultCape.value = previousDefaultCape + capes.value = previousCapesList + + if ((error as { message?: string })?.message?.includes('429 Too Many Requests')) { + notifications.addNotification({ + type: 'error', + title: 'Slow down!', + text: "You're changing your cape too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again.", + }) + } else { + handleError(error) + } + } +} + +async function onSkinSaved() { + await Promise.all([loadCapes(), loadSkins()]) +} + +async function loadCurrentUser() { + try { + const defaultId = await get_default_user() + currentUserId.value = defaultId + + const allAccounts = await users() + currentUser.value = allAccounts.find((acc) => acc.profile.id === defaultId) + } catch (e) { + handleError(e) + currentUser.value = undefined + currentUserId.value = undefined + } +} + +function getBakedSkinTextures(skin: Skin): RenderResult | undefined { + const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}` + return map.get(key) +} + +async function login() { + accountsCard.value.setLoginDisabled(true) + const loggedIn = await login_flow().catch(handleSevereError) + + if (loggedIn && accountsCard) { + await accountsCard.value.refreshValues() + } + + trackEvent('AccountLogIn') + accountsCard.value.setLoginDisabled(false) +} + +function openUploadSkinModal(e: MouseEvent) { + uploadSkinModal.value?.show(e) +} + +function onSkinFileUploaded(buffer: ArrayBuffer) { + const fakeEvent = new MouseEvent('click') + normalize_skin_texture(`data:image/png;base64,` + arrayBufferToBase64(buffer)).then( + (skinTextureNormalized: Uint8Array) => { + const skinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized) + if (editSkinModal.value && editSkinModal.value.shouldRestoreModal) { + editSkinModal.value.restoreWithNewTexture(skinTexUrl) + } else { + editSkinModal.value?.showNew(fakeEvent, skinTexUrl) + } + }, + ) +} + +function onUploadCanceled() { + editSkinModal.value?.restoreModal() +} + +watch( + () => selectedSkin.value?.cape_id, + () => {}, +) + +onMounted(() => { + userCheckInterval = window.setInterval(checkUserChanges, 250) +}) + +onUnmounted(() => { + if (userCheckInterval !== null) { + window.clearInterval(userCheckInterval) + } +}) + +async function checkUserChanges() { + try { + const defaultId = await get_default_user() + if (defaultId !== currentUserId.value) { + await loadCurrentUser() + await loadCapes() + await loadSkins() + } + } catch (error) { + if (currentUser.value) { + handleError(error) + } + } +} + +await Promise.all([loadCapes(), loadSkins(), loadCurrentUser()]) + + + + loadSkins()" + @open-upload-modal="openUploadSkinModal" + /> + + + + + + + + Skins + Beta + + + + + + + selectCapeModal?.show( + e, + selectedSkin?.texture_key, + currentCape, + skinTexture, + skinVariant, + ) + " + > + + Change cape + + + + + + + + + + Saved skins + + + + + + Add a skin + + + + + editSkinModal?.show(e, skin)" + > + Edit + + confirmDeleteSkin(skin)" + > + + + + + + + + + Default skins + + + + + + + + + + + + + + Please sign-in + + Please sign into your Minecraft account to use the skin management features of the + Modrinth app. + + + + + + Sign In + + + + + + + + diff --git a/apps/app-frontend/src/pages/index.js b/apps/app-frontend/src/pages/index.js index 82b0b3ec2..2e0361cd5 100644 --- a/apps/app-frontend/src/pages/index.js +++ b/apps/app-frontend/src/pages/index.js @@ -1,5 +1,6 @@ import Index from './Index.vue' import Browse from './Browse.vue' import Worlds from './Worlds.vue' +import Skins from './Skins.vue' -export { Index, Browse, Worlds } +export { Index, Browse, Worlds, Skins } diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js index 6d5e4e372..67172e68d 100644 --- a/apps/app-frontend/src/routes.js +++ b/apps/app-frontend/src/routes.js @@ -34,6 +34,14 @@ export default new createRouter({ breadcrumb: [{ name: 'Discover content' }], }, }, + { + path: '/skins', + name: 'Skins', + component: Pages.Skins, + meta: { + breadcrumb: [{ name: 'Skins' }], + }, + }, { path: '/library', name: 'Library', diff --git a/apps/app-frontend/tailwind.config.js b/apps/app-frontend/tailwind.config.js index 0d0fab4bf..b5196b368 100644 --- a/apps/app-frontend/tailwind.config.js +++ b/apps/app-frontend/tailwind.config.js @@ -41,6 +41,7 @@ export default { green: 'var(--color-green-highlight)', blue: 'var(--color-blue-highlight)', purple: 'var(--color-purple-highlight)', + gray: 'var(--color-gray-highlight)', }, divider: { DEFAULT: 'var(--color-divider)', diff --git a/apps/app-playground/src/main.rs b/apps/app-playground/src/main.rs index 24019b125..a2c2b8922 100644 --- a/apps/app-playground/src/main.rs +++ b/apps/app-playground/src/main.rs @@ -27,7 +27,10 @@ pub async fn authenticate_run() -> theseus::Result { let credentials = minecraft_auth::finish_login(&input, login).await?; - println!("Logged in user {}.", credentials.username); + println!( + "Logged in user {}.", + credentials.maybe_online_profile().await.name + ); Ok(credentials) } diff --git a/apps/app/build.rs b/apps/app/build.rs index 644d22b68..7a4da8872 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -99,6 +99,24 @@ fn main() { DefaultPermissionRule::AllowAllCommands, ), ) + .plugin( + "minecraft-skins", + InlinedPlugin::new() + .commands(&[ + "get_available_capes", + "get_available_skins", + "add_and_equip_custom_skin", + "set_default_cape", + "equip_skin", + "remove_custom_skin", + "unequip_skin", + "normalize_skin_texture", + "get_dragged_skin_data", + ]) + .default_permission( + DefaultPermissionRule::AllowAllCommands, + ), + ) .plugin( "mr-auth", InlinedPlugin::new() @@ -151,7 +169,6 @@ fn main() { "profile_update_managed_modrinth_version", "profile_repair_managed_modrinth", "profile_run", - "profile_run_credentials", "profile_kill", "profile_edit", "profile_edit_icon", diff --git a/apps/app/capabilities/plugins.json b/apps/app/capabilities/plugins.json index 5a02b5db9..b3947857b 100644 --- a/apps/app/capabilities/plugins.json +++ b/apps/app/capabilities/plugins.json @@ -33,6 +33,7 @@ "jre:default", "logs:default", "metadata:default", + "minecraft-skins:default", "mr-auth:default", "profile-create:default", "pack:default", diff --git a/apps/app/src/api/minecraft_skins.rs b/apps/app/src/api/minecraft_skins.rs new file mode 100644 index 000000000..a6d138fbd --- /dev/null +++ b/apps/app/src/api/minecraft_skins.rs @@ -0,0 +1,104 @@ +use crate::api::Result; + +use std::path::Path; +use theseus::minecraft_skins::{ + self, Bytes, Cape, MinecraftSkinVariant, Skin, UrlOrBlob, +}; + +pub fn init() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new("minecraft-skins") + .invoke_handler(tauri::generate_handler![ + get_available_capes, + get_available_skins, + add_and_equip_custom_skin, + set_default_cape, + equip_skin, + remove_custom_skin, + unequip_skin, + normalize_skin_texture, + get_dragged_skin_data, + ]) + .build() +} + +/// `invoke('plugin:minecraft-skins|get_available_capes')` +/// +/// See also: [minecraft_skins::get_available_capes] +#[tauri::command] +pub async fn get_available_capes() -> Result> { + Ok(minecraft_skins::get_available_capes().await?) +} + +/// `invoke('plugin:minecraft-skins|get_available_skins')` +/// +/// See also: [minecraft_skins::get_available_skins] +#[tauri::command] +pub async fn get_available_skins() -> Result> { + Ok(minecraft_skins::get_available_skins().await?) +} + +/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape_override)` +/// +/// See also: [minecraft_skins::add_and_equip_custom_skin] +#[tauri::command] +pub async fn add_and_equip_custom_skin( + texture_blob: Bytes, + variant: MinecraftSkinVariant, + cape_override: Option, +) -> Result<()> { + Ok(minecraft_skins::add_and_equip_custom_skin( + texture_blob, + variant, + cape_override, + ) + .await?) +} + +/// `invoke('plugin:minecraft-skins|set_default_cape', cape)` +/// +/// See also: [minecraft_skins::set_default_cape] +#[tauri::command] +pub async fn set_default_cape(cape: Option) -> Result<()> { + Ok(minecraft_skins::set_default_cape(cape).await?) +} + +/// `invoke('plugin:minecraft-skins|equip_skin', skin)` +/// +/// See also: [minecraft_skins::equip_skin] +#[tauri::command] +pub async fn equip_skin(skin: Skin) -> Result<()> { + Ok(minecraft_skins::equip_skin(skin).await?) +} + +/// `invoke('plugin:minecraft-skins|remove_custom_skin', skin)` +/// +/// See also: [minecraft_skins::remove_custom_skin] +#[tauri::command] +pub async fn remove_custom_skin(skin: Skin) -> Result<()> { + Ok(minecraft_skins::remove_custom_skin(skin).await?) +} + +/// `invoke('plugin:minecraft-skins|unequip_skin')` +/// +/// See also: [minecraft_skins::unequip_skin] +#[tauri::command] +pub async fn unequip_skin() -> Result<()> { + Ok(minecraft_skins::unequip_skin().await?) +} + +/// `invoke('plugin:minecraft-skins|normalize_skin_texture')` +/// +/// See also: [minecraft_skins::normalize_skin_texture] +#[tauri::command] +pub async fn normalize_skin_texture(texture: UrlOrBlob) -> Result { + Ok(minecraft_skins::normalize_skin_texture(&texture).await?) +} + +/// `invoke('plugin:minecraft-skins|get_dragged_skin_data', path)` +/// +/// See also: [minecraft_skins::get_dragged_skin_data] +#[tauri::command] +pub async fn get_dragged_skin_data(path: String) -> Result { + let path = Path::new(&path); + Ok(minecraft_skins::get_dragged_skin_data(path).await?) +} diff --git a/apps/app/src/api/mod.rs b/apps/app/src/api/mod.rs index 09d37e87a..294e784f6 100644 --- a/apps/app/src/api/mod.rs +++ b/apps/app/src/api/mod.rs @@ -7,6 +7,7 @@ pub mod import; pub mod jre; pub mod logs; pub mod metadata; +pub mod minecraft_skins; pub mod mr_auth; pub mod pack; pub mod process; diff --git a/apps/app/src/api/profile.rs b/apps/app/src/api/profile.rs index db979be35..1d812639e 100644 --- a/apps/app/src/api/profile.rs +++ b/apps/app/src/api/profile.rs @@ -28,7 +28,6 @@ pub fn init() -> tauri::plugin::TauriPlugin { profile_update_managed_modrinth_version, profile_repair_managed_modrinth, profile_run, - profile_run_credentials, profile_kill, profile_edit, profile_edit_icon, @@ -256,22 +255,6 @@ pub async fn profile_run(path: &str) -> Result { Ok(process) } -// Run Minecraft using a profile using chosen credentials -// Returns the UUID, which can be used to poll -// for the actual Child in the state. -// invoke('plugin:profile|profile_run_credentials', {path, credentials})') -#[tauri::command] -pub async fn profile_run_credentials( - path: &str, - credentials: Credentials, -) -> Result { - let process = - profile::run_credentials(path, &credentials, &QuickPlayType::None) - .await?; - - Ok(process) -} - #[tauri::command] pub async fn profile_kill(path: &str) -> Result<()> { profile::kill(path).await?; diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs index c08c1178d..b6b00ea81 100644 --- a/apps/app/src/main.rs +++ b/apps/app/src/main.rs @@ -249,6 +249,7 @@ fn main() { .plugin(api::logs::init()) .plugin(api::jre::init()) .plugin(api::metadata::init()) + .plugin(api::minecraft_skins::init()) .plugin(api::pack::init()) .plugin(api::process::init()) .plugin(api::profile::init()) diff --git a/apps/frontend/src/public/news/feed/articles.json b/apps/frontend/src/public/news/feed/articles.json index 652b01d8c..49c4b1891 100644 --- a/apps/frontend/src/public/news/feed/articles.json +++ b/apps/frontend/src/public/news/feed/articles.json @@ -4,7 +4,7 @@ "title": "Creator Updates, July 2025", "summary": "Addressing recent growth and growing pains that have been affecting creators.", "thumbnail": "https://modrinth.com/news/default.webp", - "date": "2025-07-02T03:00:00.000Z", + "date": "2025-07-02T04:20:00.000Z", "link": "https://modrinth.com/news/article/creator-updates-july-2025" }, { diff --git a/packages/app-lib/.sqlx/query-27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22.json b/packages/app-lib/.sqlx/query-27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22.json new file mode 100644 index 000000000..26c250c78 --- /dev/null +++ b/packages/app-lib/.sqlx/query-27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22" +} diff --git a/packages/app-lib/.sqlx/query-759e4ffe30ebc4f8602256cb419ef15732d84bcebb9ca15225dbabdc0f46ba2d.json b/packages/app-lib/.sqlx/query-3613473fb4d836ee0fb3c292e6bf5e50912064c29ebf1a1e5ead79c44c37e64c.json similarity index 88% rename from packages/app-lib/.sqlx/query-759e4ffe30ebc4f8602256cb419ef15732d84bcebb9ca15225dbabdc0f46ba2d.json rename to packages/app-lib/.sqlx/query-3613473fb4d836ee0fb3c292e6bf5e50912064c29ebf1a1e5ead79c44c37e64c.json index 0e8fd8613..2fce764bc 100644 --- a/packages/app-lib/.sqlx/query-759e4ffe30ebc4f8602256cb419ef15732d84bcebb9ca15225dbabdc0f46ba2d.json +++ b/packages/app-lib/.sqlx/query-3613473fb4d836ee0fb3c292e6bf5e50912064c29ebf1a1e5ead79c44c37e64c.json @@ -1,12 +1,12 @@ { "db_name": "SQLite", - "query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27\n ", + "query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27,\n hide_nametag_skins_page = $28\n ", "describe": { "columns": [], "parameters": { - "Right": 27 + "Right": 28 }, "nullable": [] }, - "hash": "759e4ffe30ebc4f8602256cb419ef15732d84bcebb9ca15225dbabdc0f46ba2d" + "hash": "3613473fb4d836ee0fb3c292e6bf5e50912064c29ebf1a1e5ead79c44c37e64c" } diff --git a/packages/app-lib/.sqlx/query-3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944.json b/packages/app-lib/.sqlx/query-3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944.json new file mode 100644 index 000000000..cf3645df1 --- /dev/null +++ b/packages/app-lib/.sqlx/query-3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR REPLACE INTO default_minecraft_capes (minecraft_user_uuid, id) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944" +} diff --git a/packages/app-lib/.sqlx/query-3f3d3c2d77c1bcaf4044b612c3822546583aa19ea7088682d718c64ed5d5f1c5.json b/packages/app-lib/.sqlx/query-3f3d3c2d77c1bcaf4044b612c3822546583aa19ea7088682d718c64ed5d5f1c5.json new file mode 100644 index 000000000..f34447870 --- /dev/null +++ b/packages/app-lib/.sqlx/query-3f3d3c2d77c1bcaf4044b612c3822546583aa19ea7088682d718c64ed5d5f1c5.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT texture FROM custom_minecraft_skin_textures WHERE texture_key = ?", + "describe": { + "columns": [ + { + "name": "texture", + "ordinal": 0, + "type_info": "Blob" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "3f3d3c2d77c1bcaf4044b612c3822546583aa19ea7088682d718c64ed5d5f1c5" +} diff --git a/packages/app-lib/.sqlx/query-4c8063f9ce2fd7deec9b69e0b2c1055fe47287d7f99be41215c25c1019d439b9.json b/packages/app-lib/.sqlx/query-4c8063f9ce2fd7deec9b69e0b2c1055fe47287d7f99be41215c25c1019d439b9.json new file mode 100644 index 000000000..4b7932bbe --- /dev/null +++ b/packages/app-lib/.sqlx/query-4c8063f9ce2fd7deec9b69e0b2c1055fe47287d7f99be41215c25c1019d439b9.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "4c8063f9ce2fd7deec9b69e0b2c1055fe47287d7f99be41215c25c1019d439b9" +} diff --git a/packages/app-lib/.sqlx/query-d90a2f2f823fc546661a94af07249758c5ca82db396268bca5087bac88f733d9.json b/packages/app-lib/.sqlx/query-5193f519f021b2e7013cdb67a6e1a31ae4bd7532d02f8b00b43d5645351941ca.json similarity index 80% rename from packages/app-lib/.sqlx/query-d90a2f2f823fc546661a94af07249758c5ca82db396268bca5087bac88f733d9.json rename to packages/app-lib/.sqlx/query-5193f519f021b2e7013cdb67a6e1a31ae4bd7532d02f8b00b43d5645351941ca.json index 72b34a957..5dc714e29 100644 --- a/packages/app-lib/.sqlx/query-d90a2f2f823fc546661a94af07249758c5ca82db396268bca5087bac88f733d9.json +++ b/packages/app-lib/.sqlx/query-5193f519f021b2e7013cdb67a6e1a31ae4bd7532d02f8b00b43d5645351941ca.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar\n FROM settings\n ", + "query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar\n FROM settings\n ", "describe": { "columns": [ { @@ -29,113 +29,118 @@ "type_info": "Integer" }, { - "name": "advanced_rendering", + "name": "hide_nametag_skins_page", "ordinal": 5, "type_info": "Integer" }, { - "name": "native_decorations", + "name": "advanced_rendering", "ordinal": 6, "type_info": "Integer" }, { - "name": "discord_rpc", + "name": "native_decorations", "ordinal": 7, "type_info": "Integer" }, { - "name": "developer_mode", + "name": "discord_rpc", "ordinal": 8, "type_info": "Integer" }, { - "name": "telemetry", + "name": "developer_mode", "ordinal": 9, "type_info": "Integer" }, { - "name": "personalized_ads", + "name": "telemetry", "ordinal": 10, "type_info": "Integer" }, { - "name": "onboarded", + "name": "personalized_ads", "ordinal": 11, "type_info": "Integer" }, { - "name": "extra_launch_args", + "name": "onboarded", "ordinal": 12, - "type_info": "Text" + "type_info": "Integer" }, { - "name": "custom_env_vars", + "name": "extra_launch_args", "ordinal": 13, "type_info": "Text" }, { - "name": "mc_memory_max", + "name": "custom_env_vars", "ordinal": 14, - "type_info": "Integer" + "type_info": "Text" }, { - "name": "mc_force_fullscreen", + "name": "mc_memory_max", "ordinal": 15, "type_info": "Integer" }, { - "name": "mc_game_resolution_x", + "name": "mc_force_fullscreen", "ordinal": 16, "type_info": "Integer" }, { - "name": "mc_game_resolution_y", + "name": "mc_game_resolution_x", "ordinal": 17, "type_info": "Integer" }, { - "name": "hide_on_process_start", + "name": "mc_game_resolution_y", "ordinal": 18, "type_info": "Integer" }, { - "name": "hook_pre_launch", + "name": "hide_on_process_start", "ordinal": 19, - "type_info": "Text" + "type_info": "Integer" }, { - "name": "hook_wrapper", + "name": "hook_pre_launch", "ordinal": 20, "type_info": "Text" }, { - "name": "hook_post_exit", + "name": "hook_wrapper", "ordinal": 21, "type_info": "Text" }, { - "name": "custom_dir", + "name": "hook_post_exit", "ordinal": 22, "type_info": "Text" }, { - "name": "prev_custom_dir", + "name": "custom_dir", "ordinal": 23, "type_info": "Text" }, { - "name": "migrated", + "name": "prev_custom_dir", "ordinal": 24, + "type_info": "Text" + }, + { + "name": "migrated", + "ordinal": 25, "type_info": "Integer" }, { "name": "feature_flags", - "ordinal": 25, + "ordinal": 26, "type_info": "Text" }, { "name": "toggle_sidebar", - "ordinal": 26, + "ordinal": 27, "type_info": "Integer" } ], @@ -155,6 +160,7 @@ false, false, false, + false, null, null, false, @@ -172,5 +178,5 @@ false ] }, - "hash": "d90a2f2f823fc546661a94af07249758c5ca82db396268bca5087bac88f733d9" + "hash": "5193f519f021b2e7013cdb67a6e1a31ae4bd7532d02f8b00b43d5645351941ca" } diff --git a/packages/app-lib/.sqlx/query-545b01d8cc1e79ff5d4136887fbb712aba58908a66dd7bbd64c293b9ee7a1523.json b/packages/app-lib/.sqlx/query-545b01d8cc1e79ff5d4136887fbb712aba58908a66dd7bbd64c293b9ee7a1523.json new file mode 100644 index 000000000..ee92d633c --- /dev/null +++ b/packages/app-lib/.sqlx/query-545b01d8cc1e79ff5d4136887fbb712aba58908a66dd7bbd64c293b9ee7a1523.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR REPLACE INTO custom_minecraft_skin_textures (texture_key, texture) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "545b01d8cc1e79ff5d4136887fbb712aba58908a66dd7bbd64c293b9ee7a1523" +} diff --git a/packages/app-lib/.sqlx/query-6e3fa492c085ebb8e7280dd4d55cdcf73da199ea6ac05ee3ee798ece80d877cf.json b/packages/app-lib/.sqlx/query-6e3fa492c085ebb8e7280dd4d55cdcf73da199ea6ac05ee3ee798ece80d877cf.json index 22e39e75b..9742cb7b4 100644 --- a/packages/app-lib/.sqlx/query-6e3fa492c085ebb8e7280dd4d55cdcf73da199ea6ac05ee3ee798ece80d877cf.json +++ b/packages/app-lib/.sqlx/query-6e3fa492c085ebb8e7280dd4d55cdcf73da199ea6ac05ee3ee798ece80d877cf.json @@ -41,7 +41,7 @@ { "name": "display_claims!: serde_json::Value", "ordinal": 7, - "type_info": "Text" + "type_info": "Null" } ], "parameters": { diff --git a/packages/app-lib/.sqlx/query-957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246.json b/packages/app-lib/.sqlx/query-957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246.json new file mode 100644 index 000000000..2c946cb4e --- /dev/null +++ b/packages/app-lib/.sqlx/query-957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS 'id: Hyphenated' FROM default_minecraft_capes WHERE minecraft_user_uuid = ?", + "describe": { + "columns": [ + { + "name": "id: Hyphenated", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246" +} diff --git a/packages/app-lib/.sqlx/query-aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac.json b/packages/app-lib/.sqlx/query-aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac.json new file mode 100644 index 000000000..4d0c3892f --- /dev/null +++ b/packages/app-lib/.sqlx/query-aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? ORDER BY rowid ASC LIMIT ? OFFSET ?", + "describe": { + "columns": [ + { + "name": "texture_key", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "variant: MinecraftSkinVariant", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "cape_id: Hyphenated", + "ordinal": 2, + "type_info": "Text" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac" +} diff --git a/packages/app-lib/.sqlx/query-e9449930a74c6a6151c3d868042b878b6789927df5adf50986fe642c8afcb681.json b/packages/app-lib/.sqlx/query-e9449930a74c6a6151c3d868042b878b6789927df5adf50986fe642c8afcb681.json new file mode 100644 index 000000000..a09ac2ff7 --- /dev/null +++ b/packages/app-lib/.sqlx/query-e9449930a74c6a6151c3d868042b878b6789927df5adf50986fe642c8afcb681.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "e9449930a74c6a6151c3d868042b878b6789927df5adf50986fe642c8afcb681" +} diff --git a/packages/app-lib/.sqlx/query-faa8437519571b147b0135054847674be5035f385e0d85e759d4bbf9bca54f20.json b/packages/app-lib/.sqlx/query-faa8437519571b147b0135054847674be5035f385e0d85e759d4bbf9bca54f20.json new file mode 100644 index 000000000..ad8564624 --- /dev/null +++ b/packages/app-lib/.sqlx/query-faa8437519571b147b0135054847674be5035f385e0d85e759d4bbf9bca54f20.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ? AND variant = ? AND cape_id IS ?", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "faa8437519571b147b0135054847674be5035f385e0d85e759d4bbf9bca54f20" +} diff --git a/packages/app-lib/.sqlx/query-fd494269d944b179ade61876669d1821b46d60f5cb79685c489aaebf13b35d24.json b/packages/app-lib/.sqlx/query-fd494269d944b179ade61876669d1821b46d60f5cb79685c489aaebf13b35d24.json new file mode 100644 index 000000000..ee41aad88 --- /dev/null +++ b/packages/app-lib/.sqlx/query-fd494269d944b179ade61876669d1821b46d60f5cb79685c489aaebf13b35d24.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "fd494269d944b179ade61876669d1821b46d60f5cb79685c489aaebf13b35d24" +} diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml index f19a0620b..bfb0745ef 100644 --- a/packages/app-lib/Cargo.toml +++ b/packages/app-lib/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Jai A "] edition.workspace = true [dependencies] -bytes.workspace = true +bytes = { workspace = true, features = ["serde"] } serde = { workspace = true, features = ["derive"] } serde_json.workspace = true serde_ini.workspace = true @@ -24,6 +24,9 @@ enumset.workspace = true chardetng.workspace = true encoding_rs.workspace = true hashlink.workspace = true +png.workspace = true +bytemuck.workspace = true +rgb.workspace = true chrono = { workspace = true, features = ["serde"] } daedalus.workspace = true @@ -33,21 +36,23 @@ regex.workspace = true sysinfo = { workspace = true, features = ["system", "disk"] } thiserror.workspace = true either.workspace = true +data-url.workspace = true tracing.workspace = true tracing-subscriber = { workspace = true, features = ["chrono", "env-filter"] } tracing-error.workspace = true paste.workspace = true +heck.workspace = true tauri = { workspace = true, optional = true, features = ["unstable"] } indicatif = { workspace = true, optional = true } async-tungstenite = { workspace = true, features = ["tokio-runtime", "tokio-rustls-webpki-roots"] } futures = { workspace = true, features = ["async-await", "alloc"] } -reqwest = { workspace = true, features = ["json", "stream", "deflate", "gzip", "brotli", "rustls-tls-webpki-roots", "charset", "http2", "macos-system-configuration"] } +reqwest = { workspace = true, features = ["json", "stream", "deflate", "gzip", "brotli", "rustls-tls-webpki-roots", "charset", "http2", "macos-system-configuration", "multipart"] } tokio = { workspace = true, features = ["time", "io-util", "net", "sync", "fs", "macros", "process"] } -tokio-util = { workspace = true, features = ["compat"] } +tokio-util = { workspace = true, features = ["compat", "io", "io-util"] } async-recursion.workspace = true fs4 = { workspace = true, features = ["tokio"] } async-walkdir.workspace = true @@ -66,7 +71,7 @@ p256 = { workspace = true, features = ["ecdsa"] } rand.workspace = true base64.workspace = true -sqlx = { workspace = true, features = ["runtime-tokio", "sqlite", "macros", "migrate", "json"] } +sqlx = { workspace = true, features = ["runtime-tokio", "sqlite", "macros", "migrate", "json", "uuid"] } quartz_nbt = { workspace = true, features = ["serde"] } hickory-resolver.workspace = true diff --git a/packages/app-lib/migrations/20250413162050_skin-selector.sql b/packages/app-lib/migrations/20250413162050_skin-selector.sql new file mode 100644 index 000000000..8053e5ade --- /dev/null +++ b/packages/app-lib/migrations/20250413162050_skin-selector.sql @@ -0,0 +1,80 @@ +CREATE TABLE default_minecraft_capes ( + minecraft_user_uuid TEXT NOT NULL, + id TEXT NOT NULL, + + PRIMARY KEY (minecraft_user_uuid, id) +); + +-- Emulate a ON UPDATE CASCADE foreign key constraint for the user UUID on the default_minecraft_capes table, +-- but allowing deletion of the user UUID in the minecraft_users table. This allows the application to temporarily +-- keep skin state around for logged-out users, allowing them to retain their skins under the right conditions +CREATE TRIGGER default_minecraft_capes_user_uuid_insert_check + BEFORE INSERT ON default_minecraft_capes FOR EACH ROW + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM minecraft_users WHERE uuid = NEW.minecraft_user_uuid + ) THEN RAISE(ABORT, 'Cannot add a default cape for an unknown Minecraft user UUID') END; + END; + +CREATE TRIGGER default_minecraft_capes_user_uuid_update_check + BEFORE UPDATE ON default_minecraft_capes FOR EACH ROW + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM minecraft_users WHERE uuid = NEW.minecraft_user_uuid + ) THEN RAISE(ABORT, 'Cannot change a default cape to refer to an unknown Minecraft user UUID') END; + END; + +CREATE TRIGGER default_minecraft_capes_user_uuid_update_cascade + AFTER UPDATE OF uuid ON minecraft_users FOR EACH ROW + BEGIN + UPDATE default_minecraft_capes SET minecraft_user_uuid = NEW.uuid WHERE minecraft_user_uuid = OLD.uuid; + END; + +CREATE TABLE custom_minecraft_skins ( + minecraft_user_uuid TEXT NOT NULL, + texture_key TEXT NOT NULL, + variant TEXT NOT NULL CHECK (variant IN ('CLASSIC', 'SLIM', 'UNKNOWN')), + cape_id TEXT, + + PRIMARY KEY (minecraft_user_uuid, texture_key, variant, cape_id), + FOREIGN KEY (texture_key) REFERENCES custom_minecraft_skin_textures(texture_key) + ON DELETE CASCADE ON UPDATE CASCADE +); + +-- Similar partial foreign key emulation as above +CREATE TRIGGER custom_minecraft_skins_user_uuid_insert_check + BEFORE INSERT ON custom_minecraft_skins FOR EACH ROW + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM minecraft_users WHERE uuid = NEW.minecraft_user_uuid + ) THEN RAISE(ABORT, 'Cannot add a custom skin for an unknown Minecraft user UUID') END; + END; + +CREATE TRIGGER custom_minecraft_skins_user_uuid_update_check + BEFORE UPDATE ON custom_minecraft_skins FOR EACH ROW + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM minecraft_users WHERE uuid = NEW.minecraft_user_uuid + ) THEN RAISE(ABORT, 'Cannot change a custom skin to refer to an unknown Minecraft user UUID') END; + END; + +CREATE TRIGGER custom_minecraft_skins_user_uuid_update_cascade + AFTER UPDATE OF uuid ON minecraft_users FOR EACH ROW + BEGIN + UPDATE custom_minecraft_skins SET minecraft_user_uuid = NEW.uuid WHERE minecraft_user_uuid = OLD.uuid; + END; + +CREATE TABLE custom_minecraft_skin_textures ( + texture_key TEXT NOT NULL, + texture PNG BLOB NOT NULL, + + PRIMARY KEY (texture_key) +); + +CREATE TRIGGER custom_minecraft_skin_texture_delete_cleanup + AFTER DELETE ON custom_minecraft_skins FOR EACH ROW + BEGIN + DELETE FROM custom_minecraft_skin_textures WHERE texture_key NOT IN ( + SELECT texture_key FROM custom_minecraft_skins + ); + END; diff --git a/packages/app-lib/migrations/20250514181748_skin_nametag_setting.sql b/packages/app-lib/migrations/20250514181748_skin_nametag_setting.sql new file mode 100644 index 000000000..faba8e36f --- /dev/null +++ b/packages/app-lib/migrations/20250514181748_skin_nametag_setting.sql @@ -0,0 +1 @@ +ALTER TABLE settings ADD COLUMN hide_nametag_skins_page INTEGER NOT NULL DEFAULT 0 CHECK (hide_nametag_skins_page IN (0, 1)); diff --git a/packages/app-lib/src/api/logs.rs b/packages/app-lib/src/api/logs.rs index 7d24418b5..265d9bcb4 100644 --- a/packages/app-lib/src/api/logs.rs +++ b/packages/app-lib/src/api/logs.rs @@ -39,21 +39,27 @@ pub struct LatestLogCursor { #[serde(transparent)] pub struct CensoredString(String); impl CensoredString { - pub fn censor(mut s: String, credentials_set: &Vec) -> Self { + pub fn censor(mut s: String, credentials_list: &[Credentials]) -> Self { let username = whoami::username(); s = s .replace(&format!("/{username}/"), "/{COMPUTER_USERNAME}/") .replace(&format!("\\{username}\\"), "\\{COMPUTER_USERNAME}\\"); - for credentials in credentials_set { + for credentials in credentials_list { + // Use the offline profile to guarantee that this function does not cause + // Mojang API request, and is never delayed by a network request. The offline + // profile is optimistically updated on upsert from time to time anyway s = s .replace(&credentials.access_token, "{MINECRAFT_ACCESS_TOKEN}") - .replace(&credentials.username, "{MINECRAFT_USERNAME}") .replace( - &credentials.id.as_simple().to_string(), + &credentials.offline_profile.name, + "{MINECRAFT_USERNAME}", + ) + .replace( + &credentials.offline_profile.id.as_simple().to_string(), "{MINECRAFT_UUID}", ) .replace( - &credentials.id.as_hyphenated().to_string(), + &credentials.offline_profile.id.as_hyphenated().to_string(), "{MINECRAFT_UUID}", ); } @@ -210,7 +216,7 @@ pub async fn get_output_by_filename( .await? .into_iter() .map(|x| x.1) - .collect(); + .collect::>(); // Load .gz file into String if let Some(ext) = path.extension() { @@ -350,7 +356,7 @@ pub async fn get_generic_live_log_cursor( .await? .into_iter() .map(|x| x.1) - .collect(); + .collect::>(); let output = CensoredString::censor(output, &credentials); Ok(LatestLogCursor { cursor, diff --git a/packages/app-lib/src/api/minecraft_auth.rs b/packages/app-lib/src/api/minecraft_auth.rs index 4fa75a4c8..568a6aca1 100644 --- a/packages/app-lib/src/api/minecraft_auth.rs +++ b/packages/app-lib/src/api/minecraft_auth.rs @@ -23,8 +23,8 @@ pub async fn finish_login( #[tracing::instrument] pub async fn get_default_user() -> crate::Result> { let state = State::get().await?; - let users = Credentials::get_active(&state.pool).await?; - Ok(users.map(|x| x.id)) + let user = Credentials::get_active(&state.pool).await?; + Ok(user.map(|user| user.offline_profile.id)) } #[tracing::instrument] diff --git a/packages/app-lib/src/api/minecraft_skins.rs b/packages/app-lib/src/api/minecraft_skins.rs new file mode 100644 index 000000000..2a869a22d --- /dev/null +++ b/packages/app-lib/src/api/minecraft_skins.rs @@ -0,0 +1,530 @@ +//! Theseus skin management interface + +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; + +pub use bytes::Bytes; +use futures::{StreamExt, TryStreamExt, stream}; +use serde::{Deserialize, Serialize}; +use url::Url; +use uuid::Uuid; + +pub use crate::state::MinecraftSkinVariant; +use crate::{ + ErrorKind, State, + state::{ + MinecraftCharacterExpressionState, MinecraftProfile, + minecraft_skins::{ + CustomMinecraftSkin, DefaultMinecraftCape, mojang_api, + }, + }, +}; + +use super::data::Credentials; + +mod assets { + mod default { + mod default_skins; + pub use default_skins::DEFAULT_SKINS; + } + pub use default::DEFAULT_SKINS; +} + +mod png_util; + +#[derive(Deserialize, Serialize, Debug)] +pub struct Cape { + /// An identifier for this cape, potentially unique to the owning player. + pub id: Uuid, + /// The name of the cape. + pub name: Arc, + /// The URL of the cape PNG texture. + pub texture: Arc, + /// Whether the cape is the default one, used when the currently selected cape does not + /// override it. + pub is_default: bool, + /// Whether the cape is currently equipped in the Minecraft profile of its corresponding + /// player. + pub is_equipped: bool, +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct Skin { + /// An opaque identifier for the skin texture, which can be used to identify it. + pub texture_key: Arc, + /// The name of the skin, if available. + pub name: Option>, + /// The variant of the skin model. + pub variant: MinecraftSkinVariant, + /// The UUID of the cape that this skin uses, if any. + /// + /// If `None`, the skin does not have an explicit cape set, and the default cape for + /// this player, if any, should be used. + pub cape_id: Option, + /// The URL of the skin PNG texture. Can also be a data URL. + pub texture: Arc, + /// The source of the skin, which represents how the app knows about it. + pub source: SkinSource, + /// Whether the skin is currently equipped in the Minecraft profile of its corresponding + /// player. + pub is_equipped: bool, +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum SkinSource { + /// A default Minecraft skin, which may be assigned to players at random by default. + Default, + /// A skin that is not the default, but is not a custom skin managed by our app either. + CustomExternal, + /// A custom skin we have set up in our app. + Custom, +} + +/// Represents either a URL or a blob for a Minecraft skin PNG texture. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(untagged)] +pub enum UrlOrBlob { + Url(Url), + Blob(Bytes), +} + +/// Retrieves the available capes for the currently selected Minecraft profile. At most one cape +/// can be equipped at a time. Also, at most one cape can be set as the default cape. +#[tracing::instrument] +pub async fn get_available_capes() -> crate::Result> { + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + let profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + + let default_cape_id = DefaultMinecraftCape::get(profile.id, &state.pool) + .await? + .map(|cape| cape.id); + + Ok(profile + .capes + .iter() + .map(|cape| Cape { + id: cape.id, + name: Arc::clone(&cape.name), + texture: Arc::clone(&cape.url), + is_default: default_cape_id + .is_some_and(|default_cape_id| default_cape_id == cape.id), + is_equipped: cape.state + == MinecraftCharacterExpressionState::Active, + }) + .collect()) +} + +/// Retrieves the available skins for the currently selected Minecraft profile. At the moment, +/// this includes custom skins stored in the app database, default Mojang skins, and the currently +/// equipped skin, if different from the previous skins. Exactly one of the returned skins is +/// marked as equipped. +#[tracing::instrument] +pub async fn get_available_skins() -> crate::Result> { + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + let profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + + let current_skin = profile.current_skin()?; + let current_cape_id = profile.current_cape().map(|cape| cape.id); + let default_cape_id = DefaultMinecraftCape::get(profile.id, &state.pool) + .await? + .map(|cape| cape.id); + + // Keep track of whether we have found the currently equipped skin, to potentially avoid marking + // several skins as equipped, and know if the equipped skin was found (see below) + let found_equipped_skin = Arc::new(AtomicBool::new(false)); + + let custom_skins = CustomMinecraftSkin::get_all(profile.id, &state.pool) + .await? + .then(|custom_skin| { + let found_equipped_skin = Arc::clone(&found_equipped_skin); + let state = Arc::clone(&state); + async move { + // Several custom skins may reuse the same texture for different cape or skin model + // variations, so check all attributes for correctness + let is_equipped = !found_equipped_skin.load(Ordering::Acquire) + && custom_skin.texture_key == *current_skin.texture_key() + && custom_skin.variant == current_skin.variant + && custom_skin.cape_id + == if custom_skin.cape_id.is_some() { + current_cape_id + } else { + default_cape_id + }; + + found_equipped_skin.fetch_or(is_equipped, Ordering::AcqRel); + + Ok::<_, crate::Error>(Skin { + name: None, + variant: custom_skin.variant, + cape_id: custom_skin.cape_id, + texture: png_util::blob_to_data_url( + custom_skin.texture_blob(&state.pool).await?, + ) + .or_else(|| { + // Fall back to a placeholder texture if the DB somehow contains corrupt data + png_util::blob_to_data_url(include_bytes!( + "minecraft_skins/assets/default/MissingNo.png" + )) + }) + .unwrap(), + source: SkinSource::Custom, + is_equipped, + texture_key: custom_skin.texture_key.into(), + }) + } + }); + + let default_skins = + stream::iter(assets::DEFAULT_SKINS.iter().map(|default_skin| { + let is_equipped = !found_equipped_skin.load(Ordering::Acquire) + && default_skin.texture_key == current_skin.texture_key() + && default_skin.variant == current_skin.variant; + + found_equipped_skin.fetch_or(is_equipped, Ordering::AcqRel); + + Ok::<_, crate::Error>(Skin { + texture_key: Arc::clone(&default_skin.texture_key), + name: default_skin.name.as_ref().cloned(), + variant: default_skin.variant, + cape_id: None, + texture: Arc::clone(&default_skin.texture), + source: SkinSource::Default, + is_equipped, + }) + })); + + let mut available_skins = custom_skins + .chain(default_skins) + .try_collect::>() + .await?; + + // If the currently equipped skin does not match any of the skins we know about, + // add it to the list of available skins as a custom external skin, set by an + // external service (e.g., the Minecraft launcher or website). This way we guarantee + // that the currently equipped skin is always returned as available + if !found_equipped_skin.load(Ordering::Acquire) { + available_skins.push(Skin { + texture_key: current_skin.texture_key(), + name: current_skin.name.as_deref().map(Arc::from), + variant: current_skin.variant, + cape_id: current_cape_id, + texture: Arc::clone(¤t_skin.url), + source: SkinSource::CustomExternal, + is_equipped: true, + }); + } + + Ok(available_skins) +} + +/// Adds a custom skin to the app database and equips it for the currently selected +/// Minecraft profile. +#[tracing::instrument(skip(texture_blob))] +pub async fn add_and_equip_custom_skin( + texture_blob: Bytes, + variant: MinecraftSkinVariant, + cape_override: Option, +) -> crate::Result<()> { + let (skin_width, skin_height) = png_util::dimensions(&texture_blob)?; + if skin_width != 64 || ![32, 64].contains(&skin_height) { + return Err(ErrorKind::InvalidSkinTexture)?; + } + + let cape_override = cape_override.map(|cape| cape.id); + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + // We have to equip the skin first, as it's the Mojang API backend who knows + // how to compute the texture key we require, which we can then read from the + // updated player profile + mojang_api::MinecraftSkinOperation::equip( + &selected_credentials, + stream::iter([Ok::<_, String>(Bytes::clone(&texture_blob))]), + variant, + ) + .await?; + + let profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + + sync_cape(&state, &selected_credentials, &profile, cape_override).await?; + + CustomMinecraftSkin::add( + profile.id, + &profile.current_skin()?.texture_key(), + &texture_blob, + variant, + cape_override, + &state.pool, + ) + .await?; + + Ok(()) +} + +/// Sets the default cape for the currently selected Minecraft profile. If `None`, +/// the default cape will be removed. +/// +/// This cape will be used by any custom skin that does not have a cape override +/// set. If the currently equipped skin does not have a cape override set, the equipped +/// cape will also be changed to the new default cape. When neither the equipped skin +/// defines a cape override nor the default cape is set, the player will have no +/// cape equipped. +#[tracing::instrument] +pub async fn set_default_cape(cape: Option) -> crate::Result<()> { + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + let profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + let current_skin = get_available_skins() + .await? + .into_iter() + .find(|skin| skin.is_equipped) + .unwrap(); + + if let Some(cape) = cape { + // Synchronize the equipped cape with the new default cape, if the current skin uses + // the default cape + if current_skin.cape_id.is_none() { + mojang_api::MinecraftCapeOperation::equip( + &selected_credentials, + cape.id, + ) + .await?; + } + + DefaultMinecraftCape::set(profile.id, cape.id, &state.pool).await?; + } else { + if current_skin.cape_id.is_none() { + mojang_api::MinecraftCapeOperation::unequip_any( + &selected_credentials, + ) + .await?; + } + + DefaultMinecraftCape::remove(profile.id, &state.pool).await?; + } + + Ok(()) +} + +/// Equips the given skin for the currently selected Minecraft profile. If the skin is already +/// equipped, it will be re-equipped. +/// +/// This function does not check that the passed skin, if custom, exists in the app database, +/// giving the caller complete freedom to equip any skin at any time. +#[tracing::instrument] +pub async fn equip_skin(skin: Skin) -> crate::Result<()> { + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + let profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + + mojang_api::MinecraftSkinOperation::equip( + &selected_credentials, + png_util::url_to_data_stream(&skin.texture).await?, + skin.variant, + ) + .await?; + + sync_cape(&state, &selected_credentials, &profile, skin.cape_id).await?; + + Ok(()) +} + +/// Removes a custom skin from the app database. +/// +/// The player will continue to be equipped with the same skin and cape as before, even if +/// the currently selected skin is the one being removed. This gives frontend code more options +/// to decide between unequipping strategies: falling back to other custom skin, to a default +/// skin, letting the user choose another skin, etc. +#[tracing::instrument] +pub async fn remove_custom_skin(skin: Skin) -> crate::Result<()> { + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + CustomMinecraftSkin { + texture_key: skin.texture_key.to_string(), + variant: skin.variant, + cape_id: skin.cape_id, + } + .remove( + selected_credentials.maybe_online_profile().await.id, + &state.pool, + ) + .await?; + + Ok(()) +} + +/// Unequips the currently equipped skin for the currently selected Minecraft profile, resetting +/// it to one of the default skins. The cape will be set to the default cape, or unequipped if +/// no default cape is set. +#[tracing::instrument] +pub async fn unequip_skin() -> crate::Result<()> { + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + let profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + + mojang_api::MinecraftSkinOperation::unequip_any(&selected_credentials) + .await?; + + sync_cape(&state, &selected_credentials, &profile, None).await?; + + Ok(()) +} + +/// Normalizes the texture of a Minecraft skin to the modern 64x64 format, handling +/// legacy 64x32 skins as the vanilla game client does. This function prioritizes +/// PNG encoding speed over compression density, so the resulting textures are better +/// suited for display purposes, not persistent storage or transmission. +/// +/// The normalized, processed is returned texture as a byte array in PNG format. +#[tracing::instrument] +pub async fn normalize_skin_texture( + texture: &UrlOrBlob, +) -> crate::Result { + png_util::normalize_skin_texture(texture).await +} + +/// Reads and validates a skin texture file from the given path. +/// Returns the file content as bytes if it's a valid skin texture (PNG with 64x64 or 64x32 dimensions). +#[tracing::instrument] +pub async fn get_dragged_skin_data( + path: &std::path::Path, +) -> crate::Result { + if let Some(extension) = path.extension() { + if extension.to_string_lossy().to_lowercase() != "png" { + return Err(ErrorKind::InvalidSkinTexture.into()); + } + } else { + return Err(ErrorKind::InvalidSkinTexture.into()); + } + + tracing::debug!("Reading file: {:?}", path); + + if !path.exists() { + tracing::error!("File does not exist: {:?}", path); + return Err(ErrorKind::InvalidSkinTexture.into()); + } + + let data = match tokio::fs::read(path).await { + Ok(data) => { + tracing::debug!( + "File read successfully, size: {} bytes", + data.len() + ); + data + } + Err(err) => { + tracing::error!("Failed to read file: {}", err); + return Err(err.into()); + } + }; + + let url_or_blob = UrlOrBlob::Blob(data.clone().into()); + + match normalize_skin_texture(&url_or_blob).await { + Ok(_) => Ok(data.into()), + Err(err) => { + tracing::error!("Failed to normalize skin texture: {}", err); + Err(ErrorKind::InvalidSkinTexture.into()) + } + } +} + +/// Synchronizes the equipped cape with the selected cape if necessary, taking into +/// account the currently equipped cape, the default cape for the player, and if a +/// cape override is provided. +async fn sync_cape( + state: &State, + selected_credentials: &Credentials, + profile: &MinecraftProfile, + cape_override: Option, +) -> crate::Result<()> { + let current_cape_id = profile.current_cape().map(|cape| cape.id); + let target_cape_id = match cape_override { + Some(cape_id) => Some(cape_id), + None => DefaultMinecraftCape::get(profile.id, &state.pool) + .await? + .map(|cape| cape.id), + }; + + if current_cape_id != target_cape_id { + match target_cape_id { + Some(cape_id) => { + mojang_api::MinecraftCapeOperation::equip( + selected_credentials, + cape_id, + ) + .await? + } + None => { + mojang_api::MinecraftCapeOperation::unequip_any( + selected_credentials, + ) + .await? + } + } + } + + Ok(()) +} diff --git a/packages/app-lib/src/api/minecraft_skins/assets/default/MissingNo.png b/packages/app-lib/src/api/minecraft_skins/assets/default/MissingNo.png new file mode 100644 index 000000000..54d69181d Binary files /dev/null and b/packages/app-lib/src/api/minecraft_skins/assets/default/MissingNo.png differ diff --git a/packages/app-lib/src/api/minecraft_skins/assets/default/default_skins.rs b/packages/app-lib/src/api/minecraft_skins/assets/default/default_skins.rs new file mode 100644 index 000000000..6d5d7f78c --- /dev/null +++ b/packages/app-lib/src/api/minecraft_skins/assets/default/default_skins.rs @@ -0,0 +1,213 @@ +use std::sync::{Arc, LazyLock}; + +use url::Url; + +use crate::{minecraft_skins::SkinSource, state::MinecraftSkinVariant}; + +use super::super::super::Skin; + +/// A list of default Minecraft skins to make available to the user. +/// +/// These skins were created by Mojang, and found by reverse engineering the +/// behavior of the Minecraft launcher. The textures are publicly available at +/// `https://textures.minecraft.net/texture/`. +pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| { + vec![Skin { + texture_key: Arc::from("46acd06e8483b176e8ea39fc12fe105eb3a2a4970f5100057e9d84d4b60bdfa7"), + name: Some(Arc::from("Alex")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFJklEQVR4Xu2aP2sUURTF/RRir2BAsQyC4GJjaSwEg1iJqRSDgiBooSIWoggiYm0l2KiNiKCNhaWFlYVWVhYWfoAxZ8xZfnvyJtmdkN2dMBcO7++8fefc+2Zn3ps9e7awX48G1e/ng0rpjztHRlJD7a5zXqmQ43XOTMQQOSPrd7UAf18u10jCTRHiuhyvc2ZCTR5mPUVwW47XOaP3GQXON4ljMXK8zll61eRc/vn5RfXt3eOhEFwau0IAk7bXGe6CBDAkhGExcrzOmUgy7Bn+jgDh99fXI3B9jjd3lqFd8jKJu815Lgf3Yx3Hz7LyOZ+pmwmWSCbZJMd6lykCyymAr835TN048SSek1bq0E7i7M/ooLAeg7+X85m6pdecFkVYW9t/vn8arvOSCKxznoQpylzcJD0pkh/x8hpR2f4LB2rygvIyi1DyMoUkYfdT/VwIkF7mHd2EN4OFoggUIAlTpLkQwJMcPuxsRv7Dm/9YL/MvzySV2tMkTO/z93I+Uzd6ww8x+b++QYh18nlDJHGP6ehi3m1zIUDa1VdXKmJxcbHGwsJCjeyf9vDjg+r++3s1OYPEc/xEjpem8Q06I/u1tpyQiVuI7J9mAQR5ebi01kXwuOePHRxBWwFEXlGb/VpbTrBtBNy+e27kncAilMhThBwvLQXYkQjgpNpEgMjz5YjLIcffrgDbjgB5ywPefHujunjicHXt1GJ16+zxOm84MhzeBidE7/MmqQmqjktrK2gupfETTX2SZ6OJnH7M5C+fPFKTF5SnCIK95bJ+zNd7Qk+fXRr5m1S+JICuKdWRVArtPOvTIUqTZ6PJ2yIq2PMmr1R1FCD7UQBPXqlFSPIkndeZ5NMvT4ap8yyX6vLa5NloJkKiJG8xSiJZAJKxByxCkncbyyScqa5vg+TZaAxzhr8FYIQICn+WSSYnrzTJUoCSF+llgQ9khO8vCbcnz0ZbXl6uDIa4iauOfYSVlZUayjOMk0AdBWsp12aKUurPcfIJlH99FIL3GyF5NprJmJAJlgiXQELpzSREAQy2u8z6JJ4ilMhPJECJ4Orq6oY6izIYDKqlpaUaypMQiSgvUZKQ692HQrluqgIkoQzxbFfqyFB+39G9Qxw6s7Dhbm/CJqY+vIZiZKq2JJ0CcCm0EoChTpIlwu7DsgmYsAgmSYtjgdg/2wnVJ+kSeUbCxAKYLEl5CSTxFEiQp3xnF/KBJFP3cfgT9jzbk7jJ580vRUiejabne5EysVzzJi34fYBlT5Tk9M9x/fTREaiO4vCaFIJI8ikC01YR0FtvvfW20+bdIG6Jc5uMcFuO0XnL8wLXc7doVxKX2fu7luBW5tDP+nFMewqJfPdPqE+OM1NrS7633nrrrbfeeuuWeU9QaHO4qmcGb4Z0coODAgiTHq/7XUEC6FB14i2uWVseeU8aAX5n6GwEpACTRoAE8BLoRATw5NjwuSLPHQ2JUtoI1QapX5ezLTFXb5VJ0ALwcDUPWRkh3CmmALl7zH5Kcx4zMxL3wWqeMpfg43gR4nkiy1nHNOcxM+NROb8dYPjnNwXsk+/y48K/n/VG7iHkXgI5bMtSgCRrTztCEnm+n6c9JahPzmNmxuMzLoNxvi/QqZMJlU53mqA+OY+ZWekoLc8V85id/enVccgbOY+ZWRJUeZLvCzovQBLiAWuJsFJHhvK55pNoE3IeMzOGOkmWCJeQAowjwlzdA0yWpDb7viBB4rwZJmmSn6t/gc2+LxAcCULp+wKu/63I70QE/AMDdqWZ7rX6YgAAAABJRU5ErkJggg==" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("1abc803022d8300ab7578b189294cce39622d9a404cdc00d3feacfdf45be6981"), + name: Some(Arc::from("Alex")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFQElEQVR4Xu2av2scRxzF9VeY9DZYEKPyEBh8pHFpuQhEhFTBqhIiEjAY4iI2JkVIMBgRUrsypHHShGCIGxcuXbhyYVepVKTwH7DR2+hzvHs7e9KelNWt2AeP+bGzc/PefGfvdubW1o7A3z9Nq/1fppXSt/c25lKo69SRVypmf4MDQqDEwaw/1wa8f7JdMwW3RQh12d/ggKC2GfZ6N4Fr2d/g4LPvUUC+zRzMyP4Gh5xVxFF+9+Jx9fqPhzMjfGmcCwMQzax7uIsyAMoIiBnZ3+AgkR72Hv5EgLj/6ukcqc/+Vg4Z2qVZduFcI+/LgXZe5/1nWfkcT+9AYElkik1xXk/ZTfByGsC9OZ7e4QNP4TlopYR2Cvf2Hh1uLH345+V4ekfOGmnRhIO1/c+b57N1XjLB68i7YDdlJR6SDMrFz83ygVDh4ueXavGi8gImlGbZjXTBtFP9ShiQs+xPdAQvIka5CW5ACnaTVsIABjn7sbNI/LPf/uNh2b/yEKmUmXbBPvv+eTme3uGzwY+Y/F5vGHEoPh+ILpw+iS7Pc20lDEh8/etXlXMymdRcX1+vme0TP/71Q/X9nw9qcdCFZ//J7C+h/p05Kdm+M3JACMeIbJ/AAFGzPFtahybQ72dXL8/xpAZIvKI323dGDnDZCPju/qdz7wSYUBLvJmR/iTYDTjUCfFDLRIDE+8uRL4fs/7QMWDoCNFt09u3vd6pbH12pvrkxqe5+cq3OQyKD8IY5IGbfH5IamOp8aR1FjaXUf4mL2qXeBiROH4b4L69v1OJF5d0EkdmirA/hfgaz9/MXc1+TypcM0D2lOhdUMpqyXytNjNLU24BmW0JFZh7xSlXnBmQ7N4DBK8WEFO+i8z4E7r18NEvJe7mt3k2inHobQIgLdfGYUTIJA1wMzmNCiueal11wprr/JEy9DXiYe/hjgEeIqPD3sovJwStNsW5AaQZzlv0HWZJnTNLbpN4Gtre3K+ghjnDVeRtxZ2enpvIeximgjoKD1NdkmlJq7/3411wyjfBnDky9DSAGQQgsCS7RBeVspiA3APp1yl6fopMYUBJ/LANKAnd3dxt1mDKdTqutra2ayrsgF6K8TElB1NPGjaLupAZ4PvU2kIIyxPO6UiJD+Q82L8z44cfrjac9ghGmNn6Pm5GprqXgpK93GZHXU28DHuousiSYNl5GAIIlMEViDgZ5+7zuVH0KahPvkdDJAMS6KJZACk+DRM0UT3Yxf4hkShvC38nM+/UU7eLz4VcyIfU2oN/3EoWwXPOIFnkf8DIDdXH65rh9c3OOqnNz/J40wpnCSyZ42jkCRowYMeL/BrtBviXu22ROrmUfg0eeF1Dvu0XnUrjA7J9bgUeB0M/640B7CiXme3+SdtnfmWBZ8SNGjBgxYsSIYYE9QXGZw1X9ZvANkcFtcLgBYtfjdd4VMEAHq522uM4aeeTdNQJ4ZxhsBKQBXSNABvgSWPkI8JNjyLminztCmVLaCNUGKa/Lea2NK/F2mQIxwA9X85DVI8R3itOA3EEubbPneHqHC+dgNU+ZS+Q4XkL8PBFS5/VeJs3x9A4/Kvf/Dnj4538KvE2+x58mcw+htJeQejojDUixzDQRksyz/TzpWUS1zfH0Dj8+82VwnP8X6NQJIW0nO4uotjme3lE6SstzxTxm9/Y+m13EwxxP70iBKnf5f8EyBnik5Hh6RwryA9aSYKVEhvK55lPsUczx9A4PdRdZElxiGtDFhJV4BiDWRS36f0HShfvDMMUmaZ/j6R2L/l8gEgli6f8FCPE0xbbxNCLgXy/vfcHbfYbuAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("6ac6ca262d67bcfb3dbc924ba8215a18195497c780058a5749de674217721892"), + name: Some(Arc::from("Ari")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEuklEQVR4Xu2aMYsUQRSEhYsMTARBRBRUxMsEUUMTDw1ExESDCzQRo9NQ5EBNNDMxMjAy0L/gbzH2B2giorBSA3XUfnbP7eytM7N3W1BMT/fb2VfVr2dmZ/bQoV3w5urxybtrpyYfNs40W+2rLapd4tend3bI4y0dLDwNsCkUTvH7wgCL94znfppA4fvGAM54F/H7woDdKoCCSR5v6ZCiswrcpmCSx1s62ACbkMshK+Dbi4dT3FcG5JWAy6AkPk3g8UYHn8gs7OXFo1OzrH0bIEpYjjM2qyP7tDXdry3z6R2ZbG5phoR/f7fVUG2Oe5/Hyr5RGuAEc5Y84x6T6F+fXk9RfRmjz1iUDUiDWA2OYz69w8lIgMV5rTthijdTTH6+JDQrIw1hPr3DiViAy9slr3Hh3NrajkC1BY3lktDWBpRmPs3wPvPpHZmMSz3b319tNeTsZ7+NcluC0wRXQ5rgfebTO7I0nZhPdlz7vrxNGRGxrgYdIy+HFG1DRlEBKd6Jib6+0wSK9/V+FtqINIT5DI7DG3cnyUuXrzQ8f2G9IeMJC9P2x9uNhr8/39vhkdubreTxiJ/vbzXH+fp8faoCGTc3aICF2wjGE7mUvFWV2AyJ5HeYsxhgI2WAhC/8lptJda0Anui8lBZpwJ8vD8ddAUrS5wefR/7HElhIBTABkoZkn9pKpEQaoFnzzCUlRqJEV4naHveYDfS+xhxv+ngidVZx7P6jSRspmPtOKBMTJdblafEWkEKdcI7lOi/F5Ji/j8ejzioo+MSDJ1NUnwXbkCQFpQFJinR8TSDbpX3PehrgGOqsomTA6cfPpgyw2KwK71NQlnaJjKcoGsBxHocGeJw6q6ABWQUlwSQF1QzIONKJp+C2+GQakKTOKiioxHkMsBgK4T7HSB+vK6mzCgoqsc0AihCVQE0QY9VXqhbH+kRaY15pktRZhYVxy74a0/WSaAqzQY6nCWmSYiSGvz/4Q4y/WToZkNd2ijN5/U/qju/jjbP/bMXt7e2G3s+YUjypMRpAM5JzGeA7PHPjxs3J5uZmsxU5TuaM5qyKNonrsxRLerw08yXxNIE6q8jb3DRhFvH6LA1w4rl8ZALjKLjGkgFq59rntrMBeyHFixaf54+2SmhjzjorgMLnqoBFQ+uWff8DfX1PZ/SVWF/f0xl9JTaKB6olHDgDfE1PLiK53Z4a8zvFUTxk9SMx9s+DNkFtY4NiNC88hoLfLbD/wOD6ySMTkf0HBouuAP5U9i9G/nLMPm15nF6xSANWWGGFFVZYJvCZoR+c+CkS44l8QDqahxxdQAPy8ZnIeCKv937z2/kx15CgAV0rQML9LvFAVkAugaWoAArmO4USM5ZPgUsvVbg/+K1ugoL46oxkvIT5CbAMSNEpmIYwj8EgURakdun/BTQgKdElAyg6+0ZngMXYAP6/gMx4GlASTTNGbUBWAYWXSANmJfMYDCUDupBvgmalv1/VYFNcJbUqyrHUsCfs1QC+55uVzGMwpAE0wu3ckjaANz219uhuiiTCl7TafUCOk5zZNtHZxzwGA1+Zd/1/AQ3YzQT3M4/BQEE2oSSevwtsQL7mznf8JQMcxzwGA/8v0JU0oCSaXGQF/AXuGcKOL5bNbAAAAABJRU5ErkJggg==" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("4c05ab9e07b3505dc3ec11370c3bdce5570ad2fb2b562e9b9dd9cf271f81aa44"), + name: Some(Arc::from("Ari")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEyElEQVR4Xu2aP4sUQRTEhYsMTARBRBRURDNB1NDEQwMRMdHgAk3ESA1FDtREM5OLDIwM9Cv4WYz9AJqIKKzUYB21v+mZ3bnb65u724Kip7vfzr6qefNnp/fQoRl4d+34ZOP6qcnH1TNNq762RW2X+O353U1yf3sOFp4G2BQKp/h9YYDF+4hnP02g8H1jAI/4EPH7woBZFUDBJPe355Ciswq8TcEk97fnYANsQp4OWQHfXz2a4r4yIO8EPA1K4tME7m908IXMwl5fOjp1lNW3AaKE5TxjszpyTK3pcbXMpzoy2WxphoT/2HjaUNucd5/7yrFRGuAE8yj5iHtOon9/fjtFjWWMPmNRNiANYjU4jvlUh5ORAIvzue6EKd5MMfn5ktCsjDSE+VSHE7EAl7dLXvPCuZWVTYHaFjSXp4RaG1A68mmG+8ynOjIZl3pu/3jztCGPfo7bKG9LcJrgakgT3Gc+1ZGl6cR8seO579vblBER62rQPvJ2SNE2ZBQVkOKdmOj7O02geN/v56GNSEOYz67j8Oq9SfLylasNz1+42JDxhIWp/fl+teGfL/c3eeTOWi+5P+LXh9ub+/r28mKrEhk/GDTAwm0E44k8ldyqSmyGRPI7zHkMSDNtgIQv7NGbSQ2tAF7ofCot0oC/Xx+NuwKUnK8Pvo7s5CmwrQpgAiQNyTFtK4kSaYCOmsg4CZEo0VWibc97LkvfY5r3Z0zv03HU28KxB48nfaRg9p1MJiVKrMvS4p1UCqWwvGjaAMZ4zPP+ztI+qbcFCj7x8NkUNWbBNiRJQWlAkiIdz4SzX5rjmI96GpAx1NtCyYDTT15MGWCxWRXuU1CWdomMpyAawHnGlgzIeeptgQZkFZQEkxTUZUDGkU46BffFk2kASb0tUFCJWzHAYiiEfc6R3t9WSb0tUFCJfQZQhKgv7hLEWI2VqsWxvpD2Me82JPW2YGFsOdbFdLskmsJskONpQpqkGIko/QbhAxB/t3ibelvIezvFmbz/J/XE9+nm2VYrrq+vN3Q/Y0rxpOZKBtCM5GAD/IRnrt68NVlbW2takfNkHtE8qqJN4nlZiiU9T0F94mnCXAbkY26aMI94fZYGOPE8fWQC4yi4i10GqJ/nPttBBmyHFC9afF4/+iqhjzzqJROypVHUu+PQecuxnULN75obNZOq+V1zo2ZSo3ipShw4A3xPTy4isXneGvN7xV1/0epXYhzfCmaJmTW/KxjNgsduwWsLHD8wuHHyyETk+IHBoiuAP5XzVyN/PeaY47i/KlikAUssscQSS+wl8J2hX5z4LRLjCb4g5UsOxo8ONCBfn4mMJ3i/96qvTWD86EADhlaAROda4oGrAJ4Co68ACuaaQokZy7fAfYsqpXHmUx0UxKUzkvHNUf7/BlgGUDBNYJ/5VIdEWZC2S/8voAFJiS4Z0GUC55hPdaQoG8D/F5AZTwO6BJcMGaUBWQUUXiINGErmUx0lA4aQK0FDqSqwGa6OvirK6lFLPYOxXQO4xjeUzKc60gAa4e1sSRtgQbnux3XA7I/mwUgifEvreg7IeZJHdJZgjjOf6uCS+dD/F9AACuzra5v5VAcF2YSSeP4usAG5xM31fRqQ44plPtXB/wsMJQ3oElziIirgH4/zgLkTkjiIAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("fece7017b1bb13926d1158864b283b8b930271f80a90482f174cca6a17e88236"), + name: Some(Arc::from("Efe")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFK0lEQVR4Xu2awYscRRjFcw9JhLiHoMIGzCVLIIRlEQJGDDkIghA9RBBBBdkl4EEFPYm5CCrkorlo9CBBCJGQu168GTx49OYf4D+QY8tr+A1vXqom3e3uzHTSDx71dVV1zfe++qpmumsOHXoErl35svnq3W9bfvHW9Zmtel1/9875hczxRgcX7UGgROgHl87M8bEJgIv3bIAl8R6EHG90SMEeBNmPfQBy3WN/+tpnT0YAEC3BiGYDVL3KFA7VluONDoi+cfXH2exLGPUqS1mgOrXleKMDYnMvoG7RV+EoMsBFlda713kWeFBK91EPsx47/Vk6So5LnK9xtX2zd3Ouz/sX9mZ2isrgsFRK/dOfpUNO5Gy7GNpv7b7U3Pnw1ZlAXXufFOdLhHtKmZL+LB04p/L3z99o6Y7KRnwSEZTc73WeNbIZGzv9WTp8diRKO3g6Luxtbc+EyxZo1/3aH3Sv2kl7SgJJHVyLbwkcUamdWwIkRs6qFHPmIe26/+ZHt9s6dn/VaZ9gbPYBD8RaBMCdUfnT7sutGJaFBP68+0or7pOLmy1lq079yACV//7z+kygxGvpqFSdbPUjELSnP0sHsylnRM2gxOC47Jx5yBJwkTD7+md4n/Rn5Xjh5LkGvnhqp3n7xLnm8sZWS9nZP3Hvrz8b8fb9P5rvf/u1pWzqNe7557dn44tel+MlfGwCu6/PHC5elPD3nttpKTv7J3BQG+KbO5stZRMExpdgJ/U5XkJjEFTE6zOy32C4+KEZ8PXduw8FQHX7kQHMvsY6kAxw8SKzD7N/Qg5KLEFAvKi2HD+Z4yV8Se1LBijqZ585/ZAji0i6iqztXPuHjx1vnnr6REvZuRfkPQTI7ezDGJB6+pAZKlNnFRK0tXFqTlgKLvFRAdCYzgxACsB5t70tA+D9s5+YOqvIGVU29AlEBgBHyAJmPx3OAGSdszTzGYy8J3VWIaGaIQQTBAKRgkVvT/H6cKWwnGIJyGYTrIktiXAuEsw14+k6dVZRm+1SnYuHpQC4Y7X1nP1LAr1vtpcCOTgAtT2gFITskwFwJ3zWkxmEvJdrvkH6MnVW4Wsekrq1AHg2yNkkM0MAfBYR56UHANHefwhTZxUuFvvvH7abBw/uzQUCuyuPHNtoH440juxs934EgaARELXle8auTJ1VpEOiHM+6Gj07fOP03xbYpYzyDdipOrWlsK5MnVWkoL7M3/SiBMt5Nkrs7Kf6zaPPzoLAbwZsteUTpf/8TXp76qwiBfUls6fSvx6pW9Qu5nOBZ4DaUjji/fyB0oOQOqtQumvNp7Ck+qgv61qU7c674C7XIpmR/cikFJ9BKAWg1xKYMGHChAkTJkyYsKbg+Z7H3Np1MscZLfw9QR/mOBPGinz4cpsHOfGXj8/OPbDlOBMmTFgO/MWHOOR0mZekg97wrBr59mfI/wsIgE59taGN6g2Pv+4amgG8Ih91BsC+/y8gACqVAbzry35rA1/vKd5ZOoFSvQt2Upellsda/dJbJNyF1s4fPeVLglM4+0P6sTKkaBeZs579agEQEUydH5n1Ovs7aKQghOcBCSc+3l4KADOcJU+A1KUfK0MGAMGlOhcPfZY91WszT7DSj5UhhZZEy85DUNryOb4r+Xyu/f0ApdPbS+MMhq9x6OeGTk99goBjfcnnky1D6VoGAbES5ueIXf9fwPd8X6YfK0MKgl3/Y+Ci8gB0EdOPlSEF9WUGoEsgRpEBXSkxfsyNnaJdvPqkHyvD//1/QQZgkfiDyID/AMdpmCl88QvjAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("daf3d88ccb38f11f74814e92053d92f7728ddb1a7955652a60e30cb27ae6659f"), + name: Some(Arc::from("Efe")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFPElEQVR4Xu2awatVVRTGnUcZ1BtIBk/ISY9A5PEQBA2jQSAE5sAgggriPYIGGuRIbBKo0MScqDUQEUQR5zVxZjRo2Mw/wH/A4YnvwO/yve/sc9895+npXD0LPvY6a6+z7/rWXnvfe86+e/bsID+duVRd/vrXGj9/8ctMl13X1786Ohc53tKJk/Yk0EL0+48/2IaXJgFO3qsBlMh7EnK8pZMk7EmQ/tInINc9+vlPL7waCYC0CEOaDVB2tUkcqC/HWzqB9LXvfp/NvohhV1uqAtnUl+MtnUA29wJs874Kl6ICnFRpvbvNq8CTUroPO0g7esYzuJQCFzlf4+q7unVzm8+3x7dmepLK5LBUSv4Zz+CiIHK2nQz9tzc/rO6dPTkjqGv3SXK+RLinVCkZz+BCcGofXTxdwwOVDvkEJGi5321eNdIZGz3jGVx8dkRKO3gGLtlaW58Rly6hX/drf9C96qfsaUkkNjCKbwkCUaudWwRERsGqFXLmAf26/+a5u7WN3V827ROMzT7giRhFAjwYtbc2T9RkWBYieGfzk5rcjx+t1pAum/yoALVPn3w2IyjyWjpqZZMuPxJBf8YzuDCbCkbQDIoMgUvPmQcsAScJ0tc/w30ynv9djhw4XIFjBzeqL/cdrk6trNWQnv4pD//5uxLu/vW4uvHnHzWkY9e4R99bn40vuC3HS8mxSS4/vtK/szh5QcS/eXejhvT0TyFAbYifb6zWkE4SGF+EHdhzvBSNIeK0kOez0r+zOPm+FXDlwYNGAmR7HhXA7JPo514BTl5g9kH6pygokSUJkBfUl+MncryUXFK7rgBl/dA77zcCmQfKVSCQXPuv7X2revPtfTWkZ+B5DwlyPX18T6Hf/bD7ZyXfhojQ2srBbcSScAk7JUBjOjIBSYCydt37nBh621juk3wbkjOqauiSiEwAAVMFzH4G3Ba0607Erz0p2d85ASKqGYIwSSARSVjw/iSvD1YJ68NZAtLZBNvIJslEG2G/LrXJtyFts12yOXlQSoAH1rae079E0H2zv5RIWu9Pvg2hAiDtxEtJSJ9MgAfhs57IJOS9XPMN0hfJtyG+5gGl25YArwYFm2BmFIATpB+d1hMAafffDZJvQ5ws+r+/rVfPnj3clgj0RfH63pX64UjjSM9+91OgnjQSor58z9gVybchGZCgwNPWBq8O3zj9twV6qaJ8A3bIpr4k1BXJtyFJqCvyN70gwgqejRI9/WRffWP/LAn8ZkBXXz5ROpIscJ/k25Ak1BXMnlr/esQ2r1/I5wKvAPUlaSfvZxDZLpwAlbvWfBJLyEe+rGtBugfvhBe5FqiM9KOSkngpCd52roBJJplkkkkmmWSSSUYqelLUEyKPtzzn52NvvgdY+HF47OLvCfogx5tk2SQfvrhG10Pa/R8O1ZDuD21CjjfJJJO8WPEXH0Kf02VekkrPlxzpPzrJtz99/l/gCdCJrzYzkpD+oxN/3dW3AnhFvtQVALr+v4AE8EOGc//RVoCv9yTvKJ1AyZ6EAbZsgZaJ2oxncJlH3Im2nT9mybe1EPZTpFEkIEk7yZz19GtLgABhbByZeQKEjGdwSUIQzwMSTny8v5QACGabCaDNeAaXTACESzYnD3yWvdRz5vN6NHtAEi2Rlp6HoPTlc3xXKBGul67dln3Jp7P4Ggd+bujw0icJHmQfUC19kXw6C2RFzM8RF/1/AT90+iLjGVySEFj0PwZJKA9Ad0LGM7gkoa7IBHRJwqgrYFGIhB9x5/l+G/DPeAaX3f6/IBOwCHlPQsbTVf4Dv55a+XG6bwMAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("226c617fde5b1ba569aa08bd2cb6fd84c93337532a872b3eb7bf66bdd5b395f8"), + name: Some(Arc::from("Kai")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAGa0lEQVR4XuWav4sdVRzF02gSDPiLYLOiuCgusotdGqtsigVFCxvLVG7hjyKFtloZBMU0NtZpUqQQKxv/g00hNjaClSCxCKJFhHHPJJ/HeSf3zntvdmbeLvuFw73zvd+58z3nfu+dt+/tmTML7P7vXzb3795o8e8vnzzoH/ra/mF7a//1FgfX354D/pzvxBnk54ibGBCtIec7cdZWwMMqYNURwyughpzvxFmx9BHgtFTAXNmfxgpw0jMRTk0FmABZ/giQbwB/E+R8J84g6qQdSTqR8x07S0I1olX/EjGzyin4Mp/Jbe5Qq5EI3x8/ftSiGpdtRz/zmdz++fmrJvHf37daPELw7oOS/+3b91oQQ3zOA7j3w4s7LbjWWOYzuSmJe3eut/jr5vstIARBJ+3wOIH7mc9FEHHi1Mef+UxuJCuI1A/X3phbVUG2u//ujLj6shRK92rc50QIYpgb4TOfyY0klTwrrT1Kwmpz5QHjLbHDe+TTHJorRfBtJODPfCY3Jav3tVpEEPyggvCnuy+2cAEgz33M43O2AsQhiOiZz+SWn9wc/voqrb6gmLwvUTpD5NNY5rN2e/r8883Zx56c4Z3t7TlkfNq5w3sW4fzjzzQXzj5X9Od8adyr1sXMuN4mAZ44d7Elr3Zvc7O5vLHRQv2MTxMRtgIVwNYaQ4DBKwniCDG0AEpcSOJqRSznS+N+xY5SAZT+/vZus7ex1WsL5IHIda44VeBtzpeWAhy5AnLFda0+AuSZ4NtDY76KXtaQB0nc47PNsVrF+Hw+toyQM4MIAtBHAIi7EIoDSchL3FfLE894tU4i7/XW58l7/Z7kWTVffe9LACfsAtB3ATyBTMbHuXaxSkTp+30Z6/NlfPKsmpe3E8uWyiAOXybFCpIMrRPBz71+XSOEn1h/nufAs5Jn1Zxoqcxza+An3gmUWhclxaqRSHGAE3RBfH76ybNqTirJpQAplvpOIIk70YxxsimAk+cN0oV82wjJs2o1YlkN2Wc8CbkgkKut1CJxJhEgJ1sVTmxVQLoL+bwaeguQf6SsCr0qBb01HDevfdZ8cfnqI/5aPP4cT6LLInlW7d7Br43w9aVLnSAu4WeHtgUrB3n3ZYyucwwfc6bgQCRpgY8nz6r9+dNBS1CtE/t8Z2fW9xiPU99J0conAfgkCSkOPeD3EkM8Y0kc0r7vs/xXEiA/66+KJM41ZY3fD0Jds//90PVr+kneV70kwMoVcFR79dnXGmHzqVfa9q3tvebjKx80N65+00J9QX4h71/VXrjw0uxZObYWcwGUXBfJrrFlDQHU5thaTIkgwhQCiPyxE4CSVP/lh9sg4+TTWPpXNZ6lNsfWYiINlBQC+B7lbPDkvXLyHKHvQGBaj/V7aT3HUY0EXAAdep6E+vKVBHBfCuCxpdbv45p5PcdRzQVgC9QEcNJJgLeF3hzff3d7ToyssiSc4gie46jmKwZBkUgB5EsBnBCiEesCJEEnz5zp9xxHNR7qq6IVTAHk8ySTDOeGwHbxOZ0ccTkffvU9x1GtlNgiAbzlPj4osRUY9w9TtQ9Xea9iPMdRjVWCiPpdW8CJuwAlaKwPeYntOY5qXqYQZD8Toz4+F8CrwYUs9d2X/vT5s0c3SJEI5exJaIzPARkLSkQEf9axNErPyzIrQERyPMs4S1iYdC/3tSTDdUmAklhd5OXzZx1L85Jm/4GMyS2Q+91xYraAvrzIb4y9n1+g5DfLOR9fiQt9vuBAfImYY6OYf31F30nmz+sev0gA/++SjKuZH6g5NopBKIEgJQH8d4Wcb4gKmFyA0urjyy0AcWJzviEqgDMkx0YxX036LoD7Ie5i6ctP/1WI6/zFCPCNMs93wny28AOZ1g9Wrv2g7m2+mhCHdM3v/RSA1YdoioGP5/tbIwVwIbI/WJX4Smeb5e7VgT9X3oUotcTxfCfjwO9ipECDCeAr6wS5BrrOCnGCKQBkvSrYCjzfV9ZXPQXIMVrn0su8AlyAJO4t5HXte5s+5P3ax10AyJcE8DMgS38wAUpEs+y95F0c9fNnrmXB8/3P5NJHa0f6B/mz2ckmcW9dAPrCEAL0JT+IAJlYX/QVoLbfHV1jzqWX5Q+XqyKJLYvMY2121P8vSGJCilRC5rE2K/1/gf63IP+PoPb/BSLsP3PTT8JZNZnH2iw/6yfe3NpqUfOnAJDvEmFIAf4Huv/ihcao4QEAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("e5cdc3243b2153ab28a159861be643a4fc1e3c17d291cdd3e57a7f370ad676f3"), + name: Some(Arc::from("Kai")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,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" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("7cb3ba52ddd5cc82c0b050c3f920f87da36add80165846f479079663805433db"), + name: Some(Arc::from("Makena")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,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" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("dc0fcfaf2aa040a83dc0de4e56058d1bbb2ea40157501f3e7d15dc245e493095"), + name: Some(Arc::from("Makena")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,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" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("6c160fbd16adbc4bff2409e70180d911002aebcfa811eb6ec3d1040761aea6dd"), + name: Some(Arc::from("Noor")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEsklEQVR4Xu2aTWoUQRzFszGEgJqETFDUEDWJCiIGVCQuXCgo2bkRv3YKHkAXuvUGbrxC8Ag5hGcaeQNv+Oc3Vd1d7aR7xuTBo6qrqmvqvfro6q5ZWKjBvUsXho83V4cKn+8MRnGFTndeLmR9c4eUqBim0v5bA2LPx1D8sb99jE5nfXOHlAE04r83IAqXaBpxKgyQ8LcPto6FNuDni70JA5R2Kgw4/PBsJDaa4Gvlsb65Aw3glJDIKrK+mYOEpFb1VNwGsHyqLMO4fsTpw/Z0DjYyXt9cWx7F1ZNHnw8mQuWpTFUdDJnP9nQO9lCMex5L7J/v70dhpOd/7FHGaQBDtqdzRMESKepawiz09+v9EXPXKqt7fD9NcOgRNZMGuKctxtcSKry5e20sWHHBJqhsNM3XFM5wZgxQY+Jw13Uc6nXUfb4nTgsKToVsT+dwY+Kzm7TQL09ujRiFk67HAmVwDJnO9nQON4o7OW5oUr3ufN4nUmguZHt6x+Dl3nDz3dMRFb8/WBreXl0chyxPcN7HuPJU787XV0kqj/URv26sDM3YGSzXGhIdGcUrZHkiGqB4HPo2IGWC01kfQQMkXiOO5VqDBrQZAaanQ0yz0BxZH0EDZnIEcEH09TQMOLwzmO4IYAOaMA7bT2vnJph6miiN5ZpQgslvG4uVVBnqzILiKLCO8YfdaMUXl5bHI2Bl/fJEPu/JkeJP3IDc4hTp6VFlgESLgyvX58cAik+ZwDUiLkputOJRvEYD880qEywm0vfkqPwiAy4+3J4QlROrsqQFRCHRAJEGpO6hEb6OZtG4FJ1PnVlQUCljo6IBEu1pIDI/xZRoCmxK6syCgkpJA9wLNsDTICfQPNq7Oi7j+NwZEBlHgKdAjhIsquHxuhMD+LwuJcW40XFR9dOC5ZoYwN9rSurMIt7EN76q116Tzosa5h4hEq/Q0yPFaByv2Za6NjmfOrM42F0fkh8fbY3I9BS5R2DP5x6nTUnhFh+32QyLDND+nrQ4pqfIBosUzGuS+45YnuJpQgxbjYB5g1+3FTLvVEDCZ+ZQpQ/YAI0C5p0aSLxMYHoviF973DssY3joRtZ9PGUdgn9nd+P8Mbo+lu8MTQ2IaV7QnMcTpVjWcL5EO03xqns6QRsDKNrDO1XWyBmQK98Z2hggKC1FlovodajnUNfwuvwSTKueBe6+ShnrqhNYl98LKKiUsa4m4pqUifCboanPXQ5TdDnWkwX35aVkfdMGX52bkvWc4QxnOEMn4LlC6eFqXMjafODQU6bXLTEPU6L4Jsfr0QCd+pZ+4pLw3I6zE9CArkeA3yVmxoC2I0CbmjYjQOI7fU/gxqgJvYtUnJ/ITe7wTOf79/lNoK73p74+UBwF1pHC2xgQ43UCvUgyvTUoiO8KzBc9PfowQKjLL0KV+JQJXCMovM4ArQ/RAGKqvdsEOvaiqJxYHqyKFC7yxYasMqC4d9lbpaSgUlJ8U1JHa1BQKSmolBTWlG4/p4jIbwikyowN4AJVSgoqJYU1pdvPdE6XHMcG/Ct4PN0V2Y7eEBvFE9x4kltC3k/OlAH8v0Dp/wskJh5zxzP+FF2O7egN/L+AWPL/AhpQJf4kRsBfkTxADPB27yIAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("90e75cd429ba6331cd210b9bd19399527ee3bab467b5a9f61cb8a27b177f6789"), + name: Some(Arc::from("Noor")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEu0lEQVR4Xu2awaoURxiF3XgRwUTFkYhG1KhRCCEXjASzyEIhwZ0bMepOIQ+QLHTrG7jJK0gewYfIM42chjOc+3XVdNc4t2fGew8cqrrq75o6p6uqq7vnxIkB/PjNV/Nfrp6bK310a9bllbrcdbWU7e0cSqIyLZV9sQbklc9UfPfg5gG6nO3tHEoG0Igv3oAULtE04kgYIOF/3rt2ILUB73/f7xmgsiNhwIeXDzuxaYKPVcf2dg40gFNCIpeR7W0dJKS0qpfyNoDxpVimuX7k9GF/Jgc7mcffnT/d5XUlP/71uJeqTjHL2mDKevZncvAKZd7zWGL/f/uiS5Oe/3lFmacBTNmfyZGCJVLUsYRZ6H9PH3SsHStW5/h8muDUI2orDfCVthgfS6jw7IdvF4KVF2yCYtM0H1M4060xQJ3J4a7jHOpD1Hk+J6cFBZdS9mdyuDN57yYt9O9fv++Ywkm3Y4EyOFOWsz+Tw53iTo4bmtJVdz3PEym0lrI/G8fsj/351ee/dVT+p9mp+Z1ze4uU8QTnfeZVp3Zv/fOkSNWxPeLD3dn83xtnF+RoZHwzJDqZ4pUynkgDlM+hbwNKJric7RElAyTcI4/xzaABq4wA053KMgutke0RJQO2bgRwQfTxugxIEz57BLADY5jD9vX5kz2W7iYqY9wYWjD55uLeUjqOenugOAocYv6oO6383qnTixFw9sKlXj3PqZHCD92A2uKU9PRYZoBEi7PL13fHAIovmcA1Ihckd1r5FK/RwHpzmQkpJOnzanTbowz4+uebPVE1sYolLSCFpAEiDSidQyN8nGaRFE4DROrtgYJamT+WBki0p4HI+hJLoimuldTbAwW1kgboR9MAT4OaQPPj/pVFjPM7Z0AyR4CnQI0SLKrDeTyJAbxft5Ji3OlcVH23YNwYA/h7raTeHjKYT3zcZpZIx0UNc48QiVfq6VFiGsdj9mVMvzKGent4fPvCnHx1/1pHlpfIPQKvfO12OpYUneJzq810tAHa35MWx/IS2WGRgnlMct+R8RReMiHT5hGwi/Ajt1LWHQlI+NZ8WNkEbIBGAeuODCReJrB8cuTbHl8Zxhgetsmhl6dsw/Bv3b545gDdJuMnwVgDssyLmev4RSljE46RaJcpP3TeoWIVAyjaQ7sUm6gZsOycQ8cqBggqK5FxxMaGeg1DHR+qb8Va2uLuq5XZ1pDAofqNgIJamW2NETcmJuEnw6RedTktMWPYXg/cl7eS7a0bfHRuJds7xjGOcYxDBb8rtH5czQWsW9XxkoPxhO4yG90O82NKih/zeZ0G+KuvTWA8IeG1HeckoAFTjwA/S2yNAauOAG9qWkeAxE/6jMCN0Rh6F6k8X5Gb3N2RjuP7gLFXf23rBMVR4BApfBUD3JeW9wFeLFneDAriswLrRU+PTRogjI1bimXiSyZwjaBwM9cB0uuE4tiftVzVFuizF0XVxPLDqkjhFj+GJQOaryqvVispqJUU30rqaQYFtZKCWklBreT0EPN9QIkZ01vEWklBraSgzyWnyRA5IJrhndqmyP5MjuwMv97mV9wW8vwat8IA/l+g9f8FEpGfuPl9v0bHsz+Tg/8XEFv+X0ADxohPE9ifVnwCzC7McvDpKlsAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("d5c4ee5ce20aed9e33e866c66caa37178606234b3721084bf01d13320fb2eb3f"), + name: Some(Arc::from("Steve")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEwElEQVR4Xu1av2sUURgMqCABQQVBBK0SCdooMQQD5jSFkNgpKdIEwSZoZ6GYJohNUmlhqrSxsUlhYZM/If/TmdncrLOz3+7drcneXtyB4f36bvNm3vd2l32ZmOiD+3eudMHZqWtJyTrbn189LqVfb+xAwZ17N1LxqLsBH17MZniuDFDBXn5b6+TEkxjz640dXLSmP8r/wgDd+74VILJoC5wLA3TFNRsgHqRQv/mh7+v64vkwwO8BSoiE4KLSrzd2UNF+QwQpdnn6aobs9+s1DpraFDZ183Im5ZkFSjVBf6PX0WtEcSh9PrUjmiBIIVpX8V7yd5Foxvn10efzqR0qwifMvr03y91fn9a7v7+8S8qf79eSvkfT13OxINvs0zpNYJ/Pp3ZwQjpRXUkIhWAIp3gSY5Go6JqsM55tn0/t0ImxxCMM4iACQoGXT3ZS0agDaCMGffhNdC0aGhnUCAN8hShIyRV/+/RBQs0AJUX5yuv9QQ1B2+dTOzgRlFx5PsO/v15MX2jwaPu4NJdw6fZkJoaPPGaCXtMN8LbPp3bwrU3FuwkoIRomsKRoGqS/5fX0zfDurckM2e/zGTkWHu50lfPz8wlnZmYSenwOR0fp9lBj0Ycxv77TL5fDwUE34f5+Znt6WGX4hCicRnh8DsciVbyacNoGqNEeVhk+oSoZcNYGTB4epgY0MgPOdAscC9ct8M8Z8Gxurwvij6N8vvAjJftYkjp+cXc3Q0/PVHxv4kXxYKafQpW9uLS9tZWlxg4KChzUAB0HMdlL29sJU0E9oUxP74/iMyK9j/2RARsbJ6QBHB8UZQI9O3wchJALm5sJUdcURel9Hu8muFkZ8WJGMlZkADgoygQWGaB1iiH7CfL4UJi0c2mupHg1gRwULpDtqE/HSBeUWXEzQFc/MiC39/f/3uh4s9N6RI67zkK4Ab7CkSHa9pRODegJcQM83jMkFd9r66OON1Rtq+hKj8XIAN8GZeMURXoK66p6bBTvv3VhKj4ygKXrLASFFQnsZwAmqWKSDNAVZTb0BHl8aIDED2oAykoZ4OmtwpUeR1KQiqNwF8cYj8sYIOIR4yvrBqgRlQxYWVnpgn5zczLOqaIiM1Sgx6YGiGgVD6rgoizQLTC0AXi97XQ6GVGrq6s5oYjxWNR1i6hhmjk0gsI8RrePxiATIwM0K9wAlq6zEHzHpzgXSOFRHOpqAKnbiCVX1OP9XsMY9pcJ1/3vRrjOFi1atGjRosX4AR9Ai5h5fT44+ViajrVo0aJFixYtWtQN/5Q29OGqfELTDyEe1li4AUMfr4sBmZPlcYEbUCUD+JrLDBhrAypnwHFZ6airbujXX//I6WM6zpjcgaefEbDtbAoGEcls8DEwPOJWA9wEtpsCNwClG6Dj/QxID0vcCK83BS7QRfq49iUGlJ3xl5HQTOmZ498NnKdqoItnO+rTMdLP9/XG5wce2u/zGBmKBPtq67jGu1h99Hm7kY/FSGC/LaB0A4pEs5+lz2NkcIEoIwMic8DofM/Fq/DGZYCnv6+09kfbJNrnkXDv93mMDDxJpsAi+nE7WSY+Et44A3hcrqKG+f+CyAAV7Qaw7fMYGaL/G1CBFB7FoQ5BRfeByAiO+zyq4g8lK5z2I+oYkQAAAABJRU5ErkJggg==" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("31f477eb1a7beee631c2ca64d06f8f68fa93a3386d04452ab27f43acdf1b60cb"), + name: Some(Arc::from("Steve")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "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" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("b66bc80f002b10371e2fa23de6f230dd5e2f3affc2e15786f65bc9be4c6eb71a"), + name: Some(Arc::from("Sunny")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFG0lEQVR4Xu2az4odRRjF8wYiuHIhOoIuRAYiMkNEREWYIRLjahJGmIUaYVAkszAQgwHJMBsRwexCiDAbxYVZj27i0ieY5/AFWk/DuZz8Ul33L/dPpg8cqrq+6rp1Tn3dfW/1PXduCNbW1pr19fVmc3Oz5fb2dluqTbGHn29UyfFWDhadwvOYgkmOt3KQYGeAV911kYJJjrdy6DNghAx4dPDOE8LdxvFWDqNkgMSmCXnM8VYOpSeA6Qw4vXNxINpU21NhQD4GZQAvB4stUTGOt3RIcbmyKlN8zYSM51i+T7heOpfzmTs8SU7OE0zhXQaUhHYZwPM4n7mjtMopykzhKc5kJrjNY/FzlsaAnffON+aVDzYGLE2cxmSfPDfHFC+/9XqRinE+c8d3V99uzH8f7LXUDUzHEqa68H/Xwc1NdUF19VFf1X1+jjmMnM/c8cPuhcbMx5eOVVpUF9Xn4NKbjz0Oc0zz9sdvPEa3cz5zR+kLDB9nFnvj/RdbZqbwsVca78G1MpfiMenJkvxSw5VPwSl6XHI+C8e7B382l249aqn63t5es7W11VJ19idu3jvfrH/6xxPZsfvtc41ir175pUqOR2icL398uR0vs5D9JgYNsHiT/QlNTAbwUvCkJfL5i3eLHMUAjSMjVUr4zDOJBoybAZpYyQCv3LQGzD0DJDrJ/kReAnkP8cox5UmOR8w8AzRZfbDKGt2HBmklPvrqmUHpDNAkvTqq58pl/2zPUmOo/snNF5oPv3h2UGa/Wow6O2FxF67/PRArYaKPFUuTMs7JizbGK0VxLN0vS4+9f/RaK9Bl9qvFqLMTNIAr7FhmwNWjf1p2ZYDaLZDi2J9muFxIBqgcZoCYcYkgT05OBnVNhuKypBk+z2NL2CSkzk6UDPAKD7sEbBCZ/dIIiaZZZI6tMShsVFJnJ2yAJ50Csy0vgezXdX5eJmrLrKBZN47uDz7T2eUMyzs9v1zVYtTZCa7euLQBeQ+RcApxvzzH/WWA6qVz8llf+jqeBmSMOjtBQeOyZIAEZAZYlDOEN1XXLdznqKRoGmATJjaAqZMDsd2xrJcMKKWySQN8fp6T/Sm6ZADnPbEB/ICuD0rSgExlZ0GmdM0AmqcY5zJsTmMbwB87/s6fvwHIjPNSyNTmypf6cvXz0lGMwi0+r32WYxmguzN5enraku2lODOAokttvAekaB5TPE3IcqIM6NGjR48ePXosLa4dvdQkD3/bGLAW4zgrC+32JLWn8M29V9qyFuM4Pbrw2Z2HDXn9p79asr0U53iTQPsBbJsbdm/92pAWyvZSnONNgu9//n0m46wszrwBX9++e7YN6PE0IXd3VM+dpVFeruaLk/39/ebw8LAl+y0taAC309ifyFdpOzs7rQnHx8dDz1sa0IAznwHeQDXZn1i5DPCmqDdAu5iboGkQ3/BmBtRinMfCwF3h3AX2Md8DZJzv+DMDajHOY2GgAVxhx7q2wWurXItxHgtDGsCXHF0GOK5jvtYelf782p5ALZYapgIN0DFfjdOAvAQobFT682t7ArVYapgKNsDXN69xMw3Ifn7FldTd39RTQMw29eE8JgZ/6/P3fokZl7BpKHF+rWWh+fwn3Yc6JgZ/6/P3fokZp6BxyVW2eD37Kd5t6kMdCwNfT+cLSrY7lvVMbaZ+yRzHOY+FgeJKpAlJC8oU58qTS5UB/LHD/w+UmPHS6tZMmPk9YFrwvwP8/0CJGZcoZwDF+5rn/WCWGfAfZVbEuZ74z00AAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("a3bd16079f764cd541e072e888fe43885e711f98658323db0f9a6045da91ee7a"), + name: Some(Arc::from("Sunny")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFJElEQVR4Xu2awYscRRjF8x+I4MmD6Ap6EFmIyC4RERVhl0g0p01YYQ9qhEWR7MFADAYky16CCOYWJMJeIjkk59VLPPoX7N/hP9D6Gt7w9jfV09O7Y+9Mth88qrq+6m/qffVV90zVnDvXgqWlpWp5eblaXV2tub6+Xpdqk+3JlysTSX8LB4tO4XlNwST9LRwk2BngWXddpGCS/hYOQwZMkQFPd94bE+42+ls4TJMBEptByGv6WziU3gCmM+DwzsWRaFNtz0QA8jWoAHA5WGyJstHf3CHF5cyqTPGTgpD29OXnhOulezme3uFBcnAeYApvCkBJaFMAeB/H0ztKs5yizBSe4kxmgtvsi58zNwHY+OB8ZV75aGXE0sAZmOyT96ZP8dN33ixSNo6nd/xw9d3K/OfBVk09wHQtYaoL/3UdPdxUF1RXH/VV3fenzzZyPL3j7uaFyszXl65VWlQT1Wfn0ttHXofp07x9+a0jdDvH0ztKX2D4OrPYGx++XDMzha+9kr8H18qci9ekB0vySw1nPgWn6K7keE4d7+/8UV269bSm6ltbW9Xa2lpN1dmfuHn/fLX8+eOx7Nj8/oVKttev/DaR9Ed8/dOrtS+XzEb27wwGwOJN9ic0KAWAS8EDlsgXL94rcpoAKIjy5dLiZ5ZRDEDXDNCgSgHwrJ00AL1ngEQn2Z/IJZDPEM8aU56kP2LmGaDB6oNVTqL7MECahU++eW5UOgM0QM+K6jlr2T/bs5QP1T+7+VL18VfPj0r2pZ1+qHcMFnfh+l8jsRIm+lq2DFLaOSDRgfEscVAsOavOItW3996oxblkX9rph3rHwABwhm3LDLi693fNpgxQuwVywOzPYLg8lQxQ2RYAMe0SQR4cHIzqGggHlSWD4fvsW6JOQuodQykAnuG2JeAAkdkvAyHRDBaZvuWDgrqSesfgAHjQKTDbcglkv6b7c5moLbOCwbqx9+voM51dzrB8wvPLlTnJTr1j4Ox1pQOQzxAJpxD3y3vcXwFQvXRPvuP5dZyvwFIf6h0DBXVlKQASkBlgUc4QPlRdt3Dfo5KCyMyCYwWAKZNO2G5b1ksBKKWyyQD4/rwn+1MQWRrfsQPAm5s+JMkAZCo7CzKlJwWAwZONY5lmXJ0CwB87/s6fvwHItHMpZGpz5kt9Ofu5dGSj6BSfa5/l1AHQ05k8PDysyfaSnRlA0aU2PgNSNK8pvBSELDtnwIABAwYMGDBgbnFt75Uqufv7yhG22elv4aCdnqT2FL67/1pN1dvs9DeA+OLOk4q8/vOfNdlestPfcaD9ALb1hs1bDyvSQtlestPfcfDjL49m4mdhceYD8O3te2c7AAOeJeTujuq5szTN4SoPTnZ3d6vt7e26FNl/7sAAcDuN/QkepW1sbNRUEPb391vvP3UwAGc+A7yBarI/sXAZ4E1Rb4A2MTdBM0A83WUG0M4DVo6nd3BXOHeBfc1zgLTzfJ8ZQHvapjr//7/BAHCGbWvaBucML3QG8JCjKQC265rH2V3Zth/QZqeezmAAdM2jcQYglwAFdWXbfkCbnXo6wwHw+uYaNzMA2c/HW0k/+fMNILJdfTmezuBvff7eLzHtEnYSSpiPtCwy3/tNdF/q6Qz+1ufv/RLTTkFdyRm2eL3zKdq0TX2pp3fwaDoPJ9luW9aZ1pn2TUsg6xxP76C4EhmEpIVkanPGmzgXGcAfO/z/QIlpz9TPGaZYcmbPgJOC/x3g/wdKTLvEOAMoXmvd6730TJhFBvwLF5puRHnAy2sAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("eee522611005acf256dbd152e992c60c0bb7978cb0f3127807700e478ad97664"), + name: Some(Arc::from("Zuri")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEw0lEQVR4Xu2asYsVVxjFNdgasAmCRMiSRMXCBBZsXpRYSdhmsbOxtNFyOzGk0DKFCUIgTVKk0lIw5B+QNGkD+VvWkTNwlrO//e6+nd3ne2/MHDjMnTvfu++ec787M+/ed+rUHHx85nS38cm57sK5s53KPrr83def9bx348o+up7tjQ4pvDLCQtOErGN7owPF65hZkWIrsr3RIUVz9JkBFdne6JDzXUZwGlAwyfZGB4qnCRRMsr3RIef+1U/PH7gXWOgH/RTwqNsAZgDFpwlsb+1gIZzbeaPjNOC1jMljVcfPsj9LR4pj2UeN5sPbX3WP71zvqbLqKKwyJ9tKs/0Z9mfpyM4mLTqFk75uM9IICs1jxrE/S0d29Nn9rV7YUcRXJuhcbaRwiqcp7M/S4U7qhtUSL3x78aO9c5UFx9EEtVVNBU6XtTAgO2/RaYCzoqKupQH+bJpIM9KEtXhK+HHlRxcFJXdml3v6PGPNfARWbTKW/Vk6+OZGZmYkKXYeHcvPsD8rx+tfdrrkxsbGPjKe+P6Lze7PH58fEKw6XXO7T+/d3kfXsz3iv2tbnfj3l7e6J3dv9vzp4fbczx0ZNGBzc3NPvMqMJ9KAHH0a0CLbIyz+98uzPgsXnkm/PbrfJRdtwL+vf96jRWcd2yPeewZUBiQZT9iAvEeo/D4MWEgGqAMSquM/L5/0dGd8XsWYSkUJE925N3+8OHDHlwG+7nh9VtS1irr21+ff9JTgFO9pkG05TqTOJnL+UVwa0IrRl2Un3FGZIFqMO65O0gALE11n4RaVZce4HX6/4qiziUoYMyBNYFlflp2uRirFZHwVm1Q9jTBbBrh96myiJf4wE5I0IEczhWUGHDbCFEqRRyV1NpEGULyu8V6Q8Sp7NFIAhdAQm1AJzxiR95J8i6zo69TZBEVRcEu8aTFpQiU8syGnAGNTvMoUztdwCvc16myiJUzUnX+eARZDMht4vWIVn2Jdrl7HWU+dTfC5P5Q2JsmplFNqKCvRVRZkvUidTVDQUD64canJH3591ZP1Q0jRpt4AbYCpuqUbQHIEScbPYyW8Ep8miNTZRL7ni7PZrNve3u6Pouv5q9BMUSrzHpGkCT7n9TzPdNdR7/1Oe6c+j4N+G1hoim2dV6wE5ZxXfb5TMJ4mkTTAP34sWHU+WvygDJgwYcKECRMmrC20qvR2d7ckl9AcqyPbmTBhQg3+Zh9Ktjc65C+v1i+yvMY6tjc6cESHku1NmPA/h+8PJleQGE/kErkXN0a1wEEDhm6vayncJnjnVys9jFtbcFFzqAGjz4DKgCH/L0gDRpEBSvNc4OQiqN8bWougSvnc3Z23VeZz9mNlyPluAyiSMUnu6npLjMKzrOvsx8pAYSozA9IElisDPNIp3PuFPrIfKwNHNQ1omZCkAUelv59rAlwzMP2PlYWvF6QBfE2mCVW2cH8/t7pbVAz7sTJQVAo+TLxpQRTunR7S9ezHytASRrbiaEBug1XCvVXGfhwbTNeh5HN/KC0wDeB+YEXqODZyhKoRy5HLo8sUNJRM8dzjbxmw0DdDjuhQUtBQ0gCbUBmR/w2gjpXhpP8vyPTPe4F+B9CA3BZnP1aGk/6/QMK8v5/iMwMy9W0C+3FcvANG/3mGjdf2WwAAAABJRU5ErkJggg==" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("f5dddb41dcafef616e959c2817808e0be741c89ffbfed39134a13e75b811863d"), + name: Some(Arc::from("Zuri")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAE10lEQVR4Xu2asYsUSRjFvePSEzY5DkTB5TwVAxUWTMYVjeQwGcwuMdzkNtxMPC5YQwMVQTDRwEhDweP+ATExFe5v8fp4DW95++vqmS12bae1Hzyquvqb2nqvvurpqdpjx5bg+A/fNes/rTUn1n5sVHfp+m+XT7e8s3l+H93O/kaHFF4ywkLThGxjf6MDxavMrEixJbK/0SFFc/aZASWyv9Eh17uM4DKgYJL9jQ4UTxMomGR/o0Ou/Qsnf+48Cyz0q/4W8KzbAGYAxacJ7G/lYCFc2/mg4zLgvYzJstTGz3I8gyPFse5Ss7l981Jz7/aVlqqrjcJK5mRfabY/w/EMjhxs0qJTOOn7NiONoNAsM47jGRw50Idbt1phBxFfMkHX6iOFUzxN4XgGhwepB1afeOH6qe/3rlUXHEcT1FdpKXC5rIQBOXiLTgOcFSXqXhrgz6aJNCNNWIlvCX9d+auLgpI7s3MtfZ2xZn4FlvpkLMczOPjmRmZmJCl2GR3Lz3A8Xxxvn+40yfX19X1kPPHnmY3m7wdPOoLVpnvu9/6dm/vodvZHvP/1RvPvxVstVddk7P5+reWj7fnSzy8FDdjY2NgTrzrjiTQgZ58G9JH9ESn+xbnZXjYeWUY9v7vVJI/agI9vH+/RorON/RE24LNlQMmAJOMJG5DPCNWPyoDSEjhUBmgAEqryw+vdlh6Mr0sxptJQwkQP7N3LV50nvgzwfcfrs6Lulah7//xytWUKt3gvg+zPsSb1dpDrj+LSgL4Y/ZEcgAcqE0SL8aA1QBqQwtxm4RbkuvtxnPviGPw56u2gJIwZkCawrj+Ug2aK5uBNx5dik2qnEck+A/w3VFJvB33iF5mQpAE5myksM2DRDFMoBdaSejtIAyhe9/gsyHjVPRMpgEJoiE0oCc8Ykc+SJN818qFrUm8HFEXBfeJNi0kTSsIzG3IJMDbFq07RfBtNIzLGJfV20CdM1JN/mQEWQzIbeL/EUnwKTeGlV3K2H8gAfu/X0sYkuZRySdWSgnmdWcC4QQz4Y/NsL/969qYl22tIwUm9/dkAU22DGkByBknGLyNFW3hJfJpgI6i3g3zPF2ezWTOfz9tSdDt/FZopSnU+I5I0wde8n9cWnVmgd36nvVOfpWIOZICFpti+6xJLgnLNqz3fKRhPk8iSAf7hY8Fqc2nxB86ACRMmTJgwYcLKQrtK/3361Etuo3knSvdUsr8JEybsB3+z15L9jQ75y6vvF1neYxv7Gx04o7VkfxMmfOPw88HkDhLjCW6Re3NjNBscNKD2eF1b4WmCT329I8T4lQM3NWsN4IHK6DKgZEDN/xdwCax8BijNc4OTm6B+b+jbBFXK+yDT6c8s4HGZqWuOZ3DkercBFMmYJE9zfSRmE1I4S8VxPIODwlRnBqQJrJcMyFnO2c9zQ5ccz+DgrKYBfSYkaUAtuR/A/YIk9wqOZL8gDeBrMk0oZQvP9nm+v4iK5XgGB0Wl4EXiTQuh8DwRYnuWHM/g6BNG9sXRAB6DUTzvczzVYLrWkt/7tbS4NCBZaktSTzVyhkozljOXpesUVMucYZHn+4sMUCz1VIMzWksKqiUNsAmLjMj/D6CewXHY/y/I9M9ngc/3Swbk0TjHMzgO+/8FEuSz/RTPDEjm/wdwPLX4H8bzDGXhwa6jAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }] +}); diff --git a/packages/app-lib/src/api/minecraft_skins/assets/test/MissingNo_normalized.png b/packages/app-lib/src/api/minecraft_skins/assets/test/MissingNo_normalized.png new file mode 100644 index 000000000..639b3fe15 Binary files /dev/null and b/packages/app-lib/src/api/minecraft_skins/assets/test/MissingNo_normalized.png differ diff --git a/packages/app-lib/src/api/minecraft_skins/png_util.rs b/packages/app-lib/src/api/minecraft_skins/png_util.rs new file mode 100644 index 000000000..65e008ef7 --- /dev/null +++ b/packages/app-lib/src/api/minecraft_skins/png_util.rs @@ -0,0 +1,323 @@ +//! Miscellaneous PNG utilities for Minecraft skins. + +use std::sync::Arc; + +use base64::Engine; +use bytemuck::{AnyBitPattern, NoUninit}; +use bytes::Bytes; +use data_url::DataUrl; +use futures::{Stream, TryStreamExt, future::Either, stream}; +use tokio_util::{compat::FuturesAsyncReadCompatExt, io::SyncIoBridge}; +use url::Url; + +use crate::{ + ErrorKind, minecraft_skins::UrlOrBlob, util::fetch::REQWEST_CLIENT, +}; + +pub async fn url_to_data_stream( + url: &Url, +) -> crate::Result> + use<>> { + if url.scheme() == "data" { + let data = DataUrl::process(url.as_str())?.decode_to_vec()?.0.into(); + + Ok(Either::Left(stream::once(async { Ok(data) }))) + } else { + let response = REQWEST_CLIENT + .get(url.as_str()) + .header("Accept", "image/png") + .send() + .await + .and_then(|response| response.error_for_status())?; + + Ok(Either::Right(response.bytes_stream())) + } +} + +pub fn blob_to_data_url(png_data: impl AsRef<[u8]>) -> Option> { + let png_data = png_data.as_ref(); + + is_png(png_data).then(|| { + Url::parse(&format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(png_data) + )) + .unwrap() + .into() + }) +} + +pub fn is_png(png_data: &[u8]) -> bool { + /// The initial 8 bytes of a PNG file, used to identify it as such. + /// + /// Reference: + const PNG_SIGNATURE: &[u8] = + &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + + png_data.starts_with(PNG_SIGNATURE) +} + +pub fn dimensions(png_data: &[u8]) -> crate::Result<(u32, u32)> { + if !is_png(png_data) { + Err(ErrorKind::InvalidPng)?; + } + + // Read the width and height fields from the IHDR chunk, which the + // PNG specification mandates to be the first in the file, just after + // the 8 signature bytes. See: + // https://www.w3.org/TR/png-3/#5DataRep + // https://www.w3.org/TR/png-3/#11IHDR + let width = u32::from_be_bytes( + png_data + .get(16..20) + .ok_or(ErrorKind::InvalidPng)? + .try_into() + .unwrap(), + ); + let height = u32::from_be_bytes( + png_data + .get(20..24) + .ok_or(ErrorKind::InvalidPng)? + .try_into() + .unwrap(), + ); + + Ok((width, height)) +} + +/// Normalizes the texture of a Minecraft skin to the modern 64x64 format, handling +/// legacy 64x32 skins as the vanilla game client does. This function prioritizes +/// PNG encoding speed over compression density, so the resulting textures are better +/// suited for display purposes, not persistent storage or transmission. +/// +/// The normalized, processed is returned texture as a byte array in PNG format. +pub async fn normalize_skin_texture( + texture: &UrlOrBlob, +) -> crate::Result { + let texture_stream = SyncIoBridge::new(Box::pin( + match texture { + UrlOrBlob::Url(url) => Either::Left( + url_to_data_stream(url) + .await? + .map_err(std::io::Error::other) + .into_async_read(), + ), + UrlOrBlob::Blob(blob) => Either::Right( + stream::once({ + let blob = Bytes::clone(blob); + async { Ok(blob) } + }) + .into_async_read(), + ), + } + .compat(), + )); + + tokio::task::spawn_blocking(|| { + let mut png_reader = { + let mut decoder = png::Decoder::new(texture_stream); + decoder.set_transformations( + png::Transformations::normalize_to_color8(), + ); + decoder.read_info() + }?; + + // The code below assumes that the skin texture has valid dimensions. + // This also serves as a way to bail out early for obviously invalid or + // adversarial textures + if png_reader.info().width != 64 + || ![64, 32].contains(&png_reader.info().height) + { + Err(ErrorKind::InvalidSkinTexture)?; + } + + let is_legacy_skin = png_reader.info().height == 32; + + let mut texture_buf = if is_legacy_skin { + // Legacy skins have half the height, so duplicate the rows to + // turn them into a 64x64 texture + vec![0; png_reader.output_buffer_size() * 2] + } else { + // Modern skins are left as-is + vec![0; png_reader.output_buffer_size()] + }; + + let texture_buf_color_type = png_reader.output_color_type().0; + png_reader.next_frame(&mut texture_buf)?; + + if is_legacy_skin { + convert_legacy_skin_texture( + &mut texture_buf, + texture_buf_color_type, + png_reader.info(), + )?; + } + + let mut encoded_png = vec![]; + + let mut png_encoder = png::Encoder::new(&mut encoded_png, 64, 64); + png_encoder.set_color(texture_buf_color_type); + png_encoder.set_depth(png::BitDepth::Eight); + png_encoder.set_filter(png::FilterType::NoFilter); + png_encoder.set_compression(png::Compression::Fast); + + // Keeping color space information properly set, to handle the occasional + // strange PNG with non-sRGB chromacities and/or different grayscale spaces + // that keeps most people wondering, is what sets a carefully crafted image + // manipulation routine apart :) + if let Some(source_chromacities) = + png_reader.info().source_chromaticities.as_ref().copied() + { + png_encoder.set_source_chromaticities(source_chromacities); + } + if let Some(source_gamma) = + png_reader.info().source_gamma.as_ref().copied() + { + png_encoder.set_source_gamma(source_gamma); + } + if let Some(source_srgb) = png_reader.info().srgb.as_ref().copied() { + png_encoder.set_source_srgb(source_srgb); + } + + let mut png_writer = png_encoder.write_header()?; + png_writer.write_image_data(&texture_buf)?; + png_writer.finish()?; + + Ok(encoded_png.into()) + }) + .await? +} + +/// Converts a legacy skin texture (32x64 pixels) within a 64x64 buffer to the +/// native 64x64 format used by modern Minecraft clients. +/// +/// See also 25w16a's `SkinTextureDownloader#processLegacySkin` method. +#[inline] +fn convert_legacy_skin_texture( + texture_buf: &mut [u8], + texture_color_type: png::ColorType, + texture_info: &png::Info, +) -> crate::Result<()> { + /// The skin faces the game client copies around, in order, when converting a + /// legacy skin to the native 64x64 format. + const FACE_COPY_PARAMETERS: &[( + usize, + usize, + isize, + isize, + usize, + usize, + )] = &[ + (4, 16, 16, 32, 4, 4), + (8, 16, 16, 32, 4, 4), + (0, 20, 24, 32, 4, 12), + (4, 20, 16, 32, 4, 12), + (8, 20, 8, 32, 4, 12), + (12, 20, 16, 32, 4, 12), + (44, 16, -8, 32, 4, 4), + (48, 16, -8, 32, 4, 4), + (40, 20, 0, 32, 4, 12), + (44, 20, -8, 32, 4, 12), + (48, 20, -16, 32, 4, 12), + (52, 20, -8, 32, 4, 12), + ]; + + for (x, y, off_x, off_y, width, height) in FACE_COPY_PARAMETERS { + macro_rules! do_copy { + ($pixel_type:ty) => { + copy_rect_mirror_horizontally::<$pixel_type>( + // This cast should never fail because all pixels have a depth of 8 bits + // after the transformations applied during decoding + ::bytemuck::try_cast_slice_mut(texture_buf).map_err(|_| ErrorKind::InvalidPng)?, + &texture_info, + *x, + *y, + *off_x, + *off_y, + *width, + *height, + ) + }; + } + + match texture_color_type.samples() { + 1 => do_copy!(rgb::Gray), + 2 => do_copy!(rgb::GrayAlpha), + 3 => do_copy!(rgb::Rgb), + 4 => do_copy!(rgb::Rgba), + _ => Err(ErrorKind::InvalidPng)?, // Cannot happen by PNG spec after transformations + }; + } + + Ok(()) +} + +/// Copies a `width` pixels wide, `height` pixels tall rectangle of pixels within `texture_buf` +/// whose top-left corner is at coordinates `(x, y)` to a destination rectangle whose top-left +/// corner is at coordinates `(x + off_x, y + off_y)`, while mirroring (i.e., flipping) the +/// pixels horizontally. +/// +/// Equivalent to Mojang's Blaze3D `NativeImage#copyRect(int, int, int, int, int, int, +/// boolean, boolean)` method, but with the last two parameters fixed to `true` and `false`, +/// respectively. +#[allow(clippy::too_many_arguments)] +fn copy_rect_mirror_horizontally( + texture_buf: &mut [PixelType], + texture_info: &png::Info, + x: usize, + y: usize, + off_x: isize, + off_y: isize, + width: usize, + height: usize, +) { + for row in 0..height { + for col in 0..width { + let src_x = x + col; + let src_y = y + row; + let dst_x = (x as isize + off_x) as usize + (width - 1 - col); + let dst_y = (y as isize + off_y) as usize + row; + + texture_buf[dst_x + dst_y * texture_info.width as usize] = + texture_buf[src_x + src_y * texture_info.width as usize]; + } + } +} + +#[cfg(test)] +#[tokio::test] +async fn normalize_skin_texture_works() { + let legacy_png_data = &include_bytes!("assets/default/MissingNo.png")[..]; + let expected_normalized_png_data = + &include_bytes!("assets/test/MissingNo_normalized.png")[..]; + + let normalized_png_data = + normalize_skin_texture(&UrlOrBlob::Blob(legacy_png_data.into())) + .await + .expect("Failed to normalize skin texture"); + + let decode_to_pixels = |png_data: &[u8]| { + let decoder = png::Decoder::new(png_data); + let mut reader = decoder.read_info().expect("Failed to read PNG info"); + let mut buffer = vec![0; reader.output_buffer_size()]; + reader + .next_frame(&mut buffer) + .expect("Failed to decode PNG"); + (buffer, reader.info().clone()) + }; + + let (normalized_pixels, normalized_info) = + decode_to_pixels(&normalized_png_data); + let (expected_pixels, expected_info) = + decode_to_pixels(expected_normalized_png_data); + + // Check that dimensions match + assert_eq!(normalized_info.width, expected_info.width); + assert_eq!(normalized_info.height, expected_info.height); + assert_eq!(normalized_info.color_type, expected_info.color_type); + + // Check that pixel data matches + assert_eq!( + normalized_pixels, expected_pixels, + "Pixel data doesn't match" + ); +} diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs index 421d805c1..2beb93ed7 100644 --- a/packages/app-lib/src/api/mod.rs +++ b/packages/app-lib/src/api/mod.rs @@ -6,6 +6,7 @@ pub mod jre; pub mod logs; pub mod metadata; pub mod minecraft_auth; +pub mod minecraft_skins; pub mod mr_auth; pub mod pack; pub mod process; diff --git a/packages/app-lib/src/api/profile/mod.rs b/packages/app-lib/src/api/profile/mod.rs index 5c10f6433..da12fe309 100644 --- a/packages/app-lib/src/api/profile/mod.rs +++ b/packages/app-lib/src/api/profile/mod.rs @@ -642,9 +642,8 @@ pub async fn run( } /// Run Minecraft using a profile, and credentials for authentication -/// Returns Arc pointer to RwLock to Child #[tracing::instrument(skip(credentials))] -pub async fn run_credentials( +async fn run_credentials( path: &str, credentials: &Credentials, quick_play_type: &QuickPlayType, diff --git a/packages/app-lib/src/api/settings.rs b/packages/app-lib/src/api/settings.rs index 75e34d33c..761959683 100644 --- a/packages/app-lib/src/api/settings.rs +++ b/packages/app-lib/src/api/settings.rs @@ -24,6 +24,8 @@ pub async fn set(settings: Settings) -> crate::Result<()> { #[tracing::instrument] pub async fn cancel_directory_change() -> crate::Result<()> { + // This is called to handle state initialization errors due to folder migrations + // failing, so fetching a DB connection pool from `State::get` is not reliable here let pool = crate::state::db::connect().await?; let mut settings = Settings::get(&pool).await?; diff --git a/packages/app-lib/src/error.rs b/packages/app-lib/src/error.rs index 587c9559a..75c144f55 100644 --- a/packages/app-lib/src/error.rs +++ b/packages/app-lib/src/error.rs @@ -1,5 +1,8 @@ //! Theseus error type +use std::sync::Arc; + use crate::{profile, util}; +use data_url::DataUrlError; use tracing_error::InstrumentError; #[derive(thiserror::Error, Debug)] @@ -125,12 +128,35 @@ pub enum ErrorKind { #[error("Error resolving DNS: {0}")] DNSError(#[from] hickory_resolver::ResolveError), + + #[error("An online profile for {user_name} is not available")] + OnlineMinecraftProfileUnavailable { user_name: String }, + + #[error("Invalid data URL: {0}")] + InvalidDataUrl(#[from] DataUrlError), + + #[error("Invalid data URL: {0}")] + InvalidDataUrlBase64(#[from] data_url::forgiving_base64::InvalidBase64), + + #[error("Invalid PNG")] + InvalidPng, + + #[error("Invalid PNG: {0}")] + PngDecodingError(#[from] png::DecodingError), + + #[error("PNG encoding error: {0}")] + PngEncodingError(#[from] png::EncodingError), + + #[error( + "A skin texture must have a dimension of either 64x64 or 64x32 pixels" + )] + InvalidSkinTexture, } #[derive(Debug)] pub struct Error { - pub raw: std::sync::Arc, - pub source: tracing_error::TracedError>, + pub raw: Arc, + pub source: tracing_error::TracedError>, } impl std::error::Error for Error { @@ -148,7 +174,7 @@ impl std::fmt::Display for Error { impl> From for Error { fn from(source: E) -> Self { let error = Into::::into(source); - let boxed_error = std::sync::Arc::new(error); + let boxed_error = Arc::new(error); Self { raw: boxed_error.clone(), diff --git a/packages/app-lib/src/launcher/args.rs b/packages/app-lib/src/launcher/args.rs index 0884fb574..5d6bbc5d8 100644 --- a/packages/app-lib/src/launcher/args.rs +++ b/packages/app-lib/src/launcher/args.rs @@ -213,7 +213,7 @@ fn parse_jvm_argument( } #[allow(clippy::too_many_arguments)] -pub fn get_minecraft_arguments( +pub async fn get_minecraft_arguments( arguments: Option<&[Argument]>, legacy_arguments: Option<&str>, credentials: &Credentials, @@ -226,6 +226,9 @@ pub fn get_minecraft_arguments( java_arch: &str, quick_play_type: &QuickPlayType, ) -> crate::Result> { + let access_token = credentials.access_token.clone(); + let profile = credentials.maybe_online_profile().await; + if let Some(arguments) = arguments { let mut parsed_arguments = Vec::new(); @@ -235,9 +238,9 @@ pub fn get_minecraft_arguments( |arg| { parse_minecraft_argument( arg, - &credentials.access_token, - &credentials.username, - credentials.id, + &access_token, + &profile.name, + profile.id, version, asset_index_name, game_directory, @@ -257,9 +260,9 @@ pub fn get_minecraft_arguments( for x in legacy_arguments.split(' ') { parsed_arguments.push(parse_minecraft_argument( &x.replace(' ', TEMPORARY_REPLACE_CHAR), - &credentials.access_token, - &credentials.username, - credentials.id, + &access_token, + &profile.name, + profile.id, version, asset_index_name, game_directory, diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs index f1affa923..63912b5b9 100644 --- a/packages/app-lib/src/launcher/mod.rs +++ b/packages/app-lib/src/launcher/mod.rs @@ -641,7 +641,8 @@ pub async fn launch_minecraft( *resolution, &java_version.architecture, quick_play_type, - )? + ) + .await? .into_iter(), ) .current_dir(instance_path.clone()); @@ -651,7 +652,7 @@ pub async fn launch_minecraft( if std::env::var("CARGO").is_ok() { command.env_remove("DYLD_FALLBACK_LIBRARY_PATH"); } - // Java options should be set in instance options (the existence of _JAVA_OPTIONS overwites them) + // Java options should be set in instance options (the existence of _JAVA_OPTIONS overwrites them) command.env_remove("_JAVA_OPTIONS"); command.envs(env_args); diff --git a/packages/app-lib/src/state/db.rs b/packages/app-lib/src/state/db.rs index 387d381f2..de5464c4c 100644 --- a/packages/app-lib/src/state/db.rs +++ b/packages/app-lib/src/state/db.rs @@ -1,5 +1,4 @@ use crate::state::DirectoryInfo; -use sqlx::migrate::MigrateDatabase; use sqlx::sqlite::{ SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, }; @@ -20,14 +19,11 @@ pub(crate) async fn connect() -> crate::Result> { let uri = format!("sqlite:{}", settings_dir.join("app.db").display()); - if !Sqlite::database_exists(&uri).await? { - Sqlite::create_database(&uri).await?; - } - let conn_options = SqliteConnectOptions::from_str(&uri)? .busy_timeout(Duration::from_secs(30)) .journal_mode(SqliteJournalMode::Wal) - .optimize_on_close(true, None); + .optimize_on_close(true, None) + .create_if_missing(true); let pool = SqlitePoolOptions::new() .max_connections(100) @@ -36,5 +32,33 @@ pub(crate) async fn connect() -> crate::Result> { sqlx::migrate!().run(&pool).await?; + if let Err(err) = stale_data_cleanup(&pool).await { + tracing::warn!( + "Failed to clean up stale data from state database: {err}" + ); + } + Ok(pool) } + +/// Cleans up data from the database that is no longer referenced, but must be +/// kept around for a little while to allow users to recover from accidental +/// deletions. +async fn stale_data_cleanup(pool: &Pool) -> crate::Result<()> { + let mut tx = pool.begin().await?; + + sqlx::query!( + "DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)" + ) + .execute(&mut *tx) + .await?; + sqlx::query!( + "DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)" + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(()) +} diff --git a/packages/app-lib/src/state/legacy_converter.rs b/packages/app-lib/src/state/legacy_converter.rs index 9aa563a50..7a04defde 100644 --- a/packages/app-lib/src/state/legacy_converter.rs +++ b/packages/app-lib/src/state/legacy_converter.rs @@ -19,6 +19,8 @@ use std::path::PathBuf; use tokio::sync::Semaphore; use uuid::Uuid; +use super::MinecraftProfile; + pub async fn migrate_legacy_data<'a, E>(exec: E) -> crate::Result<()> where E: sqlx::Executor<'a, Database = sqlx::Sqlite> + Copy, @@ -117,13 +119,16 @@ where .await { let minecraft_users_len = minecraft_auth.users.len(); - for (uuid, credential) in minecraft_auth.users { + for (uuid, legacy_credentials) in minecraft_auth.users { Credentials { - id: credential.id, - username: credential.username, - access_token: credential.access_token, - refresh_token: credential.refresh_token, - expires: credential.expires, + offline_profile: MinecraftProfile { + id: legacy_credentials.id, + name: legacy_credentials.username, + ..MinecraftProfile::default() + }, + access_token: legacy_credentials.access_token, + refresh_token: legacy_credentials.refresh_token, + expires: legacy_credentials.expires, active: minecraft_auth.default_user == Some(uuid) || minecraft_users_len == 1, } diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs index f972610da..febfd67da 100644 --- a/packages/app-lib/src/state/minecraft_auth.rs +++ b/packages/app-lib/src/state/minecraft_auth.rs @@ -5,25 +5,38 @@ use base64::prelude::{BASE64_STANDARD, BASE64_URL_SAFE_NO_PAD}; use chrono::{DateTime, Duration, TimeZone, Utc}; use dashmap::DashMap; use futures::TryStreamExt; +use heck::ToTitleCase; use p256::ecdsa::signature::Signer; use p256::ecdsa::{Signature, SigningKey, VerifyingKey}; use p256::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding}; use rand::Rng; use rand::rngs::OsRng; -use reqwest::Response; use reqwest::header::HeaderMap; +use reqwest::{Response, StatusCode}; use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; +use serde::ser::SerializeStruct; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::json; use sha2::Digest; +use std::borrow::Cow; use std::collections::HashMap; +use std::collections::hash_map::Entry; use std::future::Future; +use std::hash::{BuildHasherDefault, DefaultHasher}; +use std::io; +use std::ops::Deref; +use std::sync::Arc; +use std::time::Instant; +use tokio::runtime::{Handle, RuntimeFlavor}; +use tokio::sync::Mutex; +use tokio::task; +use url::Url; use uuid::Uuid; #[derive(Debug, Clone, Copy)] pub enum MinecraftAuthStep { GetDeviceToken, - SisuAuthenicate, + SisuAuthenticate, GetOAuthToken, RefreshOAuthToken, SisuAuthorize, @@ -53,7 +66,7 @@ pub enum MinecraftAuthenticationError { raw: String, #[source] source: serde_json::Error, - status_code: reqwest::StatusCode, + status_code: StatusCode, }, #[error("Request failed during step {step:?}: {source}")] Request { @@ -172,36 +185,87 @@ pub async fn login_finish( minecraft_entitlements(&minecraft_token.access_token).await?; let mut credentials = Credentials { - id: Uuid::default(), - username: String::default(), + offline_profile: MinecraftProfile::default(), access_token: minecraft_token.access_token, refresh_token: oauth_token.value.refresh_token, expires: oauth_token.date + Duration::seconds(oauth_token.value.expires_in as i64), active: true, }; - credentials.get_profile().await?; + + // During login, we need to fetch the online profile at least once to get the + // player UUID and name to use for the offline profile, in order for that offline + // profile to make sense. It's also important to modify the returned credentials + // object, as otherwise continued usage of it will skip the profile cache due to + // the dummy UUID + let online_profile = credentials + .online_profile() + .await + .ok_or(io::Error::other("Failed to fetch player profile"))?; + credentials.offline_profile = MinecraftProfile { + id: online_profile.id, + name: online_profile.name.clone(), + ..credentials.offline_profile + }; credentials.upsert(exec).await?; Ok(credentials) } -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Deserialize, Debug)] pub struct Credentials { - pub id: Uuid, - pub username: String, + /// The offline profile of the user these credentials are for. + /// + /// Such a profile can only be relied upon to have a proper player UUID, which is + /// never changed. A potentially stale username may be available, but no other data + /// such as skins or capes is available. + #[serde(rename = "profile")] + pub offline_profile: MinecraftProfile, pub access_token: String, pub refresh_token: String, pub expires: DateTime, pub active: bool, } +/// An entry in the player profile cache, keyed by player UUID. +pub(super) enum ProfileCacheEntry { + /// A cached profile that is valid, even though it may be stale. + Hit(Arc), + /// A negative profile fetch result due to an authentication error, + /// from which we're recovering by holding off from repeatedly + /// attempting to fetch the profile until the token is refreshed + /// or some time has passed. + AuthErrorBackoff { + likely_expired_token: String, + last_attempt: Instant, + }, +} + +/// A thread-safe cache of online profiles, used to avoid fetching the +/// same profile multiple times as long as they don't get too stale. +/// +/// The cache has to be static because credential objects are short lived +/// and disposable, and in the future several threads may be interested in +/// profile data. +pub(super) static PROFILE_CACHE: Mutex< + HashMap>, +> = Mutex::const_new(HashMap::with_hasher(BuildHasherDefault::new())); + impl Credentials { + /// Refreshes the authentication tokens for this user if they are expired, or + /// very close to expiration. async fn refresh( &mut self, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result<()> { + // Use a margin of 5 minutes to give e.g. Minecraft and potentially + // other operations that depend on a fresh token 5 minutes to complete + // from now, and deal with some classes of clock skew + if self.expires > Utc::now() + Duration::minutes(5) { + return Ok(()); + } + let oauth_token = oauth_refresh(&self.refresh_token).await?; let (pair, current_date, _) = DeviceTokenPair::refresh_and_get_device_token( @@ -235,22 +299,118 @@ impl Credentials { self.expires = oauth_token.date + Duration::seconds(oauth_token.value.expires_in as i64); - self.get_profile().await?; - self.upsert(exec).await?; Ok(()) } - async fn get_profile(&mut self) -> crate::Result<()> { - let profile = minecraft_profile(&self.access_token).await?; + #[tracing::instrument(skip(self))] + pub async fn online_profile(&self) -> Option> { + let mut profile_cache = PROFILE_CACHE.lock().await; - self.id = profile.id.unwrap_or_default(); - self.username = profile.name; + loop { + match profile_cache.entry(self.offline_profile.id) { + Entry::Occupied(entry) => { + match entry.get() { + ProfileCacheEntry::Hit(profile) + if profile.is_fresh() => + { + return Some(Arc::clone(profile)); + } + ProfileCacheEntry::Hit(_) => { + // The profile is stale, so remove it and try again + entry.remove(); + continue; + } + // Auth errors must be handled with a backoff strategy because it + // has been experimentally found that Mojang quickly rate limits + // the profile data endpoint on repeated attempts with bad auth + ProfileCacheEntry::AuthErrorBackoff { + likely_expired_token, + last_attempt, + } if &self.access_token != likely_expired_token + || Instant::now() + .saturating_duration_since(*last_attempt) + > std::time::Duration::from_secs(60) => + { + entry.remove(); + continue; + } + ProfileCacheEntry::AuthErrorBackoff { .. } => { + return None; + } + } + } + Entry::Vacant(entry) => { + match minecraft_profile(&self.access_token).await { + Ok(profile) => { + let profile = Arc::new(profile); + let cache_entry = + ProfileCacheEntry::Hit(Arc::clone(&profile)); - Ok(()) + // When fetching a profile for the first time, the player UUID may + // be unknown (i.e., set to a dummy value), so make sure we don't + // cache it in the wrong place + if entry.key() != &profile.id { + profile_cache.insert(profile.id, cache_entry); + } else { + entry.insert(cache_entry); + } + + return Some(profile); + } + Err( + err @ MinecraftAuthenticationError::DeserializeResponse { + status_code: StatusCode::UNAUTHORIZED, + .. + }, + ) => { + tracing::warn!( + "Failed to fetch online profile for UUID {} likely due to stale credentials, backing off: {err}", + self.offline_profile.id + ); + + // We have to assume the player UUID key we have is correct here, which + // should always be the case assuming a non-adversarial server. In any + // case, any cache poisoning is inconsequential due to the entry expiration + // and the fact that we use at most one single dummy UUID + entry.insert(ProfileCacheEntry::AuthErrorBackoff { + likely_expired_token: self.access_token.clone(), + last_attempt: Instant::now(), + }); + + return None; + } + Err(err) => { + tracing::warn!( + "Failed to fetch online profile for UUID {}: {err}", + self.offline_profile.id + ); + + return None; + } + } + } + } + } } + /// Attempts to fetch the online profile for this user if possible, and if that fails + /// falls back to the known offline profile data. + /// + /// See also the [`online_profile`](Self::online_profile) method. + pub async fn maybe_online_profile( + &self, + ) -> MaybeOnlineMinecraftProfile<'_> { + let online_profile = self.online_profile().await; + online_profile.map_or_else( + || MaybeOnlineMinecraftProfile::Offline(&self.offline_profile), + MaybeOnlineMinecraftProfile::Online, + ) + } + + /// Like [`get_active`](Self::get_active), but enforces credentials to be + /// successfully refreshed unless the network is unreachable or times out. #[tracing::instrument] pub async fn get_default_credential( exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, @@ -258,37 +418,35 @@ impl Credentials { let credentials = Self::get_active(exec).await?; if let Some(mut creds) = credentials { - if creds.expires < Utc::now() { - let res = creds.refresh(exec).await; + let res = creds.refresh(exec).await; - match res { - Ok(_) => Ok(Some(creds)), - Err(err) => { - if let ErrorKind::MinecraftAuthenticationError( - MinecraftAuthenticationError::Request { - ref source, - .. - }, - ) = *err.raw - { - if source.is_connect() || source.is_timeout() { - return Ok(Some(creds)); - } + match res { + Ok(_) => Ok(Some(creds)), + Err(err) => { + if let ErrorKind::MinecraftAuthenticationError( + MinecraftAuthenticationError::Request { + ref source, + .. + }, + ) = *err.raw + { + if source.is_connect() || source.is_timeout() { + return Ok(Some(creds)); } - - Err(err) } + + Err(err) } - } else { - Ok(Some(creds)) } } else { Ok(None) } } + /// Fetches the currently selected credentials from the database, attempting + /// to refresh them if they are expired. pub async fn get_active( - exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>, + exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result> { let res = sqlx::query!( " @@ -301,21 +459,31 @@ impl Credentials { .fetch_optional(exec) .await?; - Ok(res.map(|x| Self { - id: Uuid::parse_str(&x.uuid).unwrap_or_default(), - username: x.username, - access_token: x.access_token, - refresh_token: x.refresh_token, - expires: Utc - .timestamp_opt(x.expires, 0) - .single() - .unwrap_or_else(Utc::now), - active: x.active == 1, - })) + Ok(match res { + Some(x) => { + let mut credentials = Self { + offline_profile: MinecraftProfile { + id: Uuid::parse_str(&x.uuid).unwrap_or_default(), + name: x.username, + ..MinecraftProfile::default() + }, + access_token: x.access_token, + refresh_token: x.refresh_token, + expires: Utc + .timestamp_opt(x.expires, 0) + .single() + .unwrap_or_else(Utc::now), + active: x.active == 1, + }; + credentials.refresh(exec).await.ok(); + Some(credentials) + } + None => None, + }) } pub async fn get_all( - exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>, + exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result> { let res = sqlx::query!( " @@ -327,23 +495,27 @@ impl Credentials { .fetch(exec) .try_fold(DashMap::new(), |acc, x| { let uuid = Uuid::parse_str(&x.uuid).unwrap_or_default(); - - acc.insert( - uuid, - Self { + let mut credentials = Self { + offline_profile: MinecraftProfile { id: uuid, - username: x.username, - access_token: x.access_token, - refresh_token: x.refresh_token, - expires: Utc - .timestamp_opt(x.expires, 0) - .single() - .unwrap_or_else(Utc::now), - active: x.active == 1, + name: x.username, + ..MinecraftProfile::default() }, - ); + access_token: x.access_token, + refresh_token: x.refresh_token, + expires: Utc + .timestamp_opt(x.expires, 0) + .single() + .unwrap_or_else(Utc::now), + active: x.active == 1, + }; - async move { Ok(acc) } + async move { + credentials.refresh(exec).await.ok(); + acc.insert(uuid, credentials); + + Ok(acc) + } }) .await?; @@ -354,8 +526,9 @@ impl Credentials { &self, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result<()> { + let profile = self.maybe_online_profile().await; let expires = self.expires.timestamp(); - let uuid = self.id.as_hyphenated().to_string(); + let uuid = profile.id.as_hyphenated().to_string(); if self.active { sqlx::query!( @@ -381,7 +554,7 @@ impl Credentials { ", uuid, self.active, - self.username, + profile.name, self.access_token, self.refresh_token, expires, @@ -411,6 +584,46 @@ impl Credentials { } } +impl Serialize for Credentials { + fn serialize( + &self, + serializer: S, + ) -> Result { + // Opportunistically hydrate the profile with its online data if possible for frontend + // consumption, transparently handling all the possible Tokio runtime states the current + // thread may be in the most efficient way + let profile = match Handle::try_current().ok() { + Some(runtime) + if runtime.runtime_flavor() == RuntimeFlavor::CurrentThread => + { + runtime.block_on(self.maybe_online_profile()) + } + Some(runtime) => task::block_in_place(|| { + runtime.block_on(self.maybe_online_profile()) + }), + None => tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_or_else( + |_| { + MaybeOnlineMinecraftProfile::Offline( + &self.offline_profile, + ) + }, + |runtime| runtime.block_on(self.maybe_online_profile()), + ), + }; + + let mut ser = serializer.serialize_struct("Credentials", 5)?; + ser.serialize_field("profile", &*profile)?; + ser.serialize_field("access_token", &self.access_token)?; + ser.serialize_field("refresh_token", &self.refresh_token)?; + ser.serialize_field("expires", &self.expires)?; + ser.serialize_field("active", &self.active)?; + ser.end() + } +} + pub struct DeviceTokenPair { pub token: DeviceToken, pub key: DeviceTokenKey, @@ -639,7 +852,7 @@ async fn sisu_authenticate( "TitleId": "1794566092", }), key, - MinecraftAuthStep::SisuAuthenicate, + MinecraftAuthStep::SisuAuthenticate, current_date, ) .await?; @@ -911,13 +1124,197 @@ async fn minecraft_token( }) } -#[derive(Deserialize)] -struct MinecraftProfile { - pub id: Option, - pub name: String, +#[derive( + sqlx::Type, Deserialize, Serialize, Debug, Copy, Clone, PartialEq, Eq, +)] +#[serde(rename_all = "UPPERCASE")] +#[sqlx(rename_all = "UPPERCASE")] +pub enum MinecraftSkinVariant { + /// The classic player model, with arms that are 4 pixels wide. + Classic, + /// The slim player model, with arms that are 3 pixels wide. + Slim, + /// The player model is unknown. + #[serde(other)] + Unknown, // Defensive handling of unexpected Mojang API return values to + // prevent breaking the entire profile parsing } -#[tracing::instrument] +#[derive(Deserialize, Serialize, Debug, Copy, Clone, PartialEq, Eq)] +#[serde(rename_all = "UPPERCASE")] +pub enum MinecraftCharacterExpressionState { + /// This expression is selected for being displayed ingame. + /// + /// At the moment, at most one expression can be selected at a time. + Active, + /// This expression is not selected for being displayed ingame. + Inactive, + /// The expression selection status is unknown. + #[serde(other)] + Unknown, // Defensive handling of unexpected Mojang API return values to + // prevent breaking the entire profile parsing +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct MinecraftSkin { + /// The UUID of this skin object. + /// + /// As of 2025-04-08, in the production Mojang profile endpoint this UUID + /// changes every time the player changes their skin, even if the skin + /// texture is the same as before. + pub id: Uuid, + /// The selection state of the skin. + /// + /// As of 2025-04-08, in the production Mojang profile endpoint this + /// is always `ACTIVE`, as only a single skin representing the current + /// skin is returned. + pub state: MinecraftCharacterExpressionState, + /// The URL to the skin texture. + /// + /// As of 2025-04-08, in the production Mojang profile endpoint the file + /// name for this URL is a hash of the skin texture, so that different + /// players using the same skin texture will share a texture URL. + pub url: Arc, + /// A hash of the skin texture. + /// + /// As of 2025-04-08, in the production Mojang profile endpoint this + /// is always set and the same as the file name of the skin texture URL. + #[serde( + default, // Defensive handling of unexpected Mojang API return values to + // prevent breaking the entire profile parsing + rename = "textureKey" + )] + pub texture_key: Option>, + /// The player model variant this skin is for. + pub variant: MinecraftSkinVariant, + /// User-friendly name for the skin. + /// + /// As of 2025-04-08, in the production Mojang profile endpoint this is + /// only set if the player has not set a custom skin, and this skin object + /// is therefore the default skin for the player's UUID. + #[serde( + default, + rename = "alias", + deserialize_with = "normalize_skin_alias_case" + )] + pub name: Option, +} + +impl MinecraftSkin { + /// Robustly computes the texture key for this skin, falling back to its + /// URL file name and finally to the skin UUID when necessary. + pub fn texture_key(&self) -> Arc { + self.texture_key.as_ref().cloned().unwrap_or_else(|| { + self.url + .path_segments() + .and_then(|mut path_segments| { + path_segments.next_back().map(String::from) + }) + .unwrap_or_else(|| self.id.as_simple().to_string()) + .into() + }) + } +} + +fn normalize_skin_alias_case<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + // Skin aliases have been spotted to be returned in all caps, so make sure + // they are normalized to a prettier title case + Ok(>>::deserialize(deserializer)? + .map(|alias| alias.to_title_case())) +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct MinecraftCape { + /// The UUID of the cape. + pub id: Uuid, + /// The selection state of the cape. + pub state: MinecraftCharacterExpressionState, + /// The URL to the cape texture. + pub url: Arc, + /// The user-friendly name for the cape. + #[serde(rename = "alias")] + pub name: Arc, +} + +#[derive(Deserialize, Serialize, Debug, Default, Clone)] +pub struct MinecraftProfile { + /// The UUID of the player. + #[serde(default)] + pub id: Uuid, + /// The username of the player. + pub name: String, + /// The skins the player is known to have. + /// + /// As of 2025-04-08, in the production Mojang profile endpoint every + /// player has a single skin. + pub skins: Vec, + /// The capes the player is known to have. + pub capes: Vec, + /// The instant when the profile was fetched. See also [Self::is_fresh]. + #[serde(skip)] + pub fetch_time: Option, +} + +impl MinecraftProfile { + /// Checks whether the profile data is fresh (i.e., highly likely to be + /// up-to-date because it was fetched recently) or stale. If it is not + /// known when this profile data has been fetched from Mojang servers (i.e., + /// `fetch_time` is `None`), the profile is considered stale. + /// + /// This can be used to determine if the profile data should be fetched again + /// from the Mojang API: the vanilla launcher was seen refreshing profile + /// data every 60 seconds when re-entering the skin selection screen, and + /// external applications may change this data at any time. + fn is_fresh(&self) -> bool { + self.fetch_time.is_some_and(|last_profile_fetch_time| { + Instant::now().saturating_duration_since(last_profile_fetch_time) + < std::time::Duration::from_secs(60) + }) + } + + /// Returns the currently selected skin for this profile. + pub fn current_skin(&self) -> crate::Result<&MinecraftSkin> { + Ok(self + .skins + .iter() + .find(|skin| { + skin.state == MinecraftCharacterExpressionState::Active + }) + // There should always be one active skin, even when the player uses their default skin + .ok_or_else(|| { + ErrorKind::OtherError("No active skin found".into()) + })?) + } + + /// Returns the currently selected cape for this profile. + pub fn current_cape(&self) -> Option<&MinecraftCape> { + self.capes.iter().find(|cape| { + cape.state == MinecraftCharacterExpressionState::Active + }) + } +} + +pub enum MaybeOnlineMinecraftProfile<'profile> { + /// An online profile, fetched from the Mojang API. + Online(Arc), + /// An offline profile, which has not been fetched from the Mojang API. + Offline(&'profile MinecraftProfile), +} + +impl Deref for MaybeOnlineMinecraftProfile<'_> { + type Target = MinecraftProfile; + + fn deref(&self) -> &Self::Target { + match self { + Self::Online(profile) => profile, + Self::Offline(profile) => profile, + } + } +} + +#[tracing::instrument(skip(token))] async fn minecraft_profile( token: &str, ) -> Result { @@ -926,6 +1323,9 @@ async fn minecraft_profile( .get("https://api.minecraftservices.com/minecraft/profile") .header("Accept", "application/json") .bearer_auth(token) + // Profiles may be refreshed periodically in response to user actions, + // so we want each refresh to be fast + .timeout(std::time::Duration::from_secs(10)) .send() }) .await @@ -942,14 +1342,23 @@ async fn minecraft_profile( } })?; - serde_json::from_str(&text).map_err(|source| { - MinecraftAuthenticationError::DeserializeResponse { - source, - raw: text, - step: MinecraftAuthStep::MinecraftProfile, - status_code: status, - } - }) + let mut profile = + serde_json::from_str::(&text).map_err(|source| { + MinecraftAuthenticationError::DeserializeResponse { + source, + raw: text, + step: MinecraftAuthStep::MinecraftProfile, + status_code: status, + } + })?; + profile.fetch_time = Some(Instant::now()); + + tracing::debug!( + "Successfully fetched Minecraft profile for {}", + profile.name + ); + + Ok(profile) } #[derive(Deserialize)] @@ -967,7 +1376,7 @@ async fn minecraft_entitlements( .bearer_auth(token) .send() }) - .await.map_err(|source| MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::MinecraftEntitlements })?; + .await.map_err(|source| MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::MinecraftEntitlements })?; let status = res.status(); let text = res.text().await.map_err(|source| { diff --git a/packages/app-lib/src/state/minecraft_skins/mod.rs b/packages/app-lib/src/state/minecraft_skins/mod.rs new file mode 100644 index 000000000..a5baad20c --- /dev/null +++ b/packages/app-lib/src/state/minecraft_skins/mod.rs @@ -0,0 +1,180 @@ +use futures::{Stream, StreamExt, stream}; +use uuid::{Uuid, fmt::Hyphenated}; + +use super::MinecraftSkinVariant; + +pub mod mojang_api; + +/// Represents the default cape for a Minecraft player. +#[derive(Debug, Clone)] +pub struct DefaultMinecraftCape { + /// The UUID of a cape for a Minecraft player, which comes from its profile. + /// + /// This UUID may or may not be different for every player, even if they refer to the same cape. + pub id: Uuid, +} + +impl DefaultMinecraftCape { + pub async fn set( + minecraft_user_id: Uuid, + cape_id: Uuid, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result<()> { + let minecraft_user_id = minecraft_user_id.as_hyphenated(); + let cape_id = cape_id.as_hyphenated(); + + sqlx::query!( + "INSERT OR REPLACE INTO default_minecraft_capes (minecraft_user_uuid, id) VALUES (?, ?)", + minecraft_user_id, cape_id + ) + .execute(&mut *db.acquire().await?) + .await?; + + Ok(()) + } + + pub async fn get( + minecraft_user_id: Uuid, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result> { + let minecraft_user_id = minecraft_user_id.as_hyphenated(); + + Ok(sqlx::query_as!( + Self, + "SELECT id AS 'id: Hyphenated' FROM default_minecraft_capes WHERE minecraft_user_uuid = ?", + minecraft_user_id + ) + .fetch_optional(&mut *db.acquire().await?) + .await?) + } + + pub async fn remove( + minecraft_user_id: Uuid, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result<()> { + let minecraft_user_id = minecraft_user_id.as_hyphenated(); + + sqlx::query!( + "DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid = ?", + minecraft_user_id + ) + .execute(&mut *db.acquire().await?) + .await?; + + Ok(()) + } +} + +/// Represents a custom skin for a Minecraft player. +#[derive(Debug, Clone)] +pub struct CustomMinecraftSkin { + /// The key for the texture skin, which is akin to a hash that identifies it. + pub texture_key: String, + /// The variant of the skin model. + pub variant: MinecraftSkinVariant, + /// The UUID of the cape that this skin uses, which should match one of the + /// cape UUIDs the player has in its profile. + /// + /// If `None`, the skin does not have an explicit cape set, and the default + /// cape for this player, if any, should be used. + pub cape_id: Option, +} + +impl CustomMinecraftSkin { + pub async fn add( + minecraft_user_id: Uuid, + texture_key: &str, + texture: &[u8], + variant: MinecraftSkinVariant, + cape_id: Option, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result<()> { + let minecraft_user_id = minecraft_user_id.as_hyphenated(); + let cape_id = cape_id.map(|id| id.hyphenated()); + + let mut transaction = db.begin().await?; + + sqlx::query!( + "INSERT OR REPLACE INTO custom_minecraft_skin_textures (texture_key, texture) VALUES (?, ?)", + texture_key, texture + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + "INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)", + minecraft_user_id, texture_key, variant, cape_id + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + Ok(()) + } + + pub async fn get_many( + minecraft_user_id: Uuid, + offset: u32, + count: u32, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result> { + let minecraft_user_id = minecraft_user_id.as_hyphenated(); + + Ok(stream::iter(sqlx::query!( + "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' \ + FROM custom_minecraft_skins \ + WHERE minecraft_user_uuid = ? \ + ORDER BY rowid ASC \ + LIMIT ? OFFSET ?", + minecraft_user_id, count, offset + ) + .fetch_all(&mut *db.acquire().await?) + .await?) + .map(|row| Self { + texture_key: row.texture_key, + variant: row.variant, + cape_id: row.cape_id.map(Uuid::from), + })) + } + + pub async fn get_all( + minecraft_user_id: Uuid, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result> { + // Limit ourselves to 2048 skins, so that memory usage even when storing base64 + // PNG data of a 64x64 texture with random pixels stays around ~150 MiB + Self::get_many(minecraft_user_id, 0, 2048, db).await + } + + pub async fn texture_blob( + &self, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result> { + Ok(sqlx::query_scalar!( + "SELECT texture FROM custom_minecraft_skin_textures WHERE texture_key = ?", + self.texture_key + ) + .fetch_one(&mut *db.acquire().await?) + .await?) + } + + pub async fn remove( + &self, + minecraft_user_id: Uuid, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result<()> { + let minecraft_user_id = minecraft_user_id.as_hyphenated(); + let cape_id = self.cape_id.map(|id| id.hyphenated()); + + sqlx::query!( + "DELETE FROM custom_minecraft_skins \ + WHERE minecraft_user_uuid = ? AND texture_key = ? AND variant = ? AND cape_id IS ?", + minecraft_user_id, self.texture_key, self.variant, cape_id + ) + .execute(&mut *db.acquire().await?) + .await?; + + Ok(()) + } +} diff --git a/packages/app-lib/src/state/minecraft_skins/mojang_api.rs b/packages/app-lib/src/state/minecraft_skins/mojang_api.rs new file mode 100644 index 000000000..49b5249ed --- /dev/null +++ b/packages/app-lib/src/state/minecraft_skins/mojang_api.rs @@ -0,0 +1,142 @@ +use std::{error::Error, sync::Arc, time::Instant}; + +use bytes::Bytes; +use futures::TryStream; +use reqwest::{Body, multipart::Part}; +use serde_json::json; +use uuid::Uuid; + +use super::MinecraftSkinVariant; +use crate::{ + ErrorKind, + data::Credentials, + state::{MinecraftProfile, PROFILE_CACHE, ProfileCacheEntry}, + util::fetch::REQWEST_CLIENT, +}; + +/// Provides operations for interacting with capes on a Minecraft player profile. +pub struct MinecraftCapeOperation; + +impl MinecraftCapeOperation { + pub async fn equip( + credentials: &Credentials, + cape_id: Uuid, + ) -> crate::Result<()> { + update_profile_cache_from_response( + REQWEST_CLIENT + .put("https://api.minecraftservices.com/minecraft/profile/capes/active") + .header("Content-Type", "application/json; charset=utf-8") + .header("Accept", "application/json") + .bearer_auth(&credentials.access_token) + .json(&json!({ + "capeId": cape_id.hyphenated(), + })) + .send() + .await + .and_then(|response| response.error_for_status())? + ) + .await; + + Ok(()) + } + + pub async fn unequip_any(credentials: &Credentials) -> crate::Result<()> { + update_profile_cache_from_response( + REQWEST_CLIENT + .delete("https://api.minecraftservices.com/minecraft/profile/capes/active") + .header("Accept", "application/json") + .bearer_auth(&credentials.access_token) + .send() + .await + .and_then(|response| response.error_for_status())? + ) + .await; + + Ok(()) + } +} + +/// Provides operations for interacting with skins on a Minecraft player profile. +pub struct MinecraftSkinOperation; + +impl MinecraftSkinOperation { + pub async fn equip( + credentials: &Credentials, + texture: TextureStream, + variant: MinecraftSkinVariant, + ) -> crate::Result<()> + where + TextureStream: TryStream + Send + 'static, + TextureStream::Error: Into>, + Bytes: From, + { + let form = reqwest::multipart::Form::new() + .text( + "variant", + match variant { + MinecraftSkinVariant::Slim => "slim", + MinecraftSkinVariant::Classic => "classic", + _ => { + return Err(ErrorKind::OtherError( + "Cannot equip skin of unknown model variant".into(), + ) + .into()); + } + }, + ) + .part( + "file", + Part::stream(Body::wrap_stream(texture)) + .mime_str("image/png")? + .file_name("skin.png"), + ); + + update_profile_cache_from_response( + REQWEST_CLIENT + .post( + "https://api.minecraftservices.com/minecraft/profile/skins", + ) + .header("Accept", "application/json") + .bearer_auth(&credentials.access_token) + .multipart(form) + .send() + .await + .and_then(|response| response.error_for_status())?, + ) + .await; + + Ok(()) + } + + pub async fn unequip_any(credentials: &Credentials) -> crate::Result<()> { + update_profile_cache_from_response( + REQWEST_CLIENT + .delete("https://api.minecraftservices.com/minecraft/profile/skins/active") + .header("Accept", "application/json") + .bearer_auth(&credentials.access_token) + .send() + .await + .and_then(|response| response.error_for_status())? + ) + .await; + + Ok(()) + } +} + +async fn update_profile_cache_from_response(response: reqwest::Response) { + let Some(mut profile) = response.json::().await.ok() + else { + tracing::warn!( + "Failed to parse player profile from skin or cape operation response, not updating profile cache" + ); + return; + }; + + profile.fetch_time = Some(Instant::now()); + + PROFILE_CACHE + .lock() + .await + .insert(profile.id, ProfileCacheEntry::Hit(Arc::new(profile))); +} diff --git a/packages/app-lib/src/state/mod.rs b/packages/app-lib/src/state/mod.rs index ec6d5426e..ab7a5e3e9 100644 --- a/packages/app-lib/src/state/mod.rs +++ b/packages/app-lib/src/state/mod.rs @@ -28,6 +28,8 @@ pub use self::discord::*; mod minecraft_auth; pub use self::minecraft_auth::*; +pub mod minecraft_skins; + mod cache; pub use self::cache::*; diff --git a/packages/app-lib/src/state/settings.rs b/packages/app-lib/src/state/settings.rs index 90d48028e..2615e150e 100644 --- a/packages/app-lib/src/state/settings.rs +++ b/packages/app-lib/src/state/settings.rs @@ -13,6 +13,7 @@ pub struct Settings { pub theme: Theme, pub default_page: DefaultPage, pub collapsed_navigation: bool, + pub hide_nametag_skins_page: bool, pub advanced_rendering: bool, pub native_decorations: bool, pub toggle_sidebar: bool, @@ -56,7 +57,7 @@ impl Settings { " SELECT max_concurrent_writes, max_concurrent_downloads, - theme, default_page, collapsed_navigation, advanced_rendering, native_decorations, + theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations, discord_rpc, developer_mode, telemetry, personalized_ads, onboarded, json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars, @@ -75,6 +76,7 @@ impl Settings { theme: Theme::from_string(&res.theme), default_page: DefaultPage::from_string(&res.default_page), collapsed_navigation: res.collapsed_navigation == 1, + hide_nametag_skins_page: res.hide_nametag_skins_page == 1, advanced_rendering: res.advanced_rendering == 1, native_decorations: res.native_decorations == 1, toggle_sidebar: res.toggle_sidebar == 1, @@ -167,7 +169,8 @@ impl Settings { migrated = $25, toggle_sidebar = $26, - feature_flags = $27 + feature_flags = $27, + hide_nametag_skins_page = $28 ", max_concurrent_writes, max_concurrent_downloads, @@ -195,7 +198,8 @@ impl Settings { self.prev_custom_dir, self.migrated, self.toggle_sidebar, - feature_flags + feature_flags, + self.hide_nametag_skins_page ) .execute(exec) .await?; diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts index c944c9c01..9a76fc2cf 100644 --- a/packages/assets/generated-icons.ts +++ b/packages/assets/generated-icons.ts @@ -21,6 +21,7 @@ import _BoxIcon from './icons/box.svg?component' import _BracesIcon from './icons/braces.svg?component' import _CalendarIcon from './icons/calendar.svg?component' import _CardIcon from './icons/card.svg?component' +import _ChangeSkinIcon from './icons/change-skin.svg?component' import _ChartIcon from './icons/chart.svg?component' import _CheckCheckIcon from './icons/check-check.svg?component' import _CheckCircleIcon from './icons/check-circle.svg?component' @@ -207,6 +208,7 @@ export const BoxIcon = _BoxIcon export const BracesIcon = _BracesIcon export const CalendarIcon = _CalendarIcon export const CardIcon = _CardIcon +export const ChangeSkinIcon = _ChangeSkinIcon export const ChartIcon = _ChartIcon export const CheckCheckIcon = _CheckCheckIcon export const CheckCircleIcon = _CheckCircleIcon diff --git a/packages/assets/icons/change-skin.svg b/packages/assets/icons/change-skin.svg new file mode 100644 index 000000000..762605150 --- /dev/null +++ b/packages/assets/icons/change-skin.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/assets/styles/variables.scss b/packages/assets/styles/variables.scss index 913d24fa2..863e9b151 100644 --- a/packages/assets/styles/variables.scss +++ b/packages/assets/styles/variables.scss @@ -68,6 +68,8 @@ --color-button-bg-selected: var(--color-brand); --color-button-text-selected: var(--color-accent-contrast); + --color-gradient-button-bg: linear-gradient(180deg, #f8f9fa 0%, #dce0e6 100%); + --loading-bar-gradient: linear-gradient(to right, var(--color-brand) 0%, #00af5c 100%); --color-platform-fabric: #8a7b71; @@ -186,6 +188,8 @@ html { --color-button-bg-selected: var(--color-brand-highlight); --color-button-text-selected: var(--color-brand); + --color-gradient-button-bg: linear-gradient(180deg, #3a3d47 0%, #33363d 100%); + --loading-bar-gradient: linear-gradient(to right, var(--color-brand) 0%, #1ffa9a 100%); --color-platform-fabric: #dbb69b; @@ -230,6 +234,8 @@ html { rgba(9, 18, 14, 0.6) 10%, rgba(19, 31, 23, 0.5) 100% ); + + --color-gradient-button-bg: linear-gradient(180deg, #1b1b20 0%, #25262b 100%); } .retro-mode { diff --git a/packages/blog/compiled/creator_updates_july_2025.ts b/packages/blog/compiled/creator_updates_july_2025.ts index 7b0eb8fa0..13eb8bf6d 100644 --- a/packages/blog/compiled/creator_updates_july_2025.ts +++ b/packages/blog/compiled/creator_updates_july_2025.ts @@ -3,7 +3,7 @@ export const article = { html: () => import(`./creator_updates_july_2025.content`).then((m) => m.html), title: 'Creator Updates, July 2025', summary: 'Addressing recent growth and growing pains that have been affecting creators.', - date: '2025-07-02T03:00:00.000Z', + date: '2025-07-02T04:20:00.000Z', slug: 'creator-updates-july-2025', thumbnail: false, } diff --git a/packages/ui/package.json b/packages/ui/package.json index 314dadf02..652d9f11c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -30,14 +30,19 @@ "@codemirror/view": "^6.22.1", "@modrinth/assets": "workspace:*", "@modrinth/utils": "workspace:*", + "@tresjs/cientos": "^4.3.0", + "@tresjs/core": "^4.3.4", "@types/markdown-it": "^14.1.1", + "@types/three": "^0.172.0", "@vintl/how-ago": "^3.0.1", + "@vueuse/core": "^11.1.0", "apexcharts": "^3.44.0", "dayjs": "^1.11.10", "floating-vue": "^5.2.2", "highlight.js": "^11.9.0", "markdown-it": "^13.0.2", "qrcode.vue": "^3.4.1", + "three": "^0.172.0", "vue-multiselect": "3.0.0", "vue-select": "4.0.0-beta.6", "vue-typed-virtual-list": "^1.0.10", diff --git a/packages/ui/src/components/base/ScrollablePanel.vue b/packages/ui/src/components/base/ScrollablePanel.vue index 35766efd4..15f7e5355 100644 --- a/packages/ui/src/components/base/ScrollablePanel.vue +++ b/packages/ui/src/components/base/ScrollablePanel.vue @@ -55,6 +55,7 @@ onUnmounted(() => { } }) function updateFade(scrollTop, offsetHeight, scrollHeight) { + console.log(scrollTop, offsetHeight, scrollHeight) scrollableAtBottom.value = Math.ceil(scrollTop + offsetHeight) >= scrollHeight scrollableAtTop.value = scrollTop <= 0 } @@ -64,6 +65,18 @@ function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) { diff --git a/packages/ui/src/components/skin/CapeLikeTextButton.vue b/packages/ui/src/components/skin/CapeLikeTextButton.vue new file mode 100644 index 000000000..9d8ebbd6f --- /dev/null +++ b/packages/ui/src/components/skin/CapeLikeTextButton.vue @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui/src/components/skin/SkinButton.vue b/packages/ui/src/components/skin/SkinButton.vue new file mode 100644 index 000000000..763627e52 --- /dev/null +++ b/packages/ui/src/components/skin/SkinButton.vue @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui/src/components/skin/SkinLikeTextButton.vue b/packages/ui/src/components/skin/SkinLikeTextButton.vue new file mode 100644 index 000000000..de174234a --- /dev/null +++ b/packages/ui/src/components/skin/SkinLikeTextButton.vue @@ -0,0 +1,67 @@ + + + + + emit('click', e)" + > + + + + + + + + + + + + + diff --git a/packages/ui/src/components/skin/SkinPreviewRenderer.vue b/packages/ui/src/components/skin/SkinPreviewRenderer.vue new file mode 100644 index 000000000..26bb188af --- /dev/null +++ b/packages/ui/src/components/skin/SkinPreviewRenderer.vue @@ -0,0 +1,666 @@ + + + + + Drag to rotate + + + + + + + {{ nametagText }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Loading... + + + + + + + diff --git a/packages/ui/src/composables/dynamic-font-size.ts b/packages/ui/src/composables/dynamic-font-size.ts new file mode 100644 index 000000000..af1cf9ed3 --- /dev/null +++ b/packages/ui/src/composables/dynamic-font-size.ts @@ -0,0 +1,117 @@ +import { computed, onMounted, onUnmounted, type Ref } from 'vue' +import { useElementSize } from '@vueuse/core' + +export interface DynamicFontSizeOptions { + containerElement: Ref + text: Ref + baseFontSize?: number + minFontSize?: number + maxFontSize?: number + availableWidthRatio?: number + maxContainerWidth?: number + padding?: number + fontFamily?: string + fontWeight?: string | number +} + +export function useDynamicFontSize(options: DynamicFontSizeOptions) { + const { + containerElement, + text, + baseFontSize = 1.25, + minFontSize = 0.75, + maxFontSize = 2, + availableWidthRatio = 0.9, + maxContainerWidth = 400, + padding = 24, + fontFamily = 'inherit', + fontWeight = 'inherit', + } = options + + const { width: containerWidth } = useElementSize(containerElement) + let measurementElement: HTMLElement | null = null + + const createMeasurementElement = () => { + if (measurementElement) return measurementElement + + measurementElement = document.createElement('div') + measurementElement.style.cssText = ` + position: absolute; + top: -9999px; + left: -9999px; + opacity: 0; + pointer-events: none; + white-space: nowrap; + font-family: ${fontFamily}; + font-weight: ${fontWeight}; + ` + measurementElement.setAttribute('aria-hidden', 'true') + document.body.appendChild(measurementElement) + + return measurementElement + } + + const cleanupMeasurementElement = () => { + if (measurementElement?.parentNode) { + measurementElement.parentNode.removeChild(measurementElement) + measurementElement = null + } + } + + const measureTextWidth = (textContent: string, fontSize: number): number => { + if (!textContent) return 0 + + const element = createMeasurementElement() + element.style.fontSize = `${fontSize}rem` + element.textContent = textContent + + return element.getBoundingClientRect().width + } + + const findOptimalFontSize = (textContent: string, availableWidth: number): number => { + let low = minFontSize + let high = maxFontSize + let bestSize = minFontSize + + const maxWidth = measureTextWidth(textContent, maxFontSize) + if (maxWidth <= availableWidth) return maxFontSize + + for (let i = 0; i < 8; i++) { + const mid = (low + high) / 2 + const width = measureTextWidth(textContent, mid) + + if (width <= availableWidth) { + bestSize = mid + low = mid + } else { + high = mid + } + + if (high - low < 0.01) break + } + + return Math.max(bestSize, minFontSize) + } + + const fontSize = computed(() => { + if (!text.value || !containerWidth.value) return `${baseFontSize}rem` + + const availableWidth = + Math.min(containerWidth.value * availableWidthRatio, maxContainerWidth) - padding + + const baseWidth = measureTextWidth(text.value, baseFontSize) + if (baseWidth <= availableWidth) return `${baseFontSize}rem` + + const optimalSize = findOptimalFontSize(text.value, availableWidth) + return `${optimalSize}rem` + }) + + onMounted(createMeasurementElement) + onUnmounted(cleanupMeasurementElement) + + return { + fontSize, + containerWidth, + cleanup: cleanupMeasurementElement, + } +} diff --git a/packages/ui/src/composables/index.ts b/packages/ui/src/composables/index.ts index de29c5771..a84dc7fa2 100644 --- a/packages/ui/src/composables/index.ts +++ b/packages/ui/src/composables/index.ts @@ -1 +1,2 @@ export * from './how-ago' +export * from './dynamic-font-size' diff --git a/packages/ui/src/vue-shims.d.ts b/packages/ui/src/vue-shims.d.ts index 41c2ecce6..aae8c737a 100644 --- a/packages/ui/src/vue-shims.d.ts +++ b/packages/ui/src/vue-shims.d.ts @@ -4,3 +4,8 @@ declare module '*.vue' { const component: ReturnType export default component } + +declare module '*.glsl' { + const value: string + export default value +} diff --git a/packages/utils/index.ts b/packages/utils/index.ts index dc3e767c9..9369aa69f 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -8,3 +8,4 @@ export * from './types' export * from './users' export * from './utils' export * from './servers' +export * from './three/skin-rendering' diff --git a/packages/utils/package.json b/packages/utils/package.json index 5e6d89c54..d603ddd2c 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -20,10 +20,12 @@ "@codemirror/state": "^6.3.2", "@codemirror/view": "^6.22.1", "@types/markdown-it": "^14.1.1", + "@types/three": "^0.172.0", "dayjs": "^1.11.10", "highlight.js": "^11.9.0", "markdown-it": "^14.1.0", "ofetch": "^1.3.4", + "three": "^0.172.0", "xss": "^1.0.14" } } diff --git a/packages/utils/three/skin-rendering.ts b/packages/utils/three/skin-rendering.ts new file mode 100644 index 000000000..1d83db586 --- /dev/null +++ b/packages/utils/three/skin-rendering.ts @@ -0,0 +1,207 @@ +import * as THREE from 'three' +import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js' +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' + +export interface SkinRendererConfig { + textureColorSpace?: THREE.ColorSpace + textureFlipY?: boolean + textureMagFilter?: THREE.MagnificationTextureFilter + textureMinFilter?: THREE.MinificationTextureFilter +} + +const modelCache: Map = new Map() +const textureCache: Map = new Map() + +export async function loadModel(modelUrl: string): Promise { + if (modelCache.has(modelUrl)) { + return modelCache.get(modelUrl)! + } + + const loader = new GLTFLoader() + return new Promise((resolve, reject) => { + loader.load( + modelUrl, + (gltf) => { + modelCache.set(modelUrl, gltf) + resolve(gltf) + }, + undefined, + reject, + ) + }) +} + +export async function loadTexture( + textureUrl: string, + config: SkinRendererConfig = {}, +): Promise { + const cacheKey = `${textureUrl}_${JSON.stringify(config)}` + + if (textureCache.has(cacheKey)) { + return textureCache.get(cacheKey)! + } + + return new Promise((resolve) => { + const textureLoader = new THREE.TextureLoader() + textureLoader.load(textureUrl, (texture) => { + texture.colorSpace = config.textureColorSpace ?? THREE.SRGBColorSpace + texture.flipY = config.textureFlipY ?? false + texture.magFilter = config.textureMagFilter ?? THREE.NearestFilter + texture.minFilter = config.textureMinFilter ?? THREE.NearestFilter + + textureCache.set(cacheKey, texture) + resolve(texture) + }) + }) +} + +export function applyTexture(model: THREE.Object3D, texture: THREE.Texture): void { + model.traverse((child) => { + if ((child as THREE.Mesh).isMesh) { + const mesh = child as THREE.Mesh + + // Skip cape meshes + if (mesh.name === 'Cape') return + + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + + materials.forEach((mat: THREE.Material) => { + if (mat instanceof THREE.MeshStandardMaterial) { + mat.map = texture + mat.metalness = 0 + mat.color.set(0xffffff) + mat.toneMapped = false + mat.flatShading = true + mat.roughness = 1 + mat.needsUpdate = true + mat.depthTest = true + mat.side = THREE.DoubleSide + mat.alphaTest = 0.1 + mat.depthWrite = true + } + }) + } + }) +} + +export function applyCapeTexture( + model: THREE.Object3D, + texture: THREE.Texture | null, + transparentTexture?: THREE.Texture, +): void { + model.traverse((child) => { + if ((child as THREE.Mesh).isMesh) { + const mesh = child as THREE.Mesh + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + + materials.forEach((mat: THREE.Material) => { + if (mat instanceof THREE.MeshStandardMaterial) { + mat.map = texture || transparentTexture || null + mat.transparent = transparentTexture ? true : false + mat.metalness = 0 + mat.color.set(0xffffff) + mat.toneMapped = false + mat.flatShading = true + mat.roughness = 1 + mat.needsUpdate = true + mat.depthTest = true + mat.depthWrite = true + mat.side = THREE.DoubleSide + mat.alphaTest = 0.1 + } + }) + } + }) +} + +export function attachCapeToBody( + bodyNode: THREE.Object3D, + capeModel: THREE.Object3D, + position = { x: 0, y: -1, z: 0.01 }, + rotation = { x: 0, y: Math.PI / 2, z: 0 }, +): void { + if (!bodyNode || !capeModel) return + + if (capeModel.parent) { + capeModel.parent.remove(capeModel) + } + + capeModel.position.set(position.x, position.y, position.z) + capeModel.rotation.set(rotation.x, rotation.y, rotation.z) + bodyNode.add(capeModel) +} + +export function findBodyNode(model: THREE.Object3D): THREE.Object3D | null { + let bodyNode: THREE.Object3D | null = null + + model.traverse((node) => { + if (node.name === 'Body') { + bodyNode = node + } + }) + + return bodyNode +} + +export function createTransparentTexture(): THREE.Texture { + const canvas = document.createElement('canvas') + canvas.width = canvas.height = 1 + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D + ctx.clearRect(0, 0, 1, 1) + + const texture = new THREE.CanvasTexture(canvas) + texture.needsUpdate = true + texture.colorSpace = THREE.SRGBColorSpace + texture.flipY = false + texture.magFilter = THREE.NearestFilter + texture.minFilter = THREE.NearestFilter + + return texture +} + +export async function setupSkinModel( + modelUrl: string, + textureUrl: string, + capeModelUrl?: string, + capeTextureUrl?: string, + config: SkinRendererConfig = {}, +): Promise<{ + model: THREE.Object3D + bodyNode: THREE.Object3D | null + capeModel: THREE.Object3D | null +}> { + // Load model and texture in parallel + const [gltf, texture] = await Promise.all([loadModel(modelUrl), loadTexture(textureUrl, config)]) + + const model = gltf.scene.clone() + applyTexture(model, texture) + + const bodyNode = findBodyNode(model) + let capeModel: THREE.Object3D | null = null + + // Load cape if provided + if (capeModelUrl && capeTextureUrl) { + const [capeGltf, capeTexture] = await Promise.all([ + loadModel(capeModelUrl), + loadTexture(capeTextureUrl, config), + ]) + + capeModel = capeGltf.scene.clone() + applyCapeTexture(capeModel, capeTexture) + + if (bodyNode && capeModel) { + attachCapeToBody(bodyNode, capeModel) + } + } + + return { model, bodyNode, capeModel } +} + +export function disposeCaches(): void { + Array.from(textureCache.values()).forEach((texture) => { + texture.dispose() + }) + + textureCache.clear() + modelCache.clear() +} diff --git a/packages/utils/utils.ts b/packages/utils/utils.ts index dff3421da..937827c85 100644 --- a/packages/utils/utils.ts +++ b/packages/utils/utils.ts @@ -368,3 +368,8 @@ export function getPingLevel(ping: number) { return 1 } } + +export function arrayBufferToBase64(buffer: Uint8Array | ArrayBuffer): string { + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer) + return btoa(String.fromCharCode(...bytes)) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8341952db..f0beebf78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,9 +80,15 @@ importers: '@tauri-apps/plugin-window-state': specifier: ^2.2.2 version: 2.2.2 + '@types/three': + specifier: ^0.172.0 + version: 0.172.0 '@vintl/vintl': specifier: ^4.4.1 version: 4.4.1(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)) + '@vueuse/core': + specifier: ^11.1.0 + version: 11.1.0(vue@3.5.13(typescript@5.5.4)) dayjs: specifier: ^1.11.10 version: 1.11.11 @@ -98,6 +104,9 @@ importers: posthog-js: specifier: ^1.158.2 version: 1.158.2 + three: + specifier: ^0.172.0 + version: 0.172.0 vite-svg-loader: specifier: ^5.1.0 version: 5.1.0(vue@3.5.13(typescript@5.5.4)) @@ -472,12 +481,24 @@ importers: '@modrinth/utils': specifier: workspace:* version: link:../utils + '@tresjs/cientos': + specifier: ^4.3.0 + version: 4.3.1(@tresjs/core@4.3.6(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)))(@types/three@0.172.0)(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)) + '@tresjs/core': + specifier: ^4.3.4 + version: 4.3.6(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)) '@types/markdown-it': specifier: ^14.1.1 version: 14.1.1 + '@types/three': + specifier: ^0.172.0 + version: 0.172.0 '@vintl/how-ago': specifier: ^3.0.1 version: 3.0.1(@formatjs/intl@2.10.4(typescript@5.5.4)) + '@vueuse/core': + specifier: ^11.1.0 + version: 11.1.0(vue@3.5.13(typescript@5.5.4)) apexcharts: specifier: ^3.44.0 version: 3.49.2 @@ -496,6 +517,9 @@ importers: qrcode.vue: specifier: ^3.4.1 version: 3.4.1(vue@3.5.13(typescript@5.5.4)) + three: + specifier: ^0.172.0 + version: 0.172.0 vue-multiselect: specifier: 3.0.0 version: 3.0.0 @@ -566,6 +590,9 @@ importers: '@types/markdown-it': specifier: ^14.1.1 version: 14.1.1 + '@types/three': + specifier: ^0.172.0 + version: 0.172.0 dayjs: specifier: ^1.11.10 version: 1.11.11 @@ -578,6 +605,9 @@ importers: ofetch: specifier: ^1.3.4 version: 1.4.1 + three: + specifier: ^0.172.0 + version: 0.172.0 xss: specifier: ^1.0.14 version: 1.0.15 @@ -598,6 +628,9 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@alvarosabu/utils@3.2.0': + resolution: {integrity: sha512-aoGWRfaQjOo9TUwrBA6W0zwTHktgrXy69GIFNILT4gHsqscw6+X8P6uoSlZVQFr887SPm8x3aDin5EBVq8y4pw==} + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -2516,6 +2549,19 @@ packages: '@tauri-apps/plugin-window-state@2.2.2': resolution: {integrity: sha512-7pFwmMtGhhhE/WgmM7PUrj0BSSWVAQMfDdYbRalphIqqF1tWBvxtlxclx8bTutpXHLJTQoCpIeWtBEIXsoAlGw==} + '@tresjs/cientos@4.3.1': + resolution: {integrity: sha512-3qp6lEtMrFdhxDuASP1Sz/hEi8+xcEpM6Vd6uDJysCh4uRAzyJLlBSbPoR7gVjN12wrhwJIF1AfYEFz/Vhz5ZQ==} + peerDependencies: + '@tresjs/core': '>=4.2.1' + three: '>=0.133' + vue: '>=3.3' + + '@tresjs/core@4.3.6': + resolution: {integrity: sha512-CCk4+jwbiTl7Hj3REZqweglUQQdA3cF29TqJ4dEWunaBPyfsAGLTlJExK5lGIS10ptJkr8DqPvHQT41iTIb0Yg==} + peerDependencies: + three: '>=0.133' + vue: '>=3.4' + '@trysound/sax@0.2.0': resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} @@ -2535,6 +2581,9 @@ packages: '@types/dompurify@3.0.5': resolution: {integrity: sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==} + '@types/draco3d@1.4.10': + resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -2607,6 +2656,9 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/offscreencanvas@2019.7.3': + resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} + '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} @@ -2640,6 +2692,9 @@ packages: '@types/web-bluetooth@0.0.20': resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@types/webxr@0.5.21': resolution: {integrity: sha512-geZIAtLzjGmgY2JUi6VxXdCrTb99A7yP49lxLr2Nm/uIK0PkkxcEi4OGhoGDO4pxCf3JwGz2GiJL2Ej4K2bKaA==} @@ -3037,18 +3092,27 @@ packages: '@vueuse/core@11.1.0': resolution: {integrity: sha512-P6dk79QYA6sKQnghrUz/1tHi0n9mrb/iO1WTMk/ElLmTyNqgDeSZ3wcDf6fRBGzRJbeG1dxzEOvLENMjr+E3fg==} + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + '@vueuse/core@9.13.0': resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==} '@vueuse/metadata@11.1.0': resolution: {integrity: sha512-l9Q502TBTaPYGanl1G+hPgd3QX5s4CGnpXriVBR5fEZ/goI6fvDaVmIl3Td8oKFurOxTmbXvBPSsgrd6eu6HYg==} + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + '@vueuse/metadata@9.13.0': resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==} '@vueuse/shared@11.1.0': resolution: {integrity: sha512-YUtIpY122q7osj+zsNMFAfMTubGz0sn5QzE5gPzAIiCmtt2ha3uQUY1+JPyL4gRCTsLPX82Y9brNbo/aqlA91w==} + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + '@vueuse/shared@9.13.0': resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==} @@ -3464,6 +3528,11 @@ packages: resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} engines: {node: '>=16'} + camera-controls@2.10.1: + resolution: {integrity: sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==} + peerDependencies: + three: '>=0.126.1' + caniuse-api@3.0.0: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} @@ -4014,6 +4083,9 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} + draco3d@1.5.7: + resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==} + dset@3.1.4: resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} engines: {node: '>=4'} @@ -4559,6 +4631,9 @@ packages: fflate@0.4.8: resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + fflate@0.6.10: + resolution: {integrity: sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==} + fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} @@ -4788,6 +4863,15 @@ packages: resolution: {integrity: sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==} engines: {node: '>=18'} + glsl-token-functions@1.0.1: + resolution: {integrity: sha512-EigGhp1g+aUVeUNY7H1o5tL/bnwIB3/FcRREPr2E7Du+/UDXN24hDkaZ3e4aWHDjHr9lJ6YHXMISkwhUYg9UOg==} + + glsl-token-string@1.0.1: + resolution: {integrity: sha512-1mtQ47Uxd47wrovl+T6RshKGkRRCYWhnELmkEcUAPALWGTFe2XZpH3r45XAwL2B6v+l0KNsCnoaZCSnhzKEksg==} + + glsl-tokenizer@2.1.5: + resolution: {integrity: sha512-XSZEJ/i4dmz3Pmbnpsy3cKh7cotvFlBiZnDOwnj/05EwNp2XrhQ4XKJxT7/pDt4kp4YcpRSKz8eTV7S+mwV6MA==} + gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} @@ -5243,6 +5327,9 @@ packages: resolution: {integrity: sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==} engines: {node: '>=18'} + isarray@0.0.1: + resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -6482,6 +6569,9 @@ packages: posthog-js@1.158.2: resolution: {integrity: sha512-ovb7GHHRNDf6vmuL+8lbDukewzDzQlLZXg3d475hrfHSBgidYeTxtLGtoBcUz4x6558BLDFjnSip+f3m4rV9LA==} + potpack@1.0.2: + resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} + preact@10.23.2: resolution: {integrity: sha512-kKYfePf9rzKnxOAKDpsWhg/ysrHPqT+yQ7UW4JjdnqjFIeNUnNcEJvhuA8fDenxAGWzUqtd51DfVg7xp/8T9NA==} @@ -6640,6 +6730,9 @@ packages: resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==} engines: {node: '>=8'} + readable-stream@1.0.34: + resolution: {integrity: sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==} + readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -7109,6 +7202,15 @@ packages: '@astrojs/starlight': '>=0.30.0' astro: '>=5.1.5' + stats-gl@2.4.2: + resolution: {integrity: sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==} + peerDependencies: + '@types/three': '*' + three: '*' + + stats.js@0.17.0: + resolution: {integrity: sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==} + statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} @@ -7148,6 +7250,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@0.10.31: + resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -7345,9 +7450,29 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + three-custom-shader-material@5.4.0: + resolution: {integrity: sha512-Yn1lFlKOk3Vul3npEGAmbbFUZ5S2+yjPgM2XqJEZEYRSUUH2vk+WVYrtTB6Bcq15wa7hLUXAKoctAvbRmBmbYA==} + peerDependencies: + '@react-three/fiber': '>=8.0' + react: '>=18.0' + three: '>=0.154' + peerDependenciesMeta: + '@react-three/fiber': + optional: true + react: + optional: true + + three-stdlib@2.36.0: + resolution: {integrity: sha512-kv0Byb++AXztEGsULgMAs8U2jgUdz6HPpAB/wDJnLiLlaWQX2APHhiTJIN7rqW+Of0eRgcp7jn05U1BsCP3xBA==} + peerDependencies: + three: '>=0.128.0' + three@0.172.0: resolution: {integrity: sha512-6HMgMlzU97MsV7D/tY8Va38b83kz8YJX+BefKjspMNAv0Vx6dxMogHOrnRl/sbMIs3BPUKijPqDqJ/+UwJbIow==} + through2@0.6.5: + resolution: {integrity: sha512-RkK/CCESdTKQZHdmKICijdKKsCRVHs5KsLZ6pACAmF/1GPUQhonHSXWNERctxEp7RmvjdNbZTL5z9V7nSCXKcg==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -8281,6 +8406,10 @@ packages: engines: {node: '>= 0.10.0'} hasBin: true + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} @@ -8370,6 +8499,8 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@alvarosabu/utils@3.2.0': {} + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.5 @@ -9832,7 +9963,7 @@ snapshots: '@nuxtjs/eslint-config-typescript@12.1.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4)': dependencies: - '@nuxtjs/eslint-config': 12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.2)) + '@nuxtjs/eslint-config': 12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2)) '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) '@typescript-eslint/parser': 6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) eslint: 9.13.0(jiti@2.4.2) @@ -9845,10 +9976,10 @@ snapshots: - supports-color - typescript - '@nuxtjs/eslint-config@12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.2))': + '@nuxtjs/eslint-config@12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2))': dependencies: eslint: 9.13.0(jiti@2.4.2) - eslint-config-standard: 17.1.0(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0(eslint@9.13.0(jiti@2.4.2)))(eslint-plugin-promise@6.4.0(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2)) + eslint-config-standard: 17.1.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.2)))(eslint-plugin-n@15.7.0(eslint@9.13.0(jiti@2.4.2)))(eslint-plugin-promise@6.4.0(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2)) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.2)) eslint-plugin-n: 15.7.0(eslint@9.13.0(jiti@2.4.2)) eslint-plugin-node: 11.1.0(eslint@9.13.0(jiti@2.4.2)) @@ -10395,6 +10526,33 @@ snapshots: dependencies: '@tauri-apps/api': 2.5.0 + '@tresjs/cientos@4.3.1(@tresjs/core@4.3.6(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)))(@types/three@0.172.0)(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))': + dependencies: + '@tresjs/core': 4.3.6(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)) + '@vueuse/core': 12.8.2(typescript@5.5.4) + camera-controls: 2.10.1(three@0.172.0) + stats-gl: 2.4.2(@types/three@0.172.0)(three@0.172.0) + stats.js: 0.17.0 + three: 0.172.0 + three-custom-shader-material: 5.4.0(three@0.172.0) + three-stdlib: 2.36.0(three@0.172.0) + vue: 3.5.13(typescript@5.5.4) + transitivePeerDependencies: + - '@react-three/fiber' + - '@types/three' + - react + - typescript + + '@tresjs/core@4.3.6(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))': + dependencies: + '@alvarosabu/utils': 3.2.0 + '@vue/devtools-api': 6.6.4 + '@vueuse/core': 12.8.2(typescript@5.5.4) + three: 0.172.0 + vue: 3.5.13(typescript@5.5.4) + transitivePeerDependencies: + - typescript + '@trysound/sax@0.2.0': {} '@tweenjs/tween.js@23.1.3': {} @@ -10413,6 +10571,8 @@ snapshots: dependencies: '@types/trusted-types': 2.0.7 + '@types/draco3d@1.4.10': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -10490,6 +10650,8 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/offscreencanvas@2019.7.3': {} + '@types/resolve@1.20.2': {} '@types/rss@0.0.32': {} @@ -10521,6 +10683,8 @@ snapshots: '@types/web-bluetooth@0.0.20': {} + '@types/web-bluetooth@0.0.21': {} + '@types/webxr@0.5.21': {} '@types/xml2js@0.4.14': @@ -11182,6 +11346,15 @@ snapshots: - '@vue/composition-api' - vue + '@vueuse/core@12.8.2(typescript@5.5.4)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2(typescript@5.5.4) + vue: 3.5.13(typescript@5.5.4) + transitivePeerDependencies: + - typescript + '@vueuse/core@9.13.0(vue@3.5.13(typescript@5.5.4))': dependencies: '@types/web-bluetooth': 0.0.16 @@ -11194,6 +11367,8 @@ snapshots: '@vueuse/metadata@11.1.0': {} + '@vueuse/metadata@12.8.2': {} + '@vueuse/metadata@9.13.0': {} '@vueuse/shared@11.1.0(vue@3.5.13(typescript@5.5.4))': @@ -11203,6 +11378,12 @@ snapshots: - '@vue/composition-api' - vue + '@vueuse/shared@12.8.2(typescript@5.5.4)': + dependencies: + vue: 3.5.13(typescript@5.5.4) + transitivePeerDependencies: + - typescript + '@vueuse/shared@9.13.0(vue@3.5.13(typescript@5.5.4))': dependencies: vue-demi: 0.14.10(vue@3.5.13(typescript@5.5.4)) @@ -11807,6 +11988,10 @@ snapshots: camelcase@8.0.0: {} + camera-controls@2.10.1(three@0.172.0): + dependencies: + three: 0.172.0 + caniuse-api@3.0.0: dependencies: browserslist: 4.24.2 @@ -12272,6 +12457,8 @@ snapshots: dotenv@16.6.1: optional: true + draco3d@1.5.7: {} + dset@3.1.4: {} dunder-proto@1.0.1: @@ -12566,10 +12753,10 @@ snapshots: dependencies: eslint: 9.13.0(jiti@2.4.2) - eslint-config-standard@17.1.0(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0(eslint@9.13.0(jiti@2.4.2)))(eslint-plugin-promise@6.4.0(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2)): + eslint-config-standard@17.1.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.2)))(eslint-plugin-n@15.7.0(eslint@9.13.0(jiti@2.4.2)))(eslint-plugin-promise@6.4.0(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2)): dependencies: eslint: 9.13.0(jiti@2.4.2) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.2)) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.2)) eslint-plugin-n: 15.7.0(eslint@9.13.0(jiti@2.4.2)) eslint-plugin-promise: 6.4.0(eslint@9.13.0(jiti@2.4.2)) @@ -12595,7 +12782,7 @@ snapshots: debug: 4.4.0(supports-color@9.4.0) enhanced-resolve: 5.17.1 eslint: 9.13.0(jiti@2.4.2) - eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.2)) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2)) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.2)) fast-glob: 3.3.2 get-tsconfig: 4.7.5 @@ -12607,7 +12794,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.2)): + eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2)): dependencies: debug: 3.2.7 optionalDependencies: @@ -13156,6 +13343,8 @@ snapshots: fflate@0.4.8: {} + fflate@0.6.10: {} + fflate@0.8.2: {} file-entry-cache@6.0.1: @@ -13423,6 +13612,14 @@ snapshots: slash: 5.1.0 unicorn-magic: 0.1.0 + glsl-token-functions@1.0.1: {} + + glsl-token-string@1.0.1: {} + + glsl-tokenizer@2.1.5: + dependencies: + through2: 0.6.5 + gopd@1.0.1: dependencies: get-intrinsic: 1.2.4 @@ -13996,6 +14193,8 @@ snapshots: dependencies: system-architecture: 0.1.0 + isarray@0.0.1: {} + isarray@1.0.0: {} isarray@2.0.5: {} @@ -15686,6 +15885,8 @@ snapshots: preact: 10.23.2 web-vitals: 4.2.3 + potpack@1.0.2: {} + preact@10.23.2: {} preferred-pm@4.1.1: @@ -15782,6 +15983,13 @@ snapshots: parse-json: 5.2.0 type-fest: 0.6.0 + readable-stream@1.0.34: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 0.0.1 + string_decoder: 0.10.31 + readable-stream@2.3.8(patch_hash=h52dazg37p4h3yox67pw36akse): dependencies: core-util-is: 1.0.3 @@ -16458,6 +16666,13 @@ snapshots: transitivePeerDependencies: - openapi-types + stats-gl@2.4.2(@types/three@0.172.0)(three@0.172.0): + dependencies: + '@types/three': 0.172.0 + three: 0.172.0 + + stats.js@0.17.0: {} + statuses@2.0.1: {} std-env@3.8.0: {} @@ -16512,6 +16727,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.0.0 + string_decoder@0.10.31: {} + string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -16733,8 +16950,31 @@ snapshots: dependencies: any-promise: 1.3.0 + three-custom-shader-material@5.4.0(three@0.172.0): + dependencies: + glsl-token-functions: 1.0.1 + glsl-token-string: 1.0.1 + glsl-tokenizer: 2.1.5 + object-hash: 3.0.0 + three: 0.172.0 + + three-stdlib@2.36.0(three@0.172.0): + dependencies: + '@types/draco3d': 1.4.10 + '@types/offscreencanvas': 2019.7.3 + '@types/webxr': 0.5.21 + draco3d: 1.5.7 + fflate: 0.6.10 + potpack: 1.0.2 + three: 0.172.0 + three@0.172.0: {} + through2@0.6.5: + dependencies: + readable-stream: 1.0.34 + xtend: 4.0.2 + tiny-invariant@1.3.3: {} tinyexec@0.3.1: {} @@ -17675,6 +17915,8 @@ snapshots: commander: 2.20.3 cssfilter: 0.0.10 + xtend@4.0.2: {} + xxhash-wasm@1.1.0: {} y18n@5.0.8: {}
+ Select skin texture file +
+ Drag and drop or click here to browse +
+ Please sign into your Minecraft account to use the skin management features of the + Modrinth app. +