feat(app): skins frontend (#3657)

* chore: typo fix and formatting tidyups

* refactor(theseus): extend auth subsystem to fetch complete user profiles

* chore: fix new `prettier` lints

* chore: document differences between similar `Credentials` methods

* chore: remove dead `profile_run_credentials` plugin command

* feat(app): skin selector backend

* enh(app/skin-selector): better DB intension through deferred FKs, further PNG validations

* chore: fix comment typo spotted by Copilot

* fix: less racy auth token refresh logic

This may help with issues reported by users where the access token is
invalid and can't be used to join servers over long periods of time.

* tweak(app-lib): improve consistency of skin field serialization case

* fix(app-lib/minecraft_skins): fix custom skin removal from DB not working

* Begin skins frontend

* Cape preview

* feat: start on SkinPreviewRenderer

* feat: setting for nametag

* feat: hide nametag setting (sql)

* fix: positioning of meshes

* fix: lighting

* fix: allow dragging off-bounds

* fix: better color mapping

* feat: hide nametag setting (impl)

* feat: Start on edit modal + cape button cleanup + renderer fixes

* feat: Finish new skin modal

* feat: finish cape modal

* feat: skin rendering on load

* fix: logic for Skins.vue

* fix: types

* fix: types (for modal + renderer)

* feat: Editing?

* fix: renderer not updating variant

* fix: mojang username not modrinth username

* feat: batched skin rendering - remove vzge references (apart from capes, wip)

* feat: fix sizing on SkinButton and SkinLikeButton, also implement bust positioning

* feat: capes in preview renderer & baked renders

* fix: lint fixes

* refactor: Start on cleanup and polish

* fix: hide error notification when logged out

* revert: .gltf formatting

* chore(app-frontend): fix typos

* fix(app-lib): delay account skin data deletion to next reboot

This gives users an opportunity to not unexpectedly lose skin data in
case they log off on accident.

* fix: login button & provide/inject AccountsCard

* polish: skin buttons

* fix: imports

* polish: use figma values

* polish: tweak underneath shadow

* polish: cursor grab

* polish: remove green bg from CapeLikeTextButton when selected.

* polish: modal tweaks

* polish: grid tweaks + start on upload skin modal

* polish: drag and drop file flow

* polish: button positioning in SkinButton

* fix: lint issues

* polish: deduplicate model+cape stuff and fix layout

* fix: lint issues

* fix: camel case requirement for make-default

* polish: use indexed db to persist skin previews

* fix: lint issues

* polish: add skin icon sizing

* polish: theme fixes

* feat: animation system for skin preview renderer

* feat(app/minecraft_skins): save current custom external skin when equipping skins

* fix: cape button & dynamic nametag sizing

* feat(theseus): add `normalize_skin_texture` Tauri command

This command lets the app frontend opt in to normalizing the texture of
any skin, which may be in either the legacy 64x32 or newer 64x64 format,
to the newer 64x64 format for display purposes.

* chore: Rust build fixes

* feat: start impl of skin normalization on frontend

* feat(theseus): change parameter type of `normalize_skin_texture` Tauri command

* fix: normalization

* fix(theseus): make new `normalize_skin_texture` command usable

* feat: finish normalization impl

* fix: vueuse issue

* fix: use optimistic approach when changing skins/capes.

* fix: nametag cleanup + scroll fix

* fix: edit modal computedAsync not fast enough for skin preview renderer

* feat: classic player model animations

* chore: fix new Clippy lint

* fix(app-lib): actually delete custom skins with no cape overrides

* fix(app-lib): handle repeated addition of the same skin properly

* refactor(app-lib): simplify DB connection logic a little

* fix: various improvements

* feat: slim animations

* fix: z-fighting on models

* fix: shading + lighting improvements

* fix: shadows

* fix: polish

* fix: polish

* fix: accounts card not having the right head

* fix: lint issues

* fix: build issue

* feat: drag and drop func

* fix: temp disable drag and drop in the modal

* Revert "fix: temp disable drag and drop in the modal"

This reverts commit 33500c564e3f85e6c0a2e83dd9700deda892004d.

* fix: drag and drop working

* fix: lint

* fix: better media queries

* feat(app/skins): revert current custom external skin storing on equip

This reverts commit 0155262ddd081c8677654619a09e814088fdd8b0.

* regen pnpm lock

* pnpm fix

* Make default capes a little more clear

* Lint

---------

Co-authored-by: Alejandro González <me@alegon.dev>
Co-authored-by: Prospector <prospectordev@gmail.com>
This commit is contained in:
IMB11 2025-07-02 21:32:15 +01:00 committed by GitHub
parent 94a7d13af8
commit f95d0d78f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
90 changed files with 6105 additions and 233 deletions

9
Cargo.lock generated
View File

@ -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",

View File

@ -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",

View File

@ -1 +1,2 @@
**/dist
*.gltf

View File

@ -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",

View File

@ -1,8 +1,9 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch, provide } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import {
ArrowBigUpDashIcon,
ChangeSkinIcon,
CompassIcon,
DownloadIcon,
HomeIcon,
@ -67,6 +68,8 @@ import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
import FriendsList from '@/components/ui/friends/FriendsList.vue'
import { openUrl } from '@tauri-apps/plugin-opener'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import { get_available_capes, get_available_skins } from './helpers/skins'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
const themeStore = useTheming()
@ -205,6 +208,14 @@ async function setupApp() {
get_opening_command().then(handleCommand)
checkUpdates()
fetchCredentials()
try {
const skins = (await get_available_skins()) ?? []
const capes = (await get_available_capes()) ?? []
generateSkinPreviews(skins, capes)
} catch (error) {
console.warn('Failed to generate skin previews in app setup.', error)
}
}
const stateFailed = ref(false)
@ -312,6 +323,7 @@ onMounted(() => {
})
const accounts = ref(null)
provide('accountsCard', accounts)
command_listener(handleCommand)
async function handleCommand(e) {
@ -407,6 +419,9 @@ function handleAuxClick(e) {
>
<CompassIcon />
</NavButton>
<NavButton v-tooltip.right="'Skins (Beta)'" to="/skins">
<ChangeSkinIcon />
</NavButton>
<NavButton
v-tooltip.right="'Library'"
to="/library"

View File

@ -0,0 +1 @@
{"asset":{"version":"2.0","generator":"Blockbench 4.12.4 glTF exporter"},"scenes":[{"nodes":[1],"name":"blockbench_export"}],"scene":0,"nodes":[{"rotation":[0,0,0.19509032201612825,0.9807852804032304],"translation":[0.15625,1,0],"name":"Cape","mesh":0},{"children":[0]}],"bufferViews":[{"buffer":0,"byteOffset":0,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":288,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":576,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":768,"byteLength":72,"target":34963}],"buffers":[{"byteLength":840,"uri":"data:application/octet-stream;base64,AAAAPQAAAAAAAKA+AAAAPQAAAAAAAKC+AAAAPQAAgL8AAKA+AAAAPQAAgL8AAKC+AAAAvQAAAAAAAKC+AAAAvQAAAAAAAKA+AAAAvQAAgL8AAKC+AAAAvQAAgL8AAKA+AAAAvQAAAAAAAKC+AAAAPQAAAAAAAKC+AAAAvQAAAAAAAKA+AAAAPQAAAAAAAKA+AAAAvQAAgL8AAKA+AAAAPQAAgL8AAKA+AAAAvQAAgL8AAKC+AAAAPQAAgL8AAKC+AAAAvQAAAAAAAKA+AAAAPQAAAAAAAKA+AAAAvQAAgL8AAKA+AAAAPQAAgL8AAKA+AAAAPQAAAAAAAKC+AAAAvQAAAAAAAKC+AAAAPQAAgL8AAKC+AAAAvQAAgL8AAKC+AACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AACAPAAAgD0AADA+AACAPQAAgDwAAAg/AAAwPgAACD8AAEA+AACAPQAAsD4AAIA9AABAPgAACD8AALA+AAAIPwAAgDwAAAA9AACAPAAAgD0AADA+AAAAPQAAMD4AAIA9AAAwPgAAAD0AAKg+AAAAPQAAMD4AAAAAAACoPgAAAAAAAEA+AACAPQAAMD4AAIA9AABAPgAACD8AADA+AAAIPwAAAAAAAIA9AACAPAAAgD0AAAAAAAAIPwAAgDwAAAg/AAACAAEAAgADAAEABAAGAAUABgAHAAUACAAKAAkACgALAAkADAAOAA0ADgAPAA0AEAASABEAEgATABEAFAAWABUAFgAXABUA"}],"accessors":[{"bufferView":0,"componentType":5126,"count":24,"max":[0.03125,0,0.3125],"min":[-0.03125,-1,-0.3125],"type":"VEC3"},{"bufferView":1,"componentType":5126,"count":24,"max":[1,1,1],"min":[-1,-1,-1],"type":"VEC3"},{"bufferView":2,"componentType":5126,"count":24,"max":[0.34375,0.53125],"min":[0,0],"type":"VEC2"},{"bufferView":3,"componentType":5123,"count":36,"max":[23],"min":[0],"type":"SCALAR"}],"materials":[{"pbrMetallicRoughness":{"metallicFactor":0,"roughnessFactor":1,"baseColorTexture":{"index":0}},"alphaMode":"MASK","alphaCutoff":0.05,"doubleSided":true}],"textures":[{"sampler":0,"source":0,"name":"cape.png"}],"samplers":[{"magFilter":9728,"minFilter":9728,"wrapS":33071,"wrapT":33071}],"images":[{"mimeType":"image/png","uri":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAAAAXNSR0IArs4c6QAABCRJREFUaEPtlktoE1EUhm/sIzFpYsBgHyn2oRZt0C5ESxWFYutKNyKIWBBR6kpBd3Ur2J0FRdCiuKmIIG4qLnzgQqTUXSlVqdoXTU0lljRpYtOHI/8dznBnOm1mphMwJWeTzJ0795zznf/cex2MMXam2S/ht38siJ8V1lgd5mOBARerc3rZnmK37rwvCyk2nE6wezMRh+4EncH9B1ql+fkU64rPsaBz5bqh4T7Daxn1Kc5zVNeEePJIci0Aq71bzenY6JChwAnA0OBHQ/OtJLnWNwqAy61R1tO3k891ueRKoDKwtqbv7MGbgCnfRgHQoqG9hyX4hc/yiho+/HNqVPFtdj2jwTpQgd/RKT7/0ZUku/o4qAJw50KYXbzr4e+3BioYzc3kwGzAUCLWFw2+UBiCb3bNTDHiPQeAP3CGNmg/6VcSBpDu3hhvDQpOCwABwrQKsRIsqYAChy/EQAXwlPiZ3a3igNPkXIyTjr8o5L7PPdzGf59c+sV/faeWeIIIAHPJ8M3kc7l1K09LKghWAIgqoPZ7djPFTlxb4D6yAgBOQfntrUVWVeRk44tplXJorOVGkVIJTBCTpw9ECFYBIEkyMfmsAXh3u1qi5MlxbbGX/x1ZSCjBAAwgfPr6h49R5UVavk0FXC2wju5p07s6iqEF0PtqSlEf+bKzDRwdgaDU7AkoySJ5Oo/D6ZRq/H0yyuJ/lxkSheG/FgCNm7kL0BoEAG32sqtY1YIHd2/mGzTMVgD3y2slqjgW9xY6ma9AThAARIMiBtOpjADwTWc0bEkB+FZsSTxTW0KBsGPXx0yvrUpEeHAAQIM7wBJLcu8DwHaP/H8i6VSND6SiSjBlRW4WWUypVIBbIgzjVgB0tpdKqLReS0KVPTMTfH0ra2cEgAmAAEd+l1z52LybqwBQYAQAyZPh6gtDW4jjuC4fHx8wXSm0JDbeI95SRYHiFZlUaWVtPQiKAugl5E8ASAUYiy8vc0C474uGasPE5PHc4g0wK/f4obom6UNimol7kTZwQLANwOuqBokqDEeQf4lfvvnNxZJcBTBAGZplWQcQ3tcgwY9oWlXinRW4ugoAgNAWWe6ocn1QvgyRTUb4RZFVljlY/3hSBYCqb6cCZo8ekuATVRZPI/gQW8FWAI1VHganVgDQUajdA6y2gAgAcZFBjTBSpG0AcAqc3VVmCMDTbxGWZvIRCaMNkJ7pFMCzVQD4liCQ8kRFUlvaBkCvL+wYw2ZmNUgCgLajFhRPJlv3ADuS1VtjPQCk823S574ffN/x1dQqy8dHR5SN2Spcbaymz2mjwNYDAD4IQn3TDpVLQIAqNjwAANRrAdoI/3sAOF7Xe1khCNQGVH1bL0JGJb1R52VtD8gVYHkAuVKpbMWZV0C2yObKunkF5EqlshVnXgHZIpsr6/4DlbxcPydnT74AAAAASUVORK5CYII="}],"meshes":[{"primitives":[{"mode":4,"attributes":{"POSITION":0,"NORMAL":1,"TEXCOORD_0":2},"indices":3,"material":0}]}]}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -9,13 +9,11 @@
<Avatar
size="36px"
:src="
selectedAccount
? `https://mc-heads.net/avatar/${selectedAccount.id}/128`
: 'https://launcher-files.modrinth.com/assets/steve_head.png'
selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
"
/>
<div class="flex flex-col w-full">
<span>{{ selectedAccount ? selectedAccount.username : 'Select account' }}</span>
<span>{{ selectedAccount ? selectedAccount.profile.name : 'Select account' }}</span>
<span class="text-secondary text-xs">Minecraft account</span>
</div>
<DropdownIcon class="w-5 h-5 shrink-0" />
@ -28,28 +26,40 @@
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
>
<div v-if="selectedAccount" class="selected account">
<Avatar size="xs" :src="`https://mc-heads.net/avatar/${selectedAccount.id}/128`" />
<Avatar size="xs" :src="avatarUrl" />
<div>
<h4>{{ selectedAccount.username }}</h4>
<h4>{{ selectedAccount.profile.name }}</h4>
<p>Selected</p>
</div>
<Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.id)">
<Button
v-tooltip="'Log out'"
icon-only
color="raised"
@click="logout(selectedAccount.profile.id)"
>
<TrashIcon />
</Button>
</div>
<div v-else class="logged-out account">
<h4>Not signed in</h4>
<Button v-tooltip="'Log in'" icon-only color="primary" @click="login()">
<LogInIcon />
<Button
v-tooltip="'Log in'"
:disabled="loginDisabled"
icon-only
color="primary"
@click="login()"
>
<LogInIcon v-if="!loginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
</Button>
</div>
<div v-if="displayAccounts.length > 0" class="account-group">
<div v-for="account in displayAccounts" :key="account.id" class="account-row">
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
<Button class="option account" @click="setAccount(account)">
<Avatar :src="`https://mc-heads.net/avatar/${account.id}/128`" class="icon" />
<p>{{ account.username }}</p>
<Avatar :src="getAccountAvatarUrl(account)" class="icon" />
<p>{{ account.profile.name }}</p>
</Button>
<Button v-tooltip="'Log out'" icon-only @click="logout(account.id)">
<Button v-tooltip="'Log out'" icon-only @click="logout(account.profile.id)">
<TrashIcon />
</Button>
</div>
@ -63,7 +73,7 @@
</template>
<script setup>
import { DropdownIcon, PlusIcon, TrashIcon, LogInIcon } from '@modrinth/assets'
import { DropdownIcon, PlusIcon, TrashIcon, LogInIcon, SpinnerIcon } from '@modrinth/assets'
import { Avatar, Button, Card } from '@modrinth/ui'
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import {
@ -77,6 +87,8 @@ import { handleError } from '@/store/state.js'
import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events'
import { handleSevereError } from '@/store/error.js'
import { get_available_skins } from '@/helpers/skins'
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
defineProps({
mode: {
@ -89,32 +101,86 @@ defineProps({
const emit = defineEmits(['change'])
const accounts = ref({})
const loginDisabled = ref(false)
const defaultUser = ref()
const equippedSkin = ref(null)
const headUrlCache = ref(new Map())
async function refreshValues() {
defaultUser.value = await get_default_user().catch(handleError)
accounts.value = await users().catch(handleError)
try {
const skins = await get_available_skins()
equippedSkin.value = skins.find((skin) => skin.is_equipped)
if (equippedSkin.value) {
try {
const headUrl = await getPlayerHeadUrl(equippedSkin.value)
headUrlCache.value.set(equippedSkin.value.texture_key, headUrl)
} catch (error) {
console.warn('Failed to get head render for equipped skin:', error)
}
}
} catch {
equippedSkin.value = null
}
}
function setLoginDisabled(value) {
loginDisabled.value = value
}
defineExpose({
refreshValues,
setLoginDisabled,
loginDisabled,
})
await refreshValues()
const displayAccounts = computed(() =>
accounts.value.filter((account) => defaultUser.value !== account.id),
accounts.value.filter((account) => defaultUser.value !== account.profile.id),
)
const avatarUrl = computed(() => {
if (equippedSkin.value?.texture_key) {
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
if (cachedUrl) {
return cachedUrl
}
return `https://mc-heads.net/avatar/${equippedSkin.value.texture_key}/128`
}
if (selectedAccount.value?.profile?.id) {
return `https://mc-heads.net/avatar/${selectedAccount.value.profile.id}/128`
}
return 'https://launcher-files.modrinth.com/assets/steve_head.png'
})
function getAccountAvatarUrl(account) {
if (
account.profile.id === selectedAccount.value?.profile?.id &&
equippedSkin.value?.texture_key
) {
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
if (cachedUrl) {
return cachedUrl
}
}
return `https://mc-heads.net/avatar/${account.profile.id}/128`
}
const selectedAccount = computed(() =>
accounts.value.find((account) => account.id === defaultUser.value),
accounts.value.find((account) => account.profile.id === defaultUser.value),
)
async function setAccount(account) {
defaultUser.value = account.id
await set_default_user(account.id).catch(handleError)
defaultUser.value = account.profile.id
await set_default_user(account.profile.id).catch(handleError)
emit('change')
}
async function login() {
loginDisabled.value = true
const loggedIn = await login_flow().catch(handleSevereError)
if (loggedIn) {
@ -123,6 +189,7 @@ async function login() {
}
trackEvent('AccountLogIn')
loginDisabled.value = false
}
const logout = async (id) => {

View File

@ -92,7 +92,7 @@ async function loginMinecraft() {
const loggedIn = await login_flow()
if (loggedIn) {
await set_default_user(loggedIn.id).catch(handleError)
await set_default_user(loggedIn.profile.id).catch(handleError)
}
await trackEvent('AccountLogIn', { source: 'ErrorModal' })
@ -219,8 +219,8 @@ async function copyToClipboard(text) {
<template v-else-if="metadata.notEnoughSpace">
<h3>Not enough space</h3>
<p>
It looks like there is not enough space on the disk containing the dirctory you
selected Please free up some space and try again or cancel the directory change.
It looks like there is not enough space on the disk containing the directory you
selected. Please free up some space and try again or cancel the directory change.
</p>
</template>
<template v-else>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useTemplateRef } from 'vue'
import { NewModal as Modal } from '@modrinth/ui'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts'
@ -26,16 +26,16 @@ const props = defineProps({
default: true,
},
})
const modal = ref(null)
const modal = useTemplateRef('modal')
defineExpose({
show: () => {
show: (e: MouseEvent) => {
hide_ads_window()
modal.value.show()
modal.value?.show(e)
},
hide: () => {
onModalHide()
modal.value.hide()
modal.value?.hide()
},
})

View File

@ -56,9 +56,17 @@ watch(
/>
</div>
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Hide nametag</h2>
<p class="m-0 mt-1">Disables the nametag above your player on the skins page. page.</p>
</div>
<Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" />
</div>
<div v-if="os !== 'MacOS'" class="mt-4 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Native Decorations</h2>
<h2 class="m-0 text-lg font-extrabold text-contrast">Native decorations</h2>
<p class="m-0 mt-1">Use system window frame (app restart required).</p>
</div>
<Toggle id="native-decorations" v-model="settings.native_decorations" />

View File

@ -0,0 +1,415 @@
<template>
<UploadSkinModal ref="uploadModal" />
<ModalWrapper ref="modal" @on-hide="resetState">
<template #title>
<span class="text-lg font-extrabold text-contrast">
{{ mode === 'edit' ? 'Editing skin' : 'Adding a skin' }}
</span>
</template>
<div class="flex flex-col md:flex-row gap-6">
<div class="max-h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
<SkinPreviewRenderer
slim-model-src="/src/assets/models/slim_player.gltf"
wide-model-src="/src/assets/models/classic_player.gltf"
cape-model-src="/src/assets/models/cape.gltf"
:variant="variant"
:texture-src="previewSkin || ''"
:cape-src="selectedCapeTexture"
:scale="1.4"
:fov="50"
:initial-rotation="Math.PI / 8"
class="h-full w-full"
/>
</div>
</div>
<div class="flex flex-col gap-4 w-full min-h-[20rem]">
<section>
<h2 class="text-base font-semibold mb-2">Texture</h2>
<Button @click="openUploadSkinModal"> <UploadIcon /> Replace texture </Button>
</section>
<section>
<h2 class="text-base font-semibold mb-2">Arm style</h2>
<RadioButtons v-model="variant" :items="['CLASSIC', 'SLIM']">
<template #default="{ item }">
{{ item === 'CLASSIC' ? 'Wide' : 'Slim' }}
</template>
</RadioButtons>
</section>
<section>
<h2 class="text-base font-semibold mb-2">Cape</h2>
<div class="flex gap-2">
<CapeButton
v-if="defaultCape"
:id="defaultCape.id"
:texture="defaultCape.texture"
:name="undefined"
:selected="!selectedCape"
faded
@select="selectCape(undefined)"
>
<span>Use default cape</span>
</CapeButton>
<CapeLikeTextButton v-else :highlighted="!selectedCape" @click="selectCape(undefined)">
<span>Use default cape</span>
</CapeLikeTextButton>
<CapeButton
v-for="cape in visibleCapeList"
:id="cape.id"
:key="cape.id"
:texture="cape.texture"
:name="cape.name || 'Cape'"
:selected="selectedCape?.id === cape.id"
@select="selectCape(cape)"
/>
<CapeLikeTextButton
v-if="(capes?.length ?? 0) > 2"
tooltip="View more capes"
@mouseup="openSelectCapeModal"
>
<template #icon><ChevronRightIcon /></template>
<span>More</span>
</CapeLikeTextButton>
</div>
</section>
</div>
</div>
<div class="flex gap-2 mt-12">
<ButtonStyled color="brand" :disabled="disableSave || isSaving">
<button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save">
<SpinnerIcon v-if="isSaving" class="animate-spin" />
<CheckIcon v-else-if="mode === 'new'" />
<SaveIcon v-else />
{{ mode === 'new' ? 'Add skin' : 'Save skin' }}
</button>
</ButtonStyled>
<Button :disabled="isSaving" @click="hide"><XIcon />Cancel</Button>
</div>
</ModalWrapper>
<SelectCapeModal
ref="selectCapeModal"
:capes="capes || []"
@select="handleCapeSelected"
@cancel="handleCapeCancel"
/>
</template>
<script setup lang="ts">
import { ref, computed, watch, useTemplateRef } from 'vue'
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
import {
SkinPreviewRenderer,
Button,
RadioButtons,
CapeButton,
CapeLikeTextButton,
ButtonStyled,
} from '@modrinth/ui'
import {
add_and_equip_custom_skin,
remove_custom_skin,
unequip_skin,
type Skin,
type Cape,
type SkinModel,
get_normalized_skin_texture,
} from '@/helpers/skins.ts'
import { handleError } from '@/store/notifications'
import {
UploadIcon,
CheckIcon,
SaveIcon,
XIcon,
ChevronRightIcon,
SpinnerIcon,
} from '@modrinth/assets'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
const modal = useTemplateRef('modal')
const selectCapeModal = useTemplateRef('selectCapeModal')
const mode = ref<'new' | 'edit'>('new')
const currentSkin = ref<Skin | null>(null)
const shouldRestoreModal = ref(false)
const isSaving = ref(false)
const uploadedTextureUrl = ref<string | null>(null)
const previewSkin = ref<string>('')
const variant = ref<SkinModel>('CLASSIC')
const selectedCape = ref<Cape | undefined>(undefined)
const props = defineProps<{ capes?: Cape[]; defaultCape?: Cape }>()
const selectedCapeTexture = computed(() => selectedCape.value?.texture)
const visibleCapeList = ref<Cape[]>([])
const sortedCapes = computed(() => {
return [...(props.capes || [])].sort((a, b) => {
const nameA = (a.name || '').toLowerCase()
const nameB = (b.name || '').toLowerCase()
return nameA.localeCompare(nameB)
})
})
function initVisibleCapeList() {
if (!props.capes || props.capes.length === 0) {
visibleCapeList.value = []
return
}
if (visibleCapeList.value.length === 0) {
if (selectedCape.value) {
const otherCape = getSortedCapeExcluding(selectedCape.value.id)
visibleCapeList.value = otherCape ? [selectedCape.value, otherCape] : [selectedCape.value]
} else {
visibleCapeList.value = getSortedCapes(2)
}
}
}
function getSortedCapes(count: number): Cape[] {
if (!sortedCapes.value || sortedCapes.value.length === 0) return []
return sortedCapes.value.slice(0, count)
}
function getSortedCapeExcluding(excludeId: string): Cape | undefined {
if (!sortedCapes.value || sortedCapes.value.length <= 1) return undefined
return sortedCapes.value.find((cape) => cape.id !== excludeId)
}
async function loadPreviewSkin() {
if (uploadedTextureUrl.value) {
previewSkin.value = uploadedTextureUrl.value
} else if (currentSkin.value) {
try {
previewSkin.value = await get_normalized_skin_texture(currentSkin.value)
} catch (error) {
console.error('Failed to load skin texture:', error)
previewSkin.value = '/src/assets/skins/steve.png'
}
} else {
previewSkin.value = '/src/assets/skins/steve.png'
}
}
const hasEdits = computed(() => {
if (mode.value !== 'edit') return true
if (uploadedTextureUrl.value) return true
if (!currentSkin.value) return false
if (variant.value !== currentSkin.value.variant) return true
if ((selectedCape.value?.id || null) !== (currentSkin.value.cape_id || null)) return true
return false
})
const disableSave = computed(
() =>
(mode.value === 'new' && !uploadedTextureUrl.value) ||
(mode.value === 'edit' && !hasEdits.value),
)
const saveTooltip = computed(() => {
if (isSaving.value) return 'Saving...'
if (mode.value === 'new' && !uploadedTextureUrl.value) return 'Upload a skin first!'
if (mode.value === 'edit' && !hasEdits.value) return 'Make an edit to the skin first!'
return undefined
})
function resetState() {
mode.value = 'new'
currentSkin.value = null
uploadedTextureUrl.value = null
previewSkin.value = ''
variant.value = 'CLASSIC'
selectedCape.value = undefined
visibleCapeList.value = []
shouldRestoreModal.value = false
isSaving.value = false
}
async function show(e: MouseEvent, skin?: Skin) {
mode.value = skin ? 'edit' : 'new'
currentSkin.value = skin ?? null
if (skin) {
variant.value = skin.variant
selectedCape.value = props.capes?.find((c) => c.id === skin.cape_id)
} else {
variant.value = 'CLASSIC'
selectedCape.value = undefined
}
visibleCapeList.value = []
initVisibleCapeList()
await loadPreviewSkin()
modal.value?.show(e)
}
async function showNew(e: MouseEvent, skinTextureUrl: string) {
mode.value = 'new'
currentSkin.value = null
uploadedTextureUrl.value = skinTextureUrl
variant.value = 'CLASSIC'
selectedCape.value = undefined
visibleCapeList.value = []
initVisibleCapeList()
await loadPreviewSkin()
modal.value?.show(e)
}
async function restoreWithNewTexture(skinTextureUrl: string) {
uploadedTextureUrl.value = skinTextureUrl
await loadPreviewSkin()
if (shouldRestoreModal.value) {
setTimeout(() => {
modal.value?.show()
shouldRestoreModal.value = false
}, 0)
}
}
function hide() {
modal.value?.hide()
setTimeout(() => resetState(), 250)
}
function selectCape(cape: Cape | undefined) {
if (cape && selectedCape.value?.id !== cape.id) {
const isInVisibleList = visibleCapeList.value.some((c) => c.id === cape.id)
if (!isInVisibleList && visibleCapeList.value.length > 0) {
visibleCapeList.value.splice(0, 1, cape)
if (visibleCapeList.value.length > 1 && visibleCapeList.value[1].id === cape.id) {
const otherCape = getSortedCapeExcluding(cape.id)
if (otherCape) {
visibleCapeList.value.splice(1, 1, otherCape)
}
}
}
}
selectedCape.value = cape
}
function handleCapeSelected(cape: Cape | undefined) {
selectCape(cape)
if (shouldRestoreModal.value) {
setTimeout(() => {
modal.value?.show()
shouldRestoreModal.value = false
}, 0)
}
}
function handleCapeCancel() {
if (shouldRestoreModal.value) {
setTimeout(() => {
modal.value?.show()
shouldRestoreModal.value = false
}, 0)
}
}
function openSelectCapeModal(e: MouseEvent) {
if (!selectCapeModal.value) return
shouldRestoreModal.value = true
modal.value?.hide()
setTimeout(() => {
selectCapeModal.value?.show(
e,
currentSkin.value?.texture_key,
selectedCape.value,
previewSkin.value,
variant.value,
)
}, 0)
}
function openUploadSkinModal(e: MouseEvent) {
shouldRestoreModal.value = true
modal.value?.hide()
emit('open-upload-modal', e)
}
function restoreModal() {
if (shouldRestoreModal.value) {
setTimeout(() => {
const fakeEvent = new MouseEvent('click')
modal.value?.show(fakeEvent)
shouldRestoreModal.value = false
}, 500)
}
}
async function save() {
isSaving.value = true
try {
let textureUrl: string
if (uploadedTextureUrl.value) {
textureUrl = uploadedTextureUrl.value
} else {
textureUrl = currentSkin.value!.texture
}
await unequip_skin()
const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer())
if (mode.value === 'new') {
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
emit('saved')
} else {
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
await remove_custom_skin(currentSkin.value!)
emit('saved')
}
hide()
} catch (err) {
handleError(err)
} finally {
isSaving.value = false
}
}
watch([uploadedTextureUrl, currentSkin], async () => {
await loadPreviewSkin()
})
watch(
() => props.capes,
() => {
initVisibleCapeList()
},
{ immediate: true },
)
const emit = defineEmits<{
(event: 'saved'): void
(event: 'deleted', skin: Skin): void
(event: 'open-upload-modal', mouseEvent: MouseEvent): void
}>()
defineExpose({
show,
showNew,
restoreWithNewTexture,
hide,
shouldRestoreModal,
restoreModal,
})
</script>

View File

@ -0,0 +1,143 @@
<script setup lang="ts">
import { useTemplateRef, ref, computed } from 'vue'
import type { Cape, SkinModel } from '@/helpers/skins.ts'
import {
ButtonStyled,
ScrollablePanel,
CapeButton,
CapeLikeTextButton,
SkinPreviewRenderer,
} from '@modrinth/ui'
import { CheckIcon, XIcon } from '@modrinth/assets'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const modal = useTemplateRef('modal')
const emit = defineEmits<{
(e: 'select', cape: Cape | undefined): void
(e: 'cancel'): void
}>()
const props = defineProps<{
capes: Cape[]
}>()
const sortedCapes = computed(() => {
return [...props.capes].sort((a, b) => {
const nameA = (a.name || '').toLowerCase()
const nameB = (b.name || '').toLowerCase()
return nameA.localeCompare(nameB)
})
})
const currentSkinId = ref<string | undefined>()
const currentSkinTexture = ref<string | undefined>()
const currentSkinVariant = ref<SkinModel>('CLASSIC')
const currentCapeTexture = computed<string | undefined>(() => currentCape.value?.texture)
const currentCape = ref<Cape | undefined>()
function show(
e: MouseEvent,
skinId?: string,
selected?: Cape,
skinTexture?: string,
variant?: SkinModel,
) {
currentSkinId.value = skinId
currentSkinTexture.value = skinTexture
currentSkinVariant.value = variant || 'CLASSIC'
currentCape.value = selected
modal.value?.show(e)
}
function select() {
emit('select', currentCape.value)
hide()
}
function hide() {
modal.value?.hide()
emit('cancel')
}
function updateSelectedCape(cape: Cape | undefined) {
currentCape.value = cape
}
function onModalHide() {
emit('cancel')
}
defineExpose({
show,
hide,
})
</script>
<template>
<ModalWrapper ref="modal" @on-hide="onModalHide">
<template #title>
<div class="flex flex-col">
<span class="text-lg font-extrabold text-heading">Change cape</span>
</div>
</template>
<div class="flex flex-col md:flex-row gap-6">
<div class="max-h-[25rem] h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
<SkinPreviewRenderer
v-if="currentSkinTexture"
slim-model-src="/src/assets/models/slim_player.gltf"
wide-model-src="/src/assets/models/classic_player.gltf"
cape-model-src="/src/assets/models/cape.gltf"
:cape-src="currentCapeTexture"
:texture-src="currentSkinTexture"
:variant="currentSkinVariant"
:scale="1.4"
:fov="50"
:initial-rotation="Math.PI + Math.PI / 8"
class="h-full w-full"
/>
</div>
</div>
<div class="flex flex-col gap-4 w-full my-auto">
<ScrollablePanel class="max-h-[20rem] max-w-[30rem] mb-5 h-full">
<div class="flex flex-wrap gap-2 justify-center content-start overflow-y-auto h-full">
<CapeLikeTextButton
tooltip="No Cape"
:highlighted="!currentCape"
@click="updateSelectedCape(undefined)"
>
<template #icon>
<XIcon />
</template>
<span>None</span>
</CapeLikeTextButton>
<CapeButton
v-for="cape in sortedCapes"
:id="cape.id"
:key="cape.id"
:name="cape.name"
:texture="cape.texture"
:selected="currentCape?.id === cape.id"
@select="updateSelectedCape(cape)"
/>
</div>
</ScrollablePanel>
</div>
</div>
<div class="flex gap-2 items-center">
<ButtonStyled color="brand">
<button @click="select">
<CheckIcon />
Select
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</ModalWrapper>
</template>

View File

@ -0,0 +1,140 @@
<template>
<ModalWrapper ref="modal" @on-hide="hide(true)">
<template #title>
<span class="text-lg font-extrabold text-contrast"> Upload skin texture </span>
</template>
<div class="relative">
<div
class="border-2 border-dashed border-highlight-gray rounded-xl h-[173px] flex flex-col items-center justify-center p-8 cursor-pointer bg-button-bg hover:bg-button-hover transition-colors relative"
@click="triggerFileInput"
>
<p class="mx-auto mb-0 text-primary font-bold text-lg text-center flex items-center gap-2">
<UploadIcon /> Select skin texture file
</p>
<p class="mx-auto mt-0 text-secondary text-sm text-center">
Drag and drop or click here to browse
</p>
<input
ref="fileInput"
type="file"
accept="image/png"
class="hidden"
@change="handleInputFileChange"
/>
</div>
</div>
</ModalWrapper>
</template>
<script setup lang="ts">
import { ref, onBeforeUnmount, watch } from 'vue'
import { UploadIcon } from '@modrinth/assets'
import { useNotifications } from '@/store/state'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_dragged_skin_data } from '@/helpers/skins'
const notifications = useNotifications()
const modal = ref()
const fileInput = ref<HTMLInputElement>()
const unlisten = ref<() => void>()
const modalVisible = ref(false)
const emit = defineEmits<{
(e: 'uploaded', data: ArrayBuffer): void
(e: 'canceled'): void
}>()
function show(e?: MouseEvent) {
modal.value?.show(e)
modalVisible.value = true
setupDragDropListener()
}
function hide(emitCanceled = false) {
modal.value?.hide()
modalVisible.value = false
cleanupDragDropListener()
resetState()
if (emitCanceled) {
emit('canceled')
}
}
function resetState() {
if (fileInput.value) fileInput.value.value = ''
}
function triggerFileInput() {
fileInput.value?.click()
}
async function handleInputFileChange(e: Event) {
const files = (e.target as HTMLInputElement).files
if (!files || files.length === 0) {
return
}
const file = files[0]
const buffer = await file.arrayBuffer()
await processData(buffer)
}
async function setupDragDropListener() {
try {
if (modalVisible.value) {
await cleanupDragDropListener()
unlisten.value = await getCurrentWebview().onDragDropEvent(async (event) => {
if (event.payload.type !== 'drop') {
return
}
if (!event.payload.paths || event.payload.paths.length === 0) {
return
}
const filePath = event.payload.paths[0]
try {
const data = await get_dragged_skin_data(filePath)
await processData(data.buffer)
} catch (error) {
notifications.addNotification({
title: 'Error processing file',
text: error instanceof Error ? error.message : 'Failed to read the dropped file.',
type: 'error',
})
}
})
}
} catch (error) {
console.error('Failed to set up drag and drop listener:', error)
}
}
async function cleanupDragDropListener() {
if (unlisten.value) {
unlisten.value()
unlisten.value = undefined
}
}
async function processData(buffer: ArrayBuffer) {
emit('uploaded', buffer)
hide()
}
watch(modalVisible, (isVisible) => {
if (isVisible) {
setupDragDropListener()
} else {
cleanupDragDropListener()
}
})
onBeforeUnmount(() => {
cleanupDragDropListener()
})
defineExpose({ show, hide })
</script>

View File

@ -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<RenderResult> {
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<string> {
this.camera.position.set(...cameraPosition)
this.camera.lookAt(...lookAtPosition)
this.renderer.render(this.scene, this.camera)
return new Promise<string>((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<void> {
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<string, RenderResult>())
export const headMap = reactive(new Map<string, string>())
const DEBUG_MODE = false
export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
const validKeys = new Set<string>()
const validHeadKeys = new Set<string>()
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<Blob> {
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<string> {
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<string> {
return await generateHeadRender(skin)
}
export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
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)
}
}

View File

@ -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

View File

@ -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<string, SkinModel> = {
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<Cape[]> {
return invoke('plugin:minecraft-skins|get_available_capes', {})
}
export async function get_available_skins(): Promise<Skin[]> {
return invoke('plugin:minecraft-skins|get_available_skins', {})
}
export async function add_and_equip_custom_skin(
textureBlob: Uint8Array,
variant: SkinModel,
capeOverride?: Cape,
): Promise<void> {
await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', {
textureBlob,
variant,
capeOverride,
})
}
export async function set_default_cape(cape?: Cape): Promise<void> {
await invoke('plugin:minecraft-skins|set_default_cape', {
cape,
})
}
export async function equip_skin(skin: Skin): Promise<void> {
await invoke('plugin:minecraft-skins|equip_skin', {
skin,
})
}
export async function remove_custom_skin(skin: Skin): Promise<void> {
await invoke('plugin:minecraft-skins|remove_custom_skin', {
skin,
})
}
export async function get_normalized_skin_texture(skin: Skin): Promise<string> {
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<Uint8Array> {
return await invoke('plugin:minecraft-skins|normalize_skin_texture', { texture })
}
export async function unequip_skin(): Promise<void> {
await invoke('plugin:minecraft-skins|unequip_skin')
}
export async function get_dragged_skin_data(path: string): Promise<Uint8Array> {
const data = await invoke('plugin:minecraft-skins|get_dragged_skin_data', { path })
return new Uint8Array(data)
}

View File

@ -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<void> {
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<void> {
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<RenderResult | null> {
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<string>): Promise<number> {
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<IDBCursorWithValue>).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()

View File

@ -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) => {
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()
}
})
},
)
onUnmounted(() => {
unlistenProfile()
@ -97,8 +100,8 @@ onUnmounted(() => {
<template>
<div class="p-6 flex flex-col gap-2">
<h1 v-if="recentInstances" class="m-0 text-2xl">Welcome back!</h1>
<h1 v-else class="m-0 text-2xl">Welcome to Modrinth App!</h1>
<h1 v-if="recentInstances?.length > 0" class="m-0 text-2xl font-extrabold">Welcome back!</h1>
<h1 v-else class="m-0 text-2xl font-extrabold">Welcome to Modrinth App!</h1>
<RecentWorldsList :recent-instances="recentInstances" />
<RowDisplay
v-if="hasFeaturedProjects"

View File

@ -0,0 +1,525 @@
<script setup lang="ts">
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<Skin[]>([])
const capes = ref<Cape[]>([])
const accountsCard = inject('accountsCard') as Ref<typeof AccountsCard>
const currentUser = ref(undefined)
const currentUserId = ref<string | undefined>(undefined)
const username = computed(() => currentUser.value?.profile?.name ?? undefined)
const selectedSkin = ref<Skin | null>(null)
const defaultCape = ref<Cape>()
const originalSelectedSkin = ref<Skin | null>(null)
const originalDefaultCape = ref<Cape>()
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<Skin | null>(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()])
</script>
<template>
<EditSkinModal
ref="editSkinModal"
:capes="capes"
:default-cape="defaultCape"
@saved="onSkinSaved"
@deleted="() => loadSkins()"
@open-upload-modal="openUploadSkinModal"
/>
<SelectCapeModal ref="selectCapeModal" :capes="capes" @select="handleCapeSelected" />
<UploadSkinModal
ref="uploadSkinModal"
@uploaded="onSkinFileUploaded"
@canceled="onUploadCanceled"
/>
<ConfirmModal
ref="deleteSkinModal"
title="Are you sure you want to delete this skin?"
description="This will permanently delete the selected skin. This action cannot be undone."
proceed-label="Delete"
@proceed="deleteSkin"
/>
<div v-if="currentUser" class="p-4 skin-layout">
<div class="preview-panel">
<h1 class="m-0 text-2xl font-bold flex items-center gap-2">
Skins
<span class="text-sm font-bold px-2 bg-brand-highlight text-brand rounded-full">Beta</span>
</h1>
<div class="preview-container">
<SkinPreviewRenderer
wide-model-src="/src/assets/models/classic_player.gltf"
slim-model-src="/src/assets/models/slim_player.gltf"
cape-model-src="/src/assets/models/cape.gltf"
:cape-src="capeTexture"
:texture-src="skinTexture || ''"
:variant="skinVariant"
:nametag="skinNametag"
:initial-rotation="Math.PI / 8"
>
<template #subtitle>
<ButtonStyled :disabled="!!selectedSkin?.cape_id">
<button
v-tooltip="
selectedSkin?.cape_id
? 'The equipped skin is overriding the default cape.'
: undefined
"
:disabled="!!selectedSkin?.cape_id"
@click="
(e: MouseEvent) =>
selectCapeModal?.show(
e,
selectedSkin?.texture_key,
currentCape,
skinTexture,
skinVariant,
)
"
>
<UpdatedIcon />
Change cape
</button>
</ButtonStyled>
</template>
</SkinPreviewRenderer>
</div>
</div>
<div class="skins-container">
<section class="flex flex-col gap-2 mt-1">
<h2 class="text-lg font-bold m-0 text-primary">Saved skins</h2>
<div class="skin-card-grid">
<SkinLikeTextButton class="skin-card" @click="openUploadSkinModal">
<template #icon>
<PlusIcon class="size-8" />
</template>
<span>Add a skin</span>
</SkinLikeTextButton>
<SkinButton
v-for="skin in savedSkins"
:key="`saved-skin-${skin.texture_key}`"
class="skin-card"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="selectedSkin === skin"
@select="changeSkin(skin)"
>
<template #overlay-buttons>
<Button
color="green"
aria-label="Edit skin"
class="pointer-events-auto"
@click.stop="(e) => editSkinModal?.show(e, skin)"
>
<EditIcon /> Edit
</Button>
<Button
v-show="!skin.is_equipped"
v-tooltip="'Delete skin'"
aria-label="Delete skin"
color="red"
class="!rounded-[100%] pointer-events-auto"
icon-only
@click.stop="() => confirmDeleteSkin(skin)"
>
<TrashIcon />
</Button>
</template>
</SkinButton>
</div>
</section>
<section class="flex flex-col gap-2 mt-6">
<h2 class="text-lg font-bold m-0 text-primary">Default skins</h2>
<div class="skin-card-grid">
<SkinButton
v-for="skin in defaultSkins"
:key="`default-skin-${skin.texture_key}`"
class="skin-card"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="selectedSkin === skin"
:tooltip="skin.name"
@select="changeSkin(skin)"
/>
</div>
</section>
</div>
</div>
<div v-else class="flex items-center justify-center min-h-[50vh] pt-[25%]">
<div
class="bg-bg-raised rounded-lg p-7 flex flex-col gap-5 shadow-md relative max-w-xl w-full mx-auto"
>
<img
:src="ExcitedRinthbot"
alt="Excited Modrinth Bot"
class="absolute -top-28 right-8 md:right-20 h-28 w-auto"
/>
<div
class="absolute top-0 left-0 w-full h-[1px] opacity-40 bg-gradient-to-r from-transparent via-green-500 to-transparent"
style="
background: linear-gradient(
to right,
transparent 2rem,
var(--color-green) calc(100% - 13rem),
var(--color-green) calc(100% - 5rem),
transparent calc(100% - 2rem)
);
"
></div>
<div class="flex flex-col gap-5">
<h1 class="text-3xl font-extrabold m-0">Please sign-in</h1>
<p class="text-lg m-0">
Please sign into your Minecraft account to use the skin management features of the
Modrinth app.
</p>
<ButtonStyled v-show="accountsCard" color="brand" :disabled="accountsCard.loginDisabled">
<button :disabled="accountsCard.loginDisabled" @click="login">
<LogInIcon v-if="!accountsCard.loginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
Sign In
</button>
</ButtonStyled>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
$skin-card-width: 155px;
$skin-card-gap: 4px;
.skin-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2.5fr);
gap: 2.5rem;
@media (max-width: 700px) {
grid-template-columns: 1fr;
}
}
.preview-panel {
top: 1.5rem;
position: sticky;
align-self: start;
padding: 0.5rem;
padding-top: 0;
}
.preview-container {
height: 80vh;
display: flex;
align-items: center;
justify-content: center;
margin-left: calc((2.5rem / 2));
@media (max-width: 700px) {
height: 50vh;
}
}
.skins-container {
padding-top: 0.5rem;
}
.skin-card-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $skin-card-gap;
width: 100%;
@media (min-width: 1300px) {
grid-template-columns: repeat(4, 1fr);
}
@media (min-width: 1750px) {
grid-template-columns: repeat(5, 1fr);
}
@media (min-width: 2050px) {
grid-template-columns: repeat(6, 1fr);
}
}
.skin-card {
aspect-ratio: 0.95;
border-radius: 10px;
box-sizing: border-box;
width: 100%;
min-width: 0;
}
</style>

View File

@ -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 }

View File

@ -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',

View File

@ -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)',

View File

@ -27,7 +27,10 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
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)
}

View File

@ -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",

View File

@ -33,6 +33,7 @@
"jre:default",
"logs:default",
"metadata:default",
"minecraft-skins:default",
"mr-auth:default",
"profile-create:default",
"pack:default",

View File

@ -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<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
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<Vec<Cape>> {
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<Vec<Skin>> {
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<Cape>,
) -> 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<Cape>) -> 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<Bytes> {
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<Bytes> {
let path = Path::new(&path);
Ok(minecraft_skins::get_dragged_skin_data(path).await?)
}

View File

@ -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;

View File

@ -28,7 +28,6 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
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<ProcessMetadata> {
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<ProcessMetadata> {
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?;

View File

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

View File

@ -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"
},
{

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -41,7 +41,7 @@
{
"name": "display_claims!: serde_json::Value",
"ordinal": 7,
"type_info": "Text"
"type_info": "Null"
}
],
"parameters": {

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -5,7 +5,7 @@ authors = ["Jai A <jaiagr+gpg@pm.me>"]
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

View File

@ -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;

View File

@ -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));

View File

@ -39,21 +39,27 @@ pub struct LatestLogCursor {
#[serde(transparent)]
pub struct CensoredString(String);
impl CensoredString {
pub fn censor(mut s: String, credentials_set: &Vec<Credentials>) -> 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::<Vec<_>>();
// 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::<Vec<_>>();
let output = CensoredString::censor(output, &credentials);
Ok(LatestLogCursor {
cursor,

View File

@ -23,8 +23,8 @@ pub async fn finish_login(
#[tracing::instrument]
pub async fn get_default_user() -> crate::Result<Option<uuid::Uuid>> {
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]

View File

@ -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<str>,
/// The URL of the cape PNG texture.
pub texture: Arc<Url>,
/// 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<str>,
/// The name of the skin, if available.
pub name: Option<Arc<str>>,
/// 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<Uuid>,
/// The URL of the skin PNG texture. Can also be a data URL.
pub texture: Arc<Url>,
/// 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<Vec<Cape>> {
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<Vec<Skin>> {
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::<Vec<_>>()
.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(&current_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<Cape>,
) -> 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<Cape>) -> 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<Bytes> {
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<Bytes> {
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<Uuid>,
) -> 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(())
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 B

View File

@ -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/<texture_key>`.
pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = 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,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAGd0lEQVR4XuWav4sdVRzF02gSDPiLYLOiuCgusotdGqtsigVFCxvLVG7hjyKFtloZBMU0NtZpUqQQKxv/g00hNjaClSCxCKJFhHHPJOfxeefdO/vmvXmT99gvHO6d7/3One8593tnZuftmTMn2P3fv2zu373R4t9fPnnQP/a1/eP21uHrLY6uvz0F+3O+jTOTnyIOMUy0hpxv46ytgIdV4FW3GKyAGnK+jbNi6VuA01IBU2V/GiuApCcinJoKgABZ/hYgnwB8EuR8G2cmStJEkk7kfGtnSahGtOqfI2ZSOQVf5jO6Td3UaiTC98ePH7WoxmXb0c98Rrd/fv6qSfz3960WMwTvPij53759r4VjHJ/zGD73w4t7LXysscxndFMS9+5cb/HXzfdbmJAJkjTBOMHnez6KIOKOU9/+zGd0c7KCSP1w7Y2pVRVk+4fvToirL0uhdK7GOaeFcIzntvCZz+jmJJW8V1p71AmrzZU3PN4SOz5HPs2huVIEbiPB/sxndFOyel6rtQgCb1Qm/On+iy0ogMn7PM/DOVsB4iZo0TOf0S3f3Ag+vkqrLygmz0uU7iHyaSzzeeT29Pnnm7OPPTnBO7u7U8j4tHPH55yE848/01w4+1zRn/Ol+Vy1AgUd5MVLAjxx7mJLXu3B9nZzeWurhfoZnyYi3gquAG+tVQngahqkokzcQgwtgBNP4mpFLOdL8/kWYfAKcOkf7u43B1s7C22BvCH6OFfcVcA250srCbBUBeSK61h9C5D3BG4PjXEVWdYmbyRxxmebY7WK4Xwc43HynTETsQDuWwATpxCKM5IQS5yrxcQzXi2TznPZcp48N6+nNvnOGFeffQlAwhTAfQrABDIZjvvYhGpESaTkz/kzfm4BWN4klq0rw3H2ZVJeQSfjlkTs97k8rhGy37G8HnPIayXfGSPRUpnn1rDf8SRQailKilUjYT/P5ZwUgeOM91jynTGSSnIpQIqlPgnwwkk0Y0jWYzzHsX6CdCGfNkTynbEasayG7Hs8CVEQk6ut1EnijCJAntAXJNYXJt2FvF4NCwuQf6T0hR6Vgp4axM1rnzVfXL4646/F25/jSagvku+M3Tv6tRG+vnSpE45L8N6hbeGVM3n6MkbHOWaf50zBiSRrMCb5ztifPx21BNWS2Od7e5M+YxinPkm5lU8C+E3SpHzTM3iuYxzvsSRN8tz72c4tQL7r90US97HL2n7eCHXs/c+bLo/dT+IlEdj2roBl7dVnX2uE7adeadu3dg+aj6980Ny4+k0L9QX5hTy/r71w4aXJtYQcH90ogJLrItk1Nq9ZALVrIYATEcYQQOQtgJDjoxtLUv2XH26DjJNPcenva76WRcjx0c0rYSEsAMvT94ZM3pXDbcQ+YYHdMpbnZstcV2JOgALopseLqy9fSQD6UgDGllqe52POKzDXlRgF8BaoCZDJse+nhZ4c3393e0qMrLIknOIwjrmuxLhiJigSKYB8KQATtWiOpQBJkOQ9Z/ody1xXYr6oiaivFUwB5GOSScb3DcHbhXOSnONyPvsZy1xXYqXEThKArc/zi5K3gsf5MlV7ucpzvY0E5roS8yqZiPpdW4DEKUAJGluGvPrMdSXGMjVB72fHqG8fBWA1UMhSn770p48iMteVmEk5ER3ne4DG/B6QsUaJiMBrraW59FiWWQEikuNZxo+shJe1JOPjkgAlsbrIy8drraWxpGv7jyXN+NzvxMZsAX28yC/G7OcHlPyynPP5k7iRHzkyPs3i+x6T44MbP1+5T5L58zrjuwTw5/H8L5OMT8sbao4PbiaUsCAlAfi7Qs43RAVYgNEqoLT69uUWMHHH5nwpwCIVwPtRjg9uXE33KQD9Jk6x9PHTH0j5K1H+YmT4i7L7JOxVpwBueWMd9B7B1TRxk6752U8BeA/In8ncZwyfGikAhci+j5NPb+NKZ5vlzuqwP1eeQpRax5UEIOynGCnQYAJwZUnQx4aOs0JyVSkAyVIAisWV5aqnADnmNvn0NlYABUjibE1ex9zbSY7HHGe8yZcE4D0gS38wAUpEs+xZ8hRH/fyJqy/4Z3Lp1ZpI/yDfC0g2ibOlAO4LQwiwKPlBBMiEFsWiAtT2O9E1lnx6W/5g2RdJqC8yn9Ft2f8vSEJCilTDWghQ+v8C/W9B/h9B7f8LRII/cefv+zU4PvMZ3fJdP/Hmzk6Lmj8FMPl5Rch8+tr/XyeHB9eM9TsAAAAASUVORK5CYII="
).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,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAF2UlEQVR4Xu2bv4sdVRzFtzHgJkZXcF0NrBLQLMqKaEISFbFQRAsR7Oz9B8TKykpsAwlImoCdFoKgndgZCClT2qTzzxg5Ez/LeWfvfT9m5s3bt3kHvtyZ+2u+53y/d3bmztutrRl464X95qXt882rz+62xrlKGfXex49zvrUDREsicJ6kT5UAkEGI/fM7E+e0q3SjLudbO5TIevpD+MWd7QmjPudbO9SIKxNoe23/6WMCqO7UCOCkcwlcO7jQknUROFdbzrd2KEWfUiaSLoKTPxUC5BKAuGfANMv5ThxYy76uIUi6SwAI+xKYNobjkmjenv6MjnQaZ3HeHZYQH145aI02H18SxucoCZP+jI4koePdJ548Rl7prGNE4Jx+jEnyHHvpfdKf0eFREyl3ljqinuYiMMbrvHSxfEz6Mzrc+XcOLzYvP392wmkRfXD/bvPG3tYRcR2rTsdOTmM1h8+J6TyzQePSn9HhEZHzEJApml9+dPlY5DG1kTU5vpQFlJ4N6c/o8HRUBJ/ZPjPhoC+BNy/uteZLgH4ao7GawyNM6Rnh5+nP6IA8JSRk/lCT0Vcd7fTX2EzzEmkv05+V4/sP3m9uf/Lxkd377ecJy/6J1/efa2T5bqC6n77em5j7mytvt+Z1OV/in1s7zV/fPdXc/OpsG4jP3jtsLft1xrIEuHbpQuu0z61r5fVyvsSDG7vNv79cPxKATMx+neEO6XhIAciAX7/4vDWu4+c5X0LRf3jn0jgZMJQAIu8Z0EcARV/LYLAMEEmtQ9IxBcDog7M4LEfSEAFDAGWAUlil7N4Pj9byNFN/RZ3+3AO4ibqpjvbkWUUKkBFJARAHS4ddBIjrWJGDCH24mU0zkf7923NHQnAPSPIY7cmzipIAbl0FkGmtyhkn7wLkeck8+hKCe0ASZx7ak2cVJQHIAOpSAO9bI0C0PGo6FgkXILPCTfUefZ1zD8hxiEl78qwiBfA17m15n8C4MOZp7c4qKtkXU5tIJhmOfRnQV3OnAFxH7cmziiSXd+eSAL485BwX5wZHdNxZ1XlWQCxFor8/8KQwjHcBXASVybOKGrl57wGQIrVxoFaqj6d1iXRGGZFVyhjr9ZRqU5k8q5gmAG1Ytss8ApkNWeKkj0mBsqQPJcQ5znbakmcVTrC0zme1ewR0YUUxx2A47WNKQnmZL2GYnv7YeMW8PXlWcXjwStPHPAI1kVJMH0Pa18oS8RJ5GW0LCfDHnR8b2btXL081+uWjcUY5l1Aum+xfE436FIBNGH9V9+8QbOAkzyqSkOzh33+2lvXehuGsk80nyTz3Mdk/z0ltT3EXQeeUvnuVPEcDW148oQ29/cXS0LzZdiKQj6iyIXd/+PM4lKCDA9JZPxQUfT0nPLYC8MB0YpbAEOnum6Yy3zrPR+fB9wP6Ip3AkUVS1IXzm6ZMpJe6H9AX6UDXPX8XzjPCo7+U/YAhgTOLkgcpouqWvh+wDPQRIeHRlxD5pugCEH215zxrC5ETeV6eIMoxArgIKnOeDTao4Panj15QVMp8T7B9Ifm/nj7ZnvNtsMEGjzdyRyg3VLJ/Qn/H2RLrssXF2FEfhx19BcB5Pcz4r1OyXw2MXdnjsO8BdhEA5xXJLhngGZRto6CvAJ7CXTLAMyjbloLcFE0Bpm2CyvJjikcw9wJK0c3xLuA843sjBchd3ZIAbjjO8747n3sBpejmeL8HzDO+N0oCuM0SQA76S46MSHn0/G1Q9Vw/x3uU5xnfG06OclYGuFj+RgcZIpV7AR5Frp/jPcrzjO+NFECkfI2nAGlOQMeewhm90p+4HO9Rnmd8byQ5Io8QJQF8eTgBf6eXZT1tKrl+jvdokxWlvQTn0As1cvPeA3DOS0WpVO9t6cfKADksSc4SgEh5KYKlem9LPzqj736Aky+t81ntpU/c+UW4ZMljZcjfCyxqKQAi1IQY/KexfdH39wV85qbkuPaPGnwaTz9WhiQkW+T3BSLl3/mTPO8ClIiQfnTFf58/FHDi+TFWAAAAAElFTkSuQmCC"
).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,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAF5ElEQVR4Xu2asYsdVRTGt4mQjYlZwXU1sIaAZlFWRBNMVMTCIFqIYGfvPyBWVlZiKxiQNIF0sRAE7cROIaRMmSadf8bIN/ot3/vNnff27sybzdu8A4czc8+9d873nXPnzdx5GxsL5M0XdpuXNs81rzy73arPZaVuzz55zPlWTgy0RILPCfpEEWAwJmL33NbMuf2yqW7jfCsnJbBZ/gb84tbmjLqd862c9AFXJdj36u4zHQLUdmIISNBcAtf2LrRgkwSfy8f5Vk5K2beVCmSSkOBPBAFcAgaeFTBPOd9jJ17Lua4N0OUuAgw4l8C8MT4ukZZ+xjO5MGgH6+AzYBHx4dW9Vu3L8SVico4SMYxnciEIHW+fOt0Br3LWsUnwuft5DMH7OG32YTyTS2ZNoDJYtznr1CTBY7ItbZKVYxjP5JLBv7N/qbn4/JmZoAX0wf2/m9d3Ng6A61htOk5wGqs5ck6rzlkNGsd4JpfMiII3AKmy+cWNK53MW+Vz1XB8qQpssxoYz+SS5agMnt98aibAXAJvXNppNZeA+2mMxmqOzLBtVkSeM57JxeBtDUKaDzXMvtrsd3+NZZmXQKdlPMcu333wfnPr448O9N6vd2eU/Smv7T7XSPluoLY7X+3MzP311bdazTbOR/nn5+vNw5tbzZ/fnj24R3363n6rWpLsXy3LIuDa5QvNj1+emZlb1+L1OB/l0e3LzYMfthsRoSpiNbJ/tWRAOh6TAFfAL59/1qqvk+ecj+LsiwgTsLQKGIsAgc8KGEKAsy8ivAQGVYBAah26HEmA1X0crAMWKKpJsJoAVYAAyErvfX+2M5aq/sq4++c9wDfSVLVllRBvR0gAM0ICTI6VAScJBq5jBW4g7qNzjqMK9G/fPH1ARN4DCN6aVUK8HSkRkHpUAqQKVoEk+CSA5yXN7IuIzC6Be64king7UiLAFeA2EpB9+wAoCGfNGdGxQCQBrIpUtWf2vYRyGZQITT/xdoQE5BpPH+8TVl/UmmWdgSgj7Gt1tgjEx/OWAQmgn3g7QnC8O5cIyOWh4Hxh3+CcmQxEbVkVBkaSmGFWhMfaJgGlPsTbkT5wh70HGJRLmwHSqk9fNktEmChbtduqb/pKfYi3I/MIsM9KvzTZZzXQOsAcQ4Jo3cdW49Omr9SHeDuSAEvrfJE/2ddFlUWOsTrgHFMiKi1fwlL19OfNV6vasg/xdmR/7+VmiCb7fSSRzBzjsu+zBJ2PvgSfJJgI4u3I77d/aqTvvn1lrrofH42ZZS4hLhv27yPN7QQv9UZMvq7TeiOHeDtCQNJHf/3RKtvTZ3WwCZZPkjzPMezPc4ERENsSCTq3TfCHImBs8ZaXn87G3v7KXwvNTf+xCx9PpWPu/nA/gP5jF4Nm+1iSzwpPJAH50PRYLIExyj03TaW+j6jdzwN+dsh7wCj7AUOFATiImvJM4vKmKRXovkdnXteaVcJrjS68+FH3/JO4rIjM/lL2A8YUB1IL3kIS1bb0/YBlyBASKJn90jIgAfRzvpUTARP4fOFKmwQkCbacby1rgdz65L8XFFlp7gm2LyT/t7sP/ZxvLWtZy5Mt3BHihgr7U/Qbrp8vPcjod1ybGzVbXBxP/9JlKAEOXA8yAsN/qbA/hePpX7rkHuBRCHDgzmJtBXA8/UuXoQRk+QpMbQVwPP2jCzdFScC8TVApP6ZwDeeLkPrSP3Q88VQLCeCubomAVAfu532uYb4I0T90PPFUS4mA1EUEKDi/1ChAaWYps2cg9kuHjieeaklwtosqIMnKtzmDySxxPyCzaOBDxhNPtZAAgco1TgKoCUDHLG9mr+QfMp54qoXgnHkTUSIgl0cCyPd5K332p2Wf9LsyfLOkn3iqpQ/cYe8BDiytMmRLH/2MZ3IxOCtBLiLAWUorcLb00c94qmXofkCCL63zRf7Sp+38CDpP1Zd4Jhf+X6BWSYBJmEdE/j+A8UwuQ/9f4E/ctvy+X9L8NM54JhcCktb8v0CA8ht/CbzfA2yTBMZTK/8CEkHqRqevbWIAAAAASUVORK5CYII="
).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,
}]
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@ -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<impl Stream<Item = Result<Bytes, reqwest::Error>> + 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<Arc<Url>> {
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: <https://www.w3.org/TR/png-3/#3PNGsignature>
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<Bytes> {
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<u8>),
2 => do_copy!(rgb::GrayAlpha<u8>),
3 => do_copy!(rgb::Rgb<u8>),
4 => do_copy!(rgb::Rgba<u8>),
_ => 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<PixelType: NoUninit + AnyBitPattern>(
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"
);
}

View File

@ -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;

View File

@ -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,

View File

@ -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?;

View File

@ -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<ErrorKind>,
pub source: tracing_error::TracedError<std::sync::Arc<ErrorKind>>,
pub raw: Arc<ErrorKind>,
pub source: tracing_error::TracedError<Arc<ErrorKind>>,
}
impl std::error::Error for Error {
@ -148,7 +174,7 @@ impl std::fmt::Display for Error {
impl<E: Into<ErrorKind>> From<E> for Error {
fn from(source: E) -> Self {
let error = Into::<ErrorKind>::into(source);
let boxed_error = std::sync::Arc::new(error);
let boxed_error = Arc::new(error);
Self {
raw: boxed_error.clone(),

View File

@ -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<Vec<String>> {
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,

View File

@ -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);

View File

@ -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<Pool<Sqlite>> {
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<Pool<Sqlite>> {
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<Sqlite>) -> 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(())
}

View File

@ -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,
}

View File

@ -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<Utc>,
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<MinecraftProfile>),
/// 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<Uuid, ProfileCacheEntry, BuildHasherDefault<DefaultHasher>>,
> = 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<Arc<MinecraftProfile>> {
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,7 +418,6 @@ 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;
match res {
@ -279,16 +438,15 @@ impl Credentials {
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<Option<Self>> {
let res = sqlx::query!(
"
@ -301,9 +459,14 @@ impl Credentials {
.fetch_optional(exec)
.await?;
Ok(res.map(|x| Self {
Ok(match res {
Some(x) => {
let mut credentials = Self {
offline_profile: MinecraftProfile {
id: Uuid::parse_str(&x.uuid).unwrap_or_default(),
username: x.username,
name: x.username,
..MinecraftProfile::default()
},
access_token: x.access_token,
refresh_token: x.refresh_token,
expires: Utc
@ -311,11 +474,16 @@ impl Credentials {
.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<DashMap<Uuid, Self>> {
let res = sqlx::query!(
"
@ -327,12 +495,12 @@ 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,
name: x.username,
..MinecraftProfile::default()
},
access_token: x.access_token,
refresh_token: x.refresh_token,
expires: Utc
@ -340,10 +508,14 @@ impl Credentials {
.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<S: Serializer>(
&self,
serializer: S,
) -> Result<S::Ok, S::Error> {
// 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<Uuid>,
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<Url>,
/// 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<Arc<str>>,
/// 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<String>,
}
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<str> {
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<Option<String>, 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(<Option<Cow<'_, str>>>::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<Url>,
/// The user-friendly name for the cape.
#[serde(rename = "alias")]
pub name: Arc<str>,
}
#[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<MinecraftSkin>,
/// The capes the player is known to have.
pub capes: Vec<MinecraftCape>,
/// The instant when the profile was fetched. See also [Self::is_fresh].
#[serde(skip)]
pub fetch_time: Option<Instant>,
}
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<MinecraftProfile>),
/// 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<MinecraftProfile, MinecraftAuthenticationError> {
@ -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| {
let mut profile =
serde_json::from_str::<MinecraftProfile>(&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)]

View File

@ -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<Option<Self>> {
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<Uuid>,
}
impl CustomMinecraftSkin {
pub async fn add(
minecraft_user_id: Uuid,
texture_key: &str,
texture: &[u8],
variant: MinecraftSkinVariant,
cape_id: Option<Uuid>,
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<impl Stream<Item = Self>> {
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<impl Stream<Item = Self>> {
// 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<Vec<u8>> {
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(())
}
}

View File

@ -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<TextureStream>(
credentials: &Credentials,
texture: TextureStream,
variant: MinecraftSkinVariant,
) -> crate::Result<()>
where
TextureStream: TryStream + Send + 'static,
TextureStream::Error: Into<Box<dyn Error + Send + Sync>>,
Bytes: From<TextureStream::Ok>,
{
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::<MinecraftProfile>().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)));
}

View File

@ -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::*;

View File

@ -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?;

View File

@ -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

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" transform="scale(-1 1)" viewBox="0 0 49.915 52.72">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="4.331" d="M15.71 31.484v19.07h18.63v-19.07l6.538 6.539 6.871-6.872-11.203-11.733H14.122L2.166 31.375l6.827 6.827z"/>
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.993" d="M24.872 19.548v-6.44"/>
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="4.331" d="M24.704 13.202a5.518 5.518 0 0 1-5.518-5.518 5.518 5.518 0 0 1 5.518-5.518 5.518 5.518 0 0 1 5.518 5.518"/>
</svg>

After

Width:  |  Height:  |  Size: 695 B

View File

@ -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 {

View File

@ -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,
}

View File

@ -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",

View File

@ -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 } }) {
</script>
<style lang="scss" scoped>
@property --_top-fade-height {
syntax: '<length-percentage>';
inherits: false;
initial-value: 0%;
}
@property --_bottom-fade-height {
syntax: '<length-percentage>';
inherits: false;
initial-value: 0%;
}
.scrollable-pane-wrapper {
display: flex;
flex-direction: column;
@ -75,27 +88,25 @@ function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) {
display: flex;
overflow: hidden;
position: relative;
transition:
--_top-fade-height 0.05s linear,
--_bottom-fade-height 0.05s linear;
--_fade-height: 4rem;
--_fade-height: 3rem;
mask-image: linear-gradient(
transparent,
rgb(0 0 0 / 100%) var(--_top-fade-height, 0%),
rgb(0 0 0 / 100%) calc(100% - var(--_bottom-fade-height, 0%)),
transparent 100%
);
&.top-fade {
mask-image: linear-gradient(transparent, rgb(0 0 0 / 100%) var(--_fade-height));
--_top-fade-height: var(--_fade-height);
}
&.bottom-fade {
mask-image: linear-gradient(
rgb(0 0 0 / 100%) calc(100% - var(--_fade-height)),
transparent 100%
);
}
&.top-fade.bottom-fade {
mask-image: linear-gradient(
transparent,
rgb(0 0 0 / 100%) var(--_fade-height),
rgb(0 0 0 / 100%) calc(100% - var(--_fade-height)),
transparent 100%
);
--_bottom-fade-height: var(--_fade-height);
}
}
.scrollable-pane {

View File

@ -102,6 +102,13 @@ export { default as PurchaseModal } from './billing/PurchaseModal.vue'
export { default as AddPaymentMethodModal } from './billing/AddPaymentMethodModal.vue'
export { default as ModrinthServersPurchaseModal } from './billing/ModrinthServersPurchaseModal.vue'
// Skins
export { default as SkinPreviewRenderer } from './skin/SkinPreviewRenderer.vue'
export { default as CapeButton } from './skin/CapeButton.vue'
export { default as CapeLikeTextButton } from './skin/CapeLikeTextButton.vue'
export { default as SkinButton } from './skin/SkinButton.vue'
export { default as SkinLikeTextButton } from './skin/SkinLikeTextButton.vue'
// Version
export { default as VersionChannelIndicator } from './version/VersionChannelIndicator.vue'
export { default as VersionFilterControl } from './version/VersionFilterControl.vue'

View File

@ -0,0 +1,108 @@
<script setup lang="ts">
import { computed } from 'vue'
const emit = defineEmits<{
(e: 'select'): void
}>()
const props = withDefaults(
defineProps<{
name: string | undefined
id: string
texture: string
isEquipped?: boolean
selected?: boolean
faded?: boolean
}>(),
{
isEquipped: false,
selected: undefined,
faded: false,
},
)
console.log(props)
const highlighted = computed(() => props.selected ?? props.isEquipped)
</script>
<template>
<button
v-tooltip="name"
class="block border-0 m-0 p-0 bg-transparent group cursor-pointer"
:aria-label="name"
@click="emit('select')"
>
<span
:class="
highlighted
? `bg-brand highlighted-outer-glow`
: `bg-button-bg brightness-95 group-hover:brightness-100`
"
class="relative block p-[3px] rounded-lg border-0 group-active:scale-95 transition-all"
>
<span
class="block magical-cape-transform rounded-[5px]"
:class="{
'highlighted-inner-shadow': highlighted,
'brightness-[0.3] contrast-[0.8]': faded,
}"
>
<img :src="texture" alt="" />
</span>
<span
v-if="$slots.default || $slots.icon"
class="p-4 absolute inset-0 flex items-center justify-center text-primary font-medium"
>
<span class="mb-1">
<slot name="icon"></slot>
</span>
<span class="text-xs">
<slot></slot>
</span>
</span>
</span>
</button>
</template>
<style lang="scss" scoped>
.magical-cape-transform {
aspect-ratio: 10 / 16;
position: relative;
overflow: hidden;
box-sizing: content-box;
width: 60px;
min-height: 96px;
}
.magical-cape-transform img {
position: absolute;
object-fit: cover;
image-rendering: pixelated;
// scales image up so that the target area of the texture (10x16) is 100% of the container
width: calc(64 / 10 * 100%);
height: calc(32 / 16 * 100%);
// offsets the image so that the target area is in the container
left: calc(1 / 10 * -100%);
top: calc(1 / 16 * -100%);
// scale the image up a little bit to avoid edges from the surrounding texture due to rounding
scale: 1.01;
transform-origin: calc(10 / 2 / 64 * 100%) calc(16 / 2 / 32 * 100%);
}
.highlighted-inner-shadow::before {
content: '';
position: absolute;
inset: 0;
box-shadow: inset 0 0 4px 4px rgba(0, 0, 0, 0.4);
z-index: 2;
}
@supports (background-color: color-mix(in srgb, transparent, transparent)) {
.highlighted-glow::before {
box-shadow: inset 0 0 2px 4px color-mix(in srgb, var(--color-brand), transparent 10%);
}
}
</style>

View File

@ -0,0 +1,63 @@
<script setup lang="ts">
const emit = defineEmits<{
(e: 'click'): void
}>()
withDefaults(
defineProps<{
tooltip?: string
highlighted?: boolean
}>(),
{
tooltip: undefined,
highlighted: false,
},
)
</script>
<template>
<button
v-tooltip="tooltip"
class="block border-0 m-0 p-0 bg-transparent group cursor-pointer"
:aria-label="tooltip"
@click="emit('click')"
>
<span
:class="[
'block rounded-lg group-active:scale-95 transition-all border-2 relative',
highlighted
? 'border-brand highlighted-glow'
: 'border-transparent brightness-95 group-hover:brightness-100',
]"
>
<span class="block p-[3px] rounded-lg bg-button-bg">
<span
class="flex flex-col p-4 items-center justify-center aspect-[10/16] w-[60px] min-h-[96px] rounded-[5px] bg-black/10 relative overflow-hidden text-primary z-10"
>
<div class="mb-1">
<slot name="icon"></slot>
</div>
<span class="text-xs">
<slot></slot>
</span>
</span>
</span>
</span>
</button>
</template>
<style lang="scss" scoped>
.highlighted-glow::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
}
@supports (background-color: color-mix(in srgb, transparent, transparent)) {
.highlighted-glow::before {
box-shadow: inset 0 0 2px 2px color-mix(in srgb, var(--color-brand), transparent 10%);
}
}
</style>

View File

@ -0,0 +1,142 @@
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits<{
(e: 'select'): void
(e: 'edit', event: MouseEvent): void
}>()
const props = withDefaults(
defineProps<{
forwardImageSrc?: string
backwardImageSrc?: string
selected: boolean
tooltip?: string
}>(),
{
forwardImageSrc: undefined,
backwardImageSrc: undefined,
tooltip: undefined,
},
)
const imagesLoaded = ref({
forward: Boolean(props.forwardImageSrc),
backward: Boolean(props.backwardImageSrc),
})
function onImageLoad(type: 'forward' | 'backward') {
imagesLoaded.value[type] = true
}
</script>
<template>
<div
v-tooltip="tooltip ?? undefined"
class="group flex relative overflow-hidden rounded-xl border-solid border-2 transition-colors duration-200"
:class="[selected ? 'border-brand' : 'border-transparent hover:border-inverted']"
>
<button
class="skin-btn-bg absolute inset-0 cursor-pointer p-0 border-none group-hover:brightness-125"
:class="selected ? 'selected' : ''"
@click="emit('select')"
></button>
<div
v-if="!(imagesLoaded.forward && imagesLoaded.backward)"
class="skeleton-loader w-full h-full"
>
<div class="skeleton absolute inset-0 aspect-[5/7]"></div>
</div>
<span
v-show="imagesLoaded.forward && imagesLoaded.backward"
:class="[
'skin-button__image-parent pointer-events-none w-full h-full grid [transform-style:preserve-3d] transition-transform duration-500 group-hover:[transform:rotateY(180deg)] place-items-stretch with-shadow',
]"
>
<img
alt=""
:src="forwardImageSrc"
class="skin-button__image-facing object-contain w-full h-full [backface-visibility:hidden] col-start-1 row-start-1"
height="504"
@load="onImageLoad('forward')"
/>
<img
alt=""
:src="backwardImageSrc"
class="skin-button__image-away object-contain w-full h-full [backface-visibility:hidden] [transform:rotateY(180deg)] col-start-1 row-start-1"
height="504"
@load="onImageLoad('backward')"
/>
</span>
<span
v-if="$slots['overlay-buttons']"
class="pointer-events-none absolute inset-0 flex items-end justify-start p-1 gap-1 translate-y-4 scale-75 opacity-0 transition-all group-hover:opacity-100 group-hover:scale-100 group-hover:translate-y-0 group-hover:translate-x-0"
>
<slot name="overlay-buttons" />
</span>
</div>
</template>
<style scoped lang="scss">
.skeleton-loader {
aspect-ratio: 5 / 7;
}
.skeleton {
background: linear-gradient(
90deg,
var(--color-bg) 25%,
var(--color-raised-bg) 50%,
var(--color-bg) 75%
);
background-size: 200% 100%;
animation: wave 1500ms infinite linear;
}
@keyframes wave {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.skin-btn-bg {
background: var(--color-gradient-button-bg);
}
.skin-btn-bg.selected {
background: linear-gradient(
157.61deg,
var(--color-brand) -76.68%,
rgba(27, 217, 106, 0.534) -38.61%,
rgba(12, 89, 44, 0.6) 100.4%
),
var(--color-bg);
}
.skin-btn-bg.selected:hover,
.group:hover .skin-btn-bg.selected {
filter: brightness(1.15);
}
.with-shadow img {
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4));
}
.skin-button__image-parent img {
transition: filter 200ms ease-in-out;
}
.group:hover .skin-button__image-parent img {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
}
.with-shadow img {
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4));
}
</style>

View File

@ -0,0 +1,67 @@
<script setup lang="ts">
import { ref } from 'vue'
withDefaults(
defineProps<{
selected?: boolean
tooltip?: string
}>(),
{
selected: false,
tooltip: undefined,
},
)
const emit = defineEmits<{ (e: 'click', event: MouseEvent): void }>()
const pressed = ref(false)
</script>
<template>
<div
v-tooltip="tooltip ?? undefined"
class="group relative overflow-hidden rounded-xl border-2 transition-all duration-200"
:class="[selected ? 'border-brand' : 'border-transparent hover:border-inverted']"
>
<button
class="skin-btn-bg absolute inset-0 cursor-pointer p-0 border-none group-hover:brightness-125 transition-all duration-200"
:class="selected ? 'selected' : ''"
@mousedown="pressed = true"
@mouseup="pressed = false"
@mouseleave="pressed = false"
@click="(e) => emit('click', e)"
></button>
<div
class="relative w-full h-full flex flex-col items-center justify-center pointer-events-none z-10"
>
<div v-if="$slots.icon" class="mb-2">
<slot name="icon" />
</div>
<span class="text-md text-center px-2 text-primary">
<slot />
</span>
</div>
</div>
</template>
<style scoped lang="scss">
.skin-btn-bg {
background: var(--color-gradient-button-bg);
}
.skin-btn-bg.selected {
background: linear-gradient(
157.61deg,
var(--color-brand) -76.68%,
rgba(27, 217, 106, 0.534) -38.61%,
rgba(12, 89, 44, 0.6) 100.4%
),
var(--color-bg);
}
.skin-btn-bg.selected:hover,
.group:hover .skin-btn-bg.selected {
filter: brightness(1.15);
}
</style>

View File

@ -0,0 +1,666 @@
<template>
<div ref="skinPreviewContainer" class="relative w-full h-full cursor-grab" @click="onCanvasClick">
<div
class="absolute bottom-[18%] left-0 right-0 flex flex-col justify-center items-center mb-2 pointer-events-none z-10 gap-2"
>
<span class="text-primary text-xs px-2 py-1 rounded-full backdrop-blur-sm">
Drag to rotate
</span>
</div>
<div
class="absolute bottom-[10%] left-0 right-0 flex justify-center items-center pointer-events-auto z-10"
>
<slot name="subtitle" />
</div>
<div
v-if="nametag"
class="absolute top-[18%] left-1/2 transform -translate-x-1/2 px-3 py-1 rounded-md pointer-events-none z-10 font-minecraft text-gray nametag-bg transition-all duration-200"
:style="{ fontSize: nametagFontSize }"
>
{{ nametagText }}
</div>
<TresCanvas
shadows
alpha
:antialias="antialias"
:renderer-options="{
outputColorSpace: THREE.SRGBColorSpace,
toneMapping: THREE.NoToneMapping,
toneMappingExposure: 10.0,
}"
class="transition-opacity duration-500"
:class="{ 'opacity-0': !isReady, 'opacity-100': isReady }"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointerleave="onPointerUp"
>
<Suspense>
<Group>
<Group
:rotation="[0, modelRotation, 0]"
:position="[0, -0.05 * scale, 1.95]"
:scale="[0.8 * scale, 0.8 * scale, 0.8 * scale]"
>
<primitive v-if="scene" :object="scene" />
</Group>
<TresMesh
:position="[0, -0.095 * scale, 2]"
:rotation="[-Math.PI / 2, 0, 0]"
:scale="[0.5 * 0.75 * scale, 0.5 * 0.75 * scale, 0.5 * 0.75 * scale]"
>
<TresCircleGeometry :args="[1, 128]" />
<TresMeshBasicMaterial
color="#000000"
:opacity="0.2"
transparent
:depth-write="false"
/>
</TresMesh>
<TresMesh
:position="[0, -0.1 * scale, 2]"
:rotation="[-Math.PI / 2, 0, 0]"
:scale="[0.75 * scale, 0.75 * scale, 0.75 * scale]"
>
<TresCircleGeometry :args="[1, 128]" />
<TresMeshBasicMaterial
:map="radialTexture"
transparent
:depth-write="false"
:blending="THREE.AdditiveBlending"
/>
</TresMesh>
</Group>
</Suspense>
<TresPerspectiveCamera
:make-default.camel="true"
:fov="fov"
:position="[0, 1.5, -3.25]"
:look-at="target"
/>
<TresAmbientLight :intensity="2" />
<TresDirectionalLight :position="[2, 4, 3]" :intensity="1.2" :cast-shadow="true" />
</TresCanvas>
<div
v-if="!isReady"
class="w-full h-full flex items-center justify-center transition-opacity duration-500"
:class="{ 'opacity-100': !isReady, 'opacity-0': isReady }"
>
<div class="text-primary">Loading...</div>
</div>
</div>
</template>
<script setup lang="ts">
import * as THREE from 'three'
import { useGLTF } from '@tresjs/cientos'
import { useTexture, TresCanvas, useRenderLoop } from '@tresjs/core'
import {
shallowRef,
ref,
computed,
watch,
markRaw,
onBeforeMount,
onUnmounted,
toRefs,
useTemplateRef,
} from 'vue'
import {
applyTexture,
applyCapeTexture,
attachCapeToBody,
findBodyNode,
createTransparentTexture,
loadTexture as loadSkinTexture,
} from '@modrinth/utils'
import { useDynamicFontSize } from '../../composables'
interface AnimationConfig {
baseAnimation: string
randomAnimations: string[]
randomAnimationInterval?: number
transitionDuration?: number
}
const props = withDefaults(
defineProps<{
textureSrc: string
slimModelSrc: string
wideModelSrc: string
capeModelSrc?: string
capeSrc?: string
variant?: 'SLIM' | 'CLASSIC' | 'UNKNOWN'
nametag?: string
antialias?: boolean
scale?: number
fov?: number
initialRotation?: number
animationConfig?: AnimationConfig
}>(),
{
variant: 'CLASSIC',
antialias: false,
scale: 1,
fov: 40,
capeModelSrc: '',
capeSrc: undefined,
initialRotation: 15.75,
nametag: undefined,
animationConfig: () => ({
baseAnimation: 'idle',
randomAnimations: ['idle_sub_1', 'idle_sub_2', 'idle_sub_3'],
randomAnimationInterval: 8000,
transitionDuration: 0.2,
}),
},
)
const skinPreviewContainer = useTemplateRef<HTMLElement>('skinPreviewContainer')
const nametagText = computed(() => props.nametag)
const { fontSize: nametagFontSize } = useDynamicFontSize({
containerElement: skinPreviewContainer,
text: nametagText,
baseFontSize: 1.8,
minFontSize: 1.25,
maxFontSize: 2,
padding: 24,
fontFamily: 'inherit',
})
const selectedModelSrc = computed(() =>
props.variant === 'SLIM' ? props.slimModelSrc : props.wideModelSrc,
)
const scene = shallowRef<THREE.Object3D | null>(null)
const capeScene = shallowRef<THREE.Object3D | null>(null)
const bodyNode = shallowRef<THREE.Object3D | null>(null)
const capeAttached = ref(false)
const lastCapeSrc = ref<string | undefined>(undefined)
const texture = shallowRef<THREE.Texture | null>(null)
const capeTexture = shallowRef<THREE.Texture | null>(null)
const transparentTexture = createTransparentTexture()
const isModelLoaded = ref(false)
const isTextureLoaded = ref(false)
const isReady = computed(() => isModelLoaded.value && isTextureLoaded.value)
const mixer = ref<THREE.AnimationMixer | null>(null)
const actions = ref<Record<string, THREE.AnimationAction>>({})
const clock = new THREE.Clock()
const currentAnimation = ref<string>('')
const randomAnimationTimer = ref<number | null>(null)
const lastRandomAnimation = ref<string>('')
const { baseAnimation, randomAnimations } = toRefs(props.animationConfig)
function initializeAnimations(loadedScene: THREE.Object3D, clips: THREE.AnimationClip[]) {
if (!clips || clips.length === 0) {
console.warn('No animation clips found in the model')
return
}
mixer.value = new THREE.AnimationMixer(loadedScene)
actions.value = {}
clips.forEach((clip) => {
const action = mixer.value!.clipAction(clip)
action.setLoop(THREE.LoopOnce, 1)
action.clampWhenFinished = true
actions.value[clip.name] = action
})
if (baseAnimation.value && actions.value[baseAnimation.value]) {
actions.value[baseAnimation.value].setLoop(THREE.LoopRepeat, Infinity)
playAnimation(baseAnimation.value)
setupRandomAnimationLoop()
} else {
console.warn(`Base animation "${baseAnimation.value}" not found`)
const firstAnimationName = Object.keys(actions.value)[0]
if (firstAnimationName) {
actions.value[firstAnimationName].setLoop(THREE.LoopRepeat, Infinity)
playAnimation(firstAnimationName)
}
}
}
function playAnimation(name: string) {
if (!mixer.value || !actions.value[name]) {
console.warn(`Animation "${name}" not found!`)
return false
}
const action = actions.value[name]
if (currentAnimation.value === name && action.isRunning() && name !== baseAnimation.value) {
console.log(`Animation "${name}" is already running, ignoring request`)
return false
}
const transitionDuration = props.animationConfig.transitionDuration || 0.3
Object.entries(actions.value).forEach(([actionName, actionInstance]) => {
if (actionName !== name && actionInstance.isRunning()) {
actionInstance.fadeOut(transitionDuration)
}
})
action.reset()
if (name === baseAnimation.value) {
action.setLoop(THREE.LoopRepeat, Infinity)
} else {
action.setLoop(THREE.LoopOnce, 1)
action.clampWhenFinished = true
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const onFinished = (event: any) => {
if (event.action === action) {
mixer.value?.removeEventListener('finished', onFinished)
if (currentAnimation.value === name && baseAnimation.value) {
action.fadeOut(transitionDuration)
const baseAction = actions.value[baseAnimation.value]
baseAction.reset()
baseAction.fadeIn(transitionDuration)
baseAction.play()
currentAnimation.value = baseAnimation.value
}
}
}
mixer.value.addEventListener('finished', onFinished)
}
action.fadeIn(transitionDuration)
action.play()
currentAnimation.value = name
return true
}
function setupRandomAnimationLoop() {
const interval = props.animationConfig.randomAnimationInterval || 10000
function scheduleNextAnimation() {
if (randomAnimationTimer.value) {
clearTimeout(randomAnimationTimer.value)
}
randomAnimationTimer.value = window.setTimeout(() => {
if (randomAnimations.value.length > 0 && currentAnimation.value === baseAnimation.value) {
const availableAnimations = randomAnimations.value.filter(
(anim) => anim !== lastRandomAnimation.value,
)
// If all animations have been used, reset and use the full list
const animationsToChooseFrom =
availableAnimations.length > 0 ? availableAnimations : randomAnimations.value
const randomIndex = Math.floor(Math.random() * animationsToChooseFrom.length)
const randomAnimationName = animationsToChooseFrom[randomIndex]
if (actions.value[randomAnimationName]) {
lastRandomAnimation.value = randomAnimationName
playRandomAnimation(randomAnimationName)
}
} else {
// If not in base animation, wait and try again
scheduleNextAnimation()
}
}, interval)
}
scheduleNextAnimation()
}
function playRandomAnimation(name: string) {
if (!mixer.value || !actions.value[name]) {
console.warn(`Animation "${name}" not found!`)
return
}
const action = actions.value[name]
if (currentAnimation.value === name && action.isRunning()) {
console.log(`Animation "${name}" is already running, ignoring request`)
return
}
const transitionDuration = props.animationConfig.transitionDuration || 0.3
if (baseAnimation.value && actions.value[baseAnimation.value].isRunning()) {
actions.value[baseAnimation.value].fadeOut(transitionDuration)
}
action.reset()
action.setLoop(THREE.LoopOnce, 1)
action.clampWhenFinished = true
action.fadeIn(transitionDuration)
action.play()
currentAnimation.value = name
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const onFinished = (event: any) => {
if (event.action === action) {
mixer.value?.removeEventListener('finished', onFinished)
if (currentAnimation.value === name && baseAnimation.value) {
action.fadeOut(transitionDuration)
const baseAction = actions.value[baseAnimation.value]
baseAction.reset()
baseAction.fadeIn(transitionDuration)
baseAction.play()
currentAnimation.value = baseAnimation.value
// Schedule the next random animation after returning to base
setupRandomAnimationLoop()
}
}
}
mixer.value.addEventListener('finished', onFinished)
}
function stopAnimations() {
if (mixer.value) {
mixer.value.stopAllAction()
}
currentAnimation.value = ''
}
function getAvailableAnimations(): string[] {
return Object.keys(actions.value)
}
defineExpose({
playAnimation,
stopAnimations,
getAvailableAnimations,
getCurrentAnimation: () => currentAnimation.value,
})
const { onLoop } = useRenderLoop()
onLoop(() => {
if (mixer.value) {
mixer.value.update(clock.getDelta())
}
})
async function loadModel(src: string) {
try {
isModelLoaded.value = false
const { scene: loadedScene, animations } = await useGLTF(src)
scene.value = markRaw(loadedScene)
if (texture.value) {
applyTexture(scene.value, texture.value)
texture.value.needsUpdate = true
}
bodyNode.value = findBodyNode(loadedScene)
capeAttached.value = false
if (animations && animations.length > 0) {
initializeAnimations(loadedScene, animations)
}
updateModelInfo()
isModelLoaded.value = true
} catch (error) {
console.error('Failed to load model:', error)
isModelLoaded.value = false
}
}
async function loadCape(src: string) {
if (!src) {
capeScene.value = null
return
}
try {
const { scene: loadedCape } = await useGLTF(src)
capeScene.value = markRaw(loadedCape)
applyCapeTexture(capeScene.value, capeTexture.value, transparentTexture)
if (bodyNode.value && !capeAttached.value) {
attachCapeToBodyWrapper()
}
} catch (error) {
console.error('Failed to load cape:', error)
capeScene.value = null
}
}
async function loadAndApplyTexture(src: string) {
if (!src) return null
try {
try {
return await loadSkinTexture(src)
} catch {
const tex = await useTexture([src])
tex.colorSpace = THREE.SRGBColorSpace
tex.flipY = false
tex.magFilter = THREE.NearestFilter
tex.minFilter = THREE.NearestFilter
return tex
}
} catch (error) {
console.error('Failed to load texture:', error)
return null
}
}
async function loadAndApplyCapeTexture(src: string | undefined) {
if (src === lastCapeSrc.value) return
lastCapeSrc.value = src
if (src) {
capeTexture.value = await loadAndApplyTexture(src)
} else {
capeTexture.value = null
}
if (capeScene.value) {
applyCapeTexture(capeScene.value, capeTexture.value, transparentTexture)
}
if (capeScene.value && bodyNode.value) {
if (!src && capeAttached.value && capeScene.value.parent) {
capeScene.value.parent.remove(capeScene.value)
capeAttached.value = false
} else if (src && !capeAttached.value) {
attachCapeToBodyWrapper()
}
}
}
function attachCapeToBodyWrapper() {
if (!bodyNode.value || !capeScene.value || capeAttached.value) return
attachCapeToBody(bodyNode.value, capeScene.value)
capeAttached.value = true
}
const centre = ref<[number, number, number]>([0, 1, 0])
const modelHeight = ref(1.4)
function updateModelInfo() {
if (!scene.value) return
try {
const bbox = new THREE.Box3().setFromObject(scene.value)
const mid = new THREE.Vector3()
bbox.getCenter(mid)
centre.value = [mid.x, mid.y, mid.z]
modelHeight.value = bbox.max.y - bbox.min.y
} catch (error) {
console.error('Failed to update model info:', error)
}
}
const target = computed(() => centre.value)
const modelRotation = ref(props.initialRotation + Math.PI)
const isDragging = ref(false)
const previousX = ref(0)
const hasDragged = ref(false)
function onPointerDown(event: PointerEvent) {
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
isDragging.value = true
previousX.value = event.clientX
hasDragged.value = false
}
function onPointerMove(event: PointerEvent) {
if (!isDragging.value) return
const deltaX = event.clientX - previousX.value
modelRotation.value += deltaX * 0.01
previousX.value = event.clientX
hasDragged.value = true
}
function onPointerUp(event: PointerEvent) {
isDragging.value = false
;(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId)
}
function onCanvasClick() {
if (!hasDragged.value) {
if (actions.value['interact']) {
playRandomAnimation('interact')
}
}
hasDragged.value = false
}
const radialTexture = createRadialTexture(512)
radialTexture.minFilter = THREE.LinearFilter
radialTexture.magFilter = THREE.LinearFilter
radialTexture.wrapS = radialTexture.wrapT = THREE.ClampToEdgeWrapping
function createRadialTexture(size: number): THREE.CanvasTexture {
const canvas = document.createElement('canvas')
canvas.width = canvas.height = size
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
const grad = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2)
grad.addColorStop(0, 'rgba(119,119,119,0.1)')
grad.addColorStop(0.9, 'rgba(255,255,255,0)')
ctx.fillStyle = grad
ctx.fillRect(0, 0, size, size)
return new THREE.CanvasTexture(canvas)
}
watch(
[bodyNode, capeScene, isModelLoaded],
([newBodyNode, newCapeScene, modelLoaded]) => {
if (newBodyNode && newCapeScene && modelLoaded && !capeAttached.value) {
attachCapeToBodyWrapper()
}
},
{ immediate: true },
)
watch(capeScene, (newCapeScene) => {
if (newCapeScene && bodyNode.value && isModelLoaded.value && !capeAttached.value) {
attachCapeToBodyWrapper()
}
})
watch(selectedModelSrc, (src) => loadModel(src))
watch(
() => props.capeModelSrc,
(src) => src && loadCape(src),
)
watch(
() => props.textureSrc,
async (newSrc) => {
isTextureLoaded.value = false
texture.value = await loadAndApplyTexture(newSrc)
if (scene.value && texture.value) {
applyTexture(scene.value, texture.value)
}
isTextureLoaded.value = true
},
)
watch(
() => props.capeSrc,
async (newCapeSrc) => {
await loadAndApplyCapeTexture(newCapeSrc)
},
)
watch(
() => props.animationConfig,
(newConfig) => {
if (randomAnimationTimer.value) {
clearTimeout(randomAnimationTimer.value)
randomAnimationTimer.value = null
}
if (mixer.value && newConfig.baseAnimation && actions.value[newConfig.baseAnimation]) {
playAnimation(newConfig.baseAnimation)
setupRandomAnimationLoop()
}
},
{ deep: true },
)
onBeforeMount(async () => {
try {
isTextureLoaded.value = false
texture.value = await loadAndApplyTexture(props.textureSrc)
isTextureLoaded.value = true
await loadModel(selectedModelSrc.value)
if (props.capeSrc) {
await loadAndApplyCapeTexture(props.capeSrc)
}
if (props.capeModelSrc) {
await loadCape(props.capeModelSrc)
}
} catch (error) {
console.error('Failed to initialize skin preview:', error)
}
})
onUnmounted(() => {
if (randomAnimationTimer.value) {
clearTimeout(randomAnimationTimer.value)
}
if (mixer.value) {
mixer.value.stopAllAction()
mixer.value = null
}
})
</script>
<style scoped lang="scss">
.nametag-bg {
background: linear-gradient(
308.68deg,
rgba(50, 50, 50, 0.2) -52.46%,
rgba(100, 100, 100, 0.2) 94.75%
),
rgba(0, 0, 0, 0.2);
box-shadow:
inset -0.5px -0.5px 0px rgba(0, 0, 0, 0.25),
inset 0.5px 0.5px 0px rgba(255, 255, 255, 0.05);
}
</style>

View File

@ -0,0 +1,117 @@
import { computed, onMounted, onUnmounted, type Ref } from 'vue'
import { useElementSize } from '@vueuse/core'
export interface DynamicFontSizeOptions {
containerElement: Ref<HTMLElement | null>
text: Ref<string | undefined>
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,
}
}

View File

@ -1 +1,2 @@
export * from './how-ago'
export * from './dynamic-font-size'

View File

@ -4,3 +4,8 @@ declare module '*.vue' {
const component: ReturnType<typeof defineComponent>
export default component
}
declare module '*.glsl' {
const value: string
export default value
}

View File

@ -8,3 +8,4 @@ export * from './types'
export * from './users'
export * from './utils'
export * from './servers'
export * from './three/skin-rendering'

View File

@ -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"
}
}

View File

@ -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<string, GLTF> = new Map()
const textureCache: Map<string, THREE.Texture> = new Map()
export async function loadModel(modelUrl: string): Promise<GLTF> {
if (modelCache.has(modelUrl)) {
return modelCache.get(modelUrl)!
}
const loader = new GLTFLoader()
return new Promise<GLTF>((resolve, reject) => {
loader.load(
modelUrl,
(gltf) => {
modelCache.set(modelUrl, gltf)
resolve(gltf)
},
undefined,
reject,
)
})
}
export async function loadTexture(
textureUrl: string,
config: SkinRendererConfig = {},
): Promise<THREE.Texture> {
const cacheKey = `${textureUrl}_${JSON.stringify(config)}`
if (textureCache.has(cacheKey)) {
return textureCache.get(cacheKey)!
}
return new Promise<THREE.Texture>((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()
}

View File

@ -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))
}

256
pnpm-lock.yaml generated
View File

@ -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: {}