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:
parent
94a7d13af8
commit
f95d0d78f2
9
Cargo.lock
generated
9
Cargo.lock
generated
@ -8095,6 +8095,7 @@ dependencies = [
|
|||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
|
"uuid 1.17.0",
|
||||||
"webpki-roots 0.26.11",
|
"webpki-roots 0.26.11",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -8177,6 +8178,7 @@ dependencies = [
|
|||||||
"stringprep",
|
"stringprep",
|
||||||
"thiserror 2.0.12",
|
"thiserror 2.0.12",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"uuid 1.17.0",
|
||||||
"whoami",
|
"whoami",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -8216,6 +8218,7 @@ dependencies = [
|
|||||||
"stringprep",
|
"stringprep",
|
||||||
"thiserror 2.0.12",
|
"thiserror 2.0.12",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"uuid 1.17.0",
|
||||||
"whoami",
|
"whoami",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -8242,6 +8245,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.12",
|
"thiserror 2.0.12",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
|
"uuid 1.17.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -9004,11 +9008,13 @@ dependencies = [
|
|||||||
"async-walkdir",
|
"async-walkdir",
|
||||||
"async_zip",
|
"async_zip",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"bytemuck",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chardetng",
|
"chardetng",
|
||||||
"chrono",
|
"chrono",
|
||||||
"daedalus",
|
"daedalus",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
|
"data-url",
|
||||||
"dirs",
|
"dirs",
|
||||||
"discord-rich-presence",
|
"discord-rich-presence",
|
||||||
"dunce",
|
"dunce",
|
||||||
@ -9019,17 +9025,20 @@ dependencies = [
|
|||||||
"fs4",
|
"fs4",
|
||||||
"futures",
|
"futures",
|
||||||
"hashlink",
|
"hashlink",
|
||||||
|
"heck 0.5.0",
|
||||||
"hickory-resolver",
|
"hickory-resolver",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
"notify",
|
"notify",
|
||||||
"notify-debouncer-mini",
|
"notify-debouncer-mini",
|
||||||
"p256",
|
"p256",
|
||||||
"paste",
|
"paste",
|
||||||
|
"png",
|
||||||
"quartz_nbt",
|
"quartz_nbt",
|
||||||
"quick-xml 0.37.5",
|
"quick-xml 0.37.5",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"rgb",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_ini",
|
"serde_ini",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@ -37,6 +37,7 @@ async-tungstenite = { version = "0.29.1", default-features = false, features = [
|
|||||||
async-walkdir = "2.1.0"
|
async-walkdir = "2.1.0"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
bitflags = "2.9.1"
|
bitflags = "2.9.1"
|
||||||
|
bytemuck = "1.23.0"
|
||||||
bytes = "1.10.1"
|
bytes = "1.10.1"
|
||||||
censor = "0.3.0"
|
censor = "0.3.0"
|
||||||
chardetng = "0.1.17"
|
chardetng = "0.1.17"
|
||||||
@ -47,6 +48,7 @@ color-thief = "0.2.2"
|
|||||||
console-subscriber = "0.4.1"
|
console-subscriber = "0.4.1"
|
||||||
daedalus = { path = "packages/daedalus" }
|
daedalus = { path = "packages/daedalus" }
|
||||||
dashmap = "6.1.0"
|
dashmap = "6.1.0"
|
||||||
|
data-url = "0.3.1"
|
||||||
deadpool-redis = "0.21.1"
|
deadpool-redis = "0.21.1"
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
discord-rich-presence = "0.2.5"
|
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 = { version = "0.3.31", default-features = false }
|
||||||
futures-util = "0.3.31"
|
futures-util = "0.3.31"
|
||||||
hashlink = "0.10.0"
|
hashlink = "0.10.0"
|
||||||
|
heck = "0.5.0"
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
hickory-resolver = "0.25.2"
|
hickory-resolver = "0.25.2"
|
||||||
hmac = "0.12.1"
|
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 }
|
notify-debouncer-mini = { version = "0.6.0", default-features = false }
|
||||||
p256 = "0.13.2"
|
p256 = "0.13.2"
|
||||||
paste = "1.0.15"
|
paste = "1.0.15"
|
||||||
|
png = "0.17.16"
|
||||||
prometheus = "0.14.0"
|
prometheus = "0.14.0"
|
||||||
quartz_nbt = "0.2.9"
|
quartz_nbt = "0.2.9"
|
||||||
quick-xml = "0.37.5"
|
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
|
redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32
|
||||||
regex = "1.11.1"
|
regex = "1.11.1"
|
||||||
reqwest = { version = "0.12.20", default-features = false }
|
reqwest = { version = "0.12.20", default-features = false }
|
||||||
|
rgb = "0.8.50"
|
||||||
rust_decimal = { version = "1.37.2", features = [
|
rust_decimal = { version = "1.37.2", features = [
|
||||||
"serde-with-float",
|
"serde-with-float",
|
||||||
"serde-with-str",
|
"serde-with-str",
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
**/dist
|
**/dist
|
||||||
|
*.gltf
|
||||||
|
|||||||
@ -25,12 +25,15 @@
|
|||||||
"@tauri-apps/plugin-os": "^2.2.1",
|
"@tauri-apps/plugin-os": "^2.2.1",
|
||||||
"@tauri-apps/plugin-updater": "^2.7.1",
|
"@tauri-apps/plugin-updater": "^2.7.1",
|
||||||
"@tauri-apps/plugin-window-state": "^2.2.2",
|
"@tauri-apps/plugin-window-state": "^2.2.2",
|
||||||
|
"@types/three": "^0.172.0",
|
||||||
"@vintl/vintl": "^4.4.1",
|
"@vintl/vintl": "^4.4.1",
|
||||||
|
"@vueuse/core": "^11.1.0",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"floating-vue": "^5.2.2",
|
"floating-vue": "^5.2.2",
|
||||||
"ofetch": "^1.3.4",
|
"ofetch": "^1.3.4",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"posthog-js": "^1.158.2",
|
"posthog-js": "^1.158.2",
|
||||||
|
"three": "^0.172.0",
|
||||||
"vite-svg-loader": "^5.1.0",
|
"vite-svg-loader": "^5.1.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-multiselect": "3.0.0",
|
"vue-multiselect": "3.0.0",
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
<script setup>
|
<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 { RouterView, useRoute, useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
ArrowBigUpDashIcon,
|
ArrowBigUpDashIcon,
|
||||||
|
ChangeSkinIcon,
|
||||||
CompassIcon,
|
CompassIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
HomeIcon,
|
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 FriendsList from '@/components/ui/friends/FriendsList.vue'
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
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()
|
const themeStore = useTheming()
|
||||||
|
|
||||||
@ -205,6 +208,14 @@ async function setupApp() {
|
|||||||
get_opening_command().then(handleCommand)
|
get_opening_command().then(handleCommand)
|
||||||
checkUpdates()
|
checkUpdates()
|
||||||
fetchCredentials()
|
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)
|
const stateFailed = ref(false)
|
||||||
@ -312,6 +323,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const accounts = ref(null)
|
const accounts = ref(null)
|
||||||
|
provide('accountsCard', accounts)
|
||||||
|
|
||||||
command_listener(handleCommand)
|
command_listener(handleCommand)
|
||||||
async function handleCommand(e) {
|
async function handleCommand(e) {
|
||||||
@ -407,6 +419,9 @@ function handleAuxClick(e) {
|
|||||||
>
|
>
|
||||||
<CompassIcon />
|
<CompassIcon />
|
||||||
</NavButton>
|
</NavButton>
|
||||||
|
<NavButton v-tooltip.right="'Skins (Beta)'" to="/skins">
|
||||||
|
<ChangeSkinIcon />
|
||||||
|
</NavButton>
|
||||||
<NavButton
|
<NavButton
|
||||||
v-tooltip.right="'Library'"
|
v-tooltip.right="'Library'"
|
||||||
to="/library"
|
to="/library"
|
||||||
|
|||||||
1
apps/app-frontend/src/assets/models/cape.gltf
Normal file
1
apps/app-frontend/src/assets/models/cape.gltf
Normal 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}]}]}
|
||||||
1
apps/app-frontend/src/assets/models/classic_player.gltf
Normal file
1
apps/app-frontend/src/assets/models/classic_player.gltf
Normal file
File diff suppressed because one or more lines are too long
1
apps/app-frontend/src/assets/models/slim_player.gltf
Normal file
1
apps/app-frontend/src/assets/models/slim_player.gltf
Normal file
File diff suppressed because one or more lines are too long
BIN
apps/app-frontend/src/assets/skins/herobrine.png
Normal file
BIN
apps/app-frontend/src/assets/skins/herobrine.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
apps/app-frontend/src/assets/skins/steve.png
Normal file
BIN
apps/app-frontend/src/assets/skins/steve.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
@ -9,13 +9,11 @@
|
|||||||
<Avatar
|
<Avatar
|
||||||
size="36px"
|
size="36px"
|
||||||
:src="
|
:src="
|
||||||
selectedAccount
|
selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||||
? `https://mc-heads.net/avatar/${selectedAccount.id}/128`
|
|
||||||
: 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col w-full">
|
<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>
|
<span class="text-secondary text-xs">Minecraft account</span>
|
||||||
</div>
|
</div>
|
||||||
<DropdownIcon class="w-5 h-5 shrink-0" />
|
<DropdownIcon class="w-5 h-5 shrink-0" />
|
||||||
@ -28,28 +26,40 @@
|
|||||||
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
|
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
|
||||||
>
|
>
|
||||||
<div v-if="selectedAccount" class="selected account">
|
<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>
|
<div>
|
||||||
<h4>{{ selectedAccount.username }}</h4>
|
<h4>{{ selectedAccount.profile.name }}</h4>
|
||||||
<p>Selected</p>
|
<p>Selected</p>
|
||||||
</div>
|
</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 />
|
<TrashIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="logged-out account">
|
<div v-else class="logged-out account">
|
||||||
<h4>Not signed in</h4>
|
<h4>Not signed in</h4>
|
||||||
<Button v-tooltip="'Log in'" icon-only color="primary" @click="login()">
|
<Button
|
||||||
<LogInIcon />
|
v-tooltip="'Log in'"
|
||||||
|
:disabled="loginDisabled"
|
||||||
|
icon-only
|
||||||
|
color="primary"
|
||||||
|
@click="login()"
|
||||||
|
>
|
||||||
|
<LogInIcon v-if="!loginDisabled" />
|
||||||
|
<SpinnerIcon v-else class="animate-spin" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="displayAccounts.length > 0" class="account-group">
|
<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)">
|
<Button class="option account" @click="setAccount(account)">
|
||||||
<Avatar :src="`https://mc-heads.net/avatar/${account.id}/128`" class="icon" />
|
<Avatar :src="getAccountAvatarUrl(account)" class="icon" />
|
||||||
<p>{{ account.username }}</p>
|
<p>{{ account.profile.name }}</p>
|
||||||
</Button>
|
</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 />
|
<TrashIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -63,7 +73,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { Avatar, Button, Card } from '@modrinth/ui'
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
||||||
import {
|
import {
|
||||||
@ -77,6 +87,8 @@ import { handleError } from '@/store/state.js'
|
|||||||
import { trackEvent } from '@/helpers/analytics'
|
import { trackEvent } from '@/helpers/analytics'
|
||||||
import { process_listener } from '@/helpers/events'
|
import { process_listener } from '@/helpers/events'
|
||||||
import { handleSevereError } from '@/store/error.js'
|
import { handleSevereError } from '@/store/error.js'
|
||||||
|
import { get_available_skins } from '@/helpers/skins'
|
||||||
|
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
mode: {
|
mode: {
|
||||||
@ -89,32 +101,86 @@ defineProps({
|
|||||||
const emit = defineEmits(['change'])
|
const emit = defineEmits(['change'])
|
||||||
|
|
||||||
const accounts = ref({})
|
const accounts = ref({})
|
||||||
|
const loginDisabled = ref(false)
|
||||||
const defaultUser = ref()
|
const defaultUser = ref()
|
||||||
|
const equippedSkin = ref(null)
|
||||||
|
const headUrlCache = ref(new Map())
|
||||||
|
|
||||||
async function refreshValues() {
|
async function refreshValues() {
|
||||||
defaultUser.value = await get_default_user().catch(handleError)
|
defaultUser.value = await get_default_user().catch(handleError)
|
||||||
accounts.value = await users().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({
|
defineExpose({
|
||||||
refreshValues,
|
refreshValues,
|
||||||
|
setLoginDisabled,
|
||||||
|
loginDisabled,
|
||||||
})
|
})
|
||||||
await refreshValues()
|
await refreshValues()
|
||||||
|
|
||||||
const displayAccounts = computed(() =>
|
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(() =>
|
const selectedAccount = computed(() =>
|
||||||
accounts.value.find((account) => account.id === defaultUser.value),
|
accounts.value.find((account) => account.profile.id === defaultUser.value),
|
||||||
)
|
)
|
||||||
|
|
||||||
async function setAccount(account) {
|
async function setAccount(account) {
|
||||||
defaultUser.value = account.id
|
defaultUser.value = account.profile.id
|
||||||
await set_default_user(account.id).catch(handleError)
|
await set_default_user(account.profile.id).catch(handleError)
|
||||||
emit('change')
|
emit('change')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
|
loginDisabled.value = true
|
||||||
const loggedIn = await login_flow().catch(handleSevereError)
|
const loggedIn = await login_flow().catch(handleSevereError)
|
||||||
|
|
||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
@ -123,6 +189,7 @@ async function login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
trackEvent('AccountLogIn')
|
trackEvent('AccountLogIn')
|
||||||
|
loginDisabled.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const logout = async (id) => {
|
const logout = async (id) => {
|
||||||
|
|||||||
@ -92,7 +92,7 @@ async function loginMinecraft() {
|
|||||||
const loggedIn = await login_flow()
|
const loggedIn = await login_flow()
|
||||||
|
|
||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
await set_default_user(loggedIn.id).catch(handleError)
|
await set_default_user(loggedIn.profile.id).catch(handleError)
|
||||||
}
|
}
|
||||||
|
|
||||||
await trackEvent('AccountLogIn', { source: 'ErrorModal' })
|
await trackEvent('AccountLogIn', { source: 'ErrorModal' })
|
||||||
@ -219,8 +219,8 @@ async function copyToClipboard(text) {
|
|||||||
<template v-else-if="metadata.notEnoughSpace">
|
<template v-else-if="metadata.notEnoughSpace">
|
||||||
<h3>Not enough space</h3>
|
<h3>Not enough space</h3>
|
||||||
<p>
|
<p>
|
||||||
It looks like there is not enough space on the disk containing the dirctory you
|
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.
|
selected. Please free up some space and try again or cancel the directory change.
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { useTemplateRef } from 'vue'
|
||||||
import { NewModal as Modal } from '@modrinth/ui'
|
import { NewModal as Modal } from '@modrinth/ui'
|
||||||
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
|
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
|
||||||
import { useTheming } from '@/store/theme.ts'
|
import { useTheming } from '@/store/theme.ts'
|
||||||
@ -26,16 +26,16 @@ const props = defineProps({
|
|||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const modal = ref(null)
|
const modal = useTemplateRef('modal')
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
show: () => {
|
show: (e: MouseEvent) => {
|
||||||
hide_ads_window()
|
hide_ads_window()
|
||||||
modal.value.show()
|
modal.value?.show(e)
|
||||||
},
|
},
|
||||||
hide: () => {
|
hide: () => {
|
||||||
onModalHide()
|
onModalHide()
|
||||||
modal.value.hide()
|
modal.value?.hide()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -56,9 +56,17 @@ watch(
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 v-if="os !== 'MacOS'" class="mt-4 flex items-center justify-between gap-4">
|
||||||
<div>
|
<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>
|
<p class="m-0 mt-1">Use system window frame (app restart required).</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle id="native-decorations" v-model="settings.native_decorations" />
|
<Toggle id="native-decorations" v-model="settings.native_decorations" />
|
||||||
|
|||||||
415
apps/app-frontend/src/components/ui/skin/EditSkinModal.vue
Normal file
415
apps/app-frontend/src/components/ui/skin/EditSkinModal.vue
Normal 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>
|
||||||
143
apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue
Normal file
143
apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue
Normal 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>
|
||||||
140
apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue
Normal file
140
apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue
Normal 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>
|
||||||
353
apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts
Normal file
353
apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -37,6 +37,7 @@ export type AppSettings = {
|
|||||||
theme: ColorTheme
|
theme: ColorTheme
|
||||||
default_page: 'home' | 'library'
|
default_page: 'home' | 'library'
|
||||||
collapsed_navigation: boolean
|
collapsed_navigation: boolean
|
||||||
|
hide_nametag_skins_page: boolean
|
||||||
advanced_rendering: boolean
|
advanced_rendering: boolean
|
||||||
native_decorations: boolean
|
native_decorations: boolean
|
||||||
toggle_sidebar: boolean
|
toggle_sidebar: boolean
|
||||||
|
|||||||
163
apps/app-frontend/src/helpers/skins.ts
Normal file
163
apps/app-frontend/src/helpers/skins.ts
Normal 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)
|
||||||
|
}
|
||||||
118
apps/app-frontend/src/helpers/storage/skin-preview-storage.ts
Normal file
118
apps/app-frontend/src/helpers/storage/skin-preview-storage.ts
Normal 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()
|
||||||
@ -10,6 +10,7 @@ import dayjs from 'dayjs'
|
|||||||
import { get_search_results } from '@/helpers/cache.js'
|
import { get_search_results } from '@/helpers/cache.js'
|
||||||
import type { SearchResult } from '@modrinth/utils'
|
import type { SearchResult } from '@modrinth/utils'
|
||||||
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
|
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
|
||||||
|
import type { GameInstance } from '@/helpers/types'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const breadcrumbs = useBreadcrumbs()
|
const breadcrumbs = useBreadcrumbs()
|
||||||
@ -82,13 +83,15 @@ async function refreshFeaturedProjects() {
|
|||||||
await fetchInstances()
|
await fetchInstances()
|
||||||
await refreshFeaturedProjects()
|
await refreshFeaturedProjects()
|
||||||
|
|
||||||
const unlistenProfile = await profile_listener(async (e) => {
|
const unlistenProfile = await profile_listener(
|
||||||
|
async (e: { event: string; profile_path_id: string }) => {
|
||||||
await fetchInstances()
|
await fetchInstances()
|
||||||
|
|
||||||
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
|
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
|
||||||
await refreshFeaturedProjects()
|
await refreshFeaturedProjects()
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
unlistenProfile()
|
unlistenProfile()
|
||||||
@ -97,8 +100,8 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 flex flex-col gap-2">
|
<div class="p-6 flex flex-col gap-2">
|
||||||
<h1 v-if="recentInstances" class="m-0 text-2xl">Welcome back!</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">Welcome to Modrinth App!</h1>
|
<h1 v-else class="m-0 text-2xl font-extrabold">Welcome to Modrinth App!</h1>
|
||||||
<RecentWorldsList :recent-instances="recentInstances" />
|
<RecentWorldsList :recent-instances="recentInstances" />
|
||||||
<RowDisplay
|
<RowDisplay
|
||||||
v-if="hasFeaturedProjects"
|
v-if="hasFeaturedProjects"
|
||||||
|
|||||||
525
apps/app-frontend/src/pages/Skins.vue
Normal file
525
apps/app-frontend/src/pages/Skins.vue
Normal 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>
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import Index from './Index.vue'
|
import Index from './Index.vue'
|
||||||
import Browse from './Browse.vue'
|
import Browse from './Browse.vue'
|
||||||
import Worlds from './Worlds.vue'
|
import Worlds from './Worlds.vue'
|
||||||
|
import Skins from './Skins.vue'
|
||||||
|
|
||||||
export { Index, Browse, Worlds }
|
export { Index, Browse, Worlds, Skins }
|
||||||
|
|||||||
@ -34,6 +34,14 @@ export default new createRouter({
|
|||||||
breadcrumb: [{ name: 'Discover content' }],
|
breadcrumb: [{ name: 'Discover content' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/skins',
|
||||||
|
name: 'Skins',
|
||||||
|
component: Pages.Skins,
|
||||||
|
meta: {
|
||||||
|
breadcrumb: [{ name: 'Skins' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/library',
|
path: '/library',
|
||||||
name: 'Library',
|
name: 'Library',
|
||||||
|
|||||||
@ -41,6 +41,7 @@ export default {
|
|||||||
green: 'var(--color-green-highlight)',
|
green: 'var(--color-green-highlight)',
|
||||||
blue: 'var(--color-blue-highlight)',
|
blue: 'var(--color-blue-highlight)',
|
||||||
purple: 'var(--color-purple-highlight)',
|
purple: 'var(--color-purple-highlight)',
|
||||||
|
gray: 'var(--color-gray-highlight)',
|
||||||
},
|
},
|
||||||
divider: {
|
divider: {
|
||||||
DEFAULT: 'var(--color-divider)',
|
DEFAULT: 'var(--color-divider)',
|
||||||
|
|||||||
@ -27,7 +27,10 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
|
|||||||
|
|
||||||
let credentials = minecraft_auth::finish_login(&input, login).await?;
|
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)
|
Ok(credentials)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -99,6 +99,24 @@ fn main() {
|
|||||||
DefaultPermissionRule::AllowAllCommands,
|
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(
|
.plugin(
|
||||||
"mr-auth",
|
"mr-auth",
|
||||||
InlinedPlugin::new()
|
InlinedPlugin::new()
|
||||||
@ -151,7 +169,6 @@ fn main() {
|
|||||||
"profile_update_managed_modrinth_version",
|
"profile_update_managed_modrinth_version",
|
||||||
"profile_repair_managed_modrinth",
|
"profile_repair_managed_modrinth",
|
||||||
"profile_run",
|
"profile_run",
|
||||||
"profile_run_credentials",
|
|
||||||
"profile_kill",
|
"profile_kill",
|
||||||
"profile_edit",
|
"profile_edit",
|
||||||
"profile_edit_icon",
|
"profile_edit_icon",
|
||||||
|
|||||||
@ -33,6 +33,7 @@
|
|||||||
"jre:default",
|
"jre:default",
|
||||||
"logs:default",
|
"logs:default",
|
||||||
"metadata:default",
|
"metadata:default",
|
||||||
|
"minecraft-skins:default",
|
||||||
"mr-auth:default",
|
"mr-auth:default",
|
||||||
"profile-create:default",
|
"profile-create:default",
|
||||||
"pack:default",
|
"pack:default",
|
||||||
|
|||||||
104
apps/app/src/api/minecraft_skins.rs
Normal file
104
apps/app/src/api/minecraft_skins.rs
Normal 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?)
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ pub mod import;
|
|||||||
pub mod jre;
|
pub mod jre;
|
||||||
pub mod logs;
|
pub mod logs;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
|
pub mod minecraft_skins;
|
||||||
pub mod mr_auth;
|
pub mod mr_auth;
|
||||||
pub mod pack;
|
pub mod pack;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
|
|||||||
@ -28,7 +28,6 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
|||||||
profile_update_managed_modrinth_version,
|
profile_update_managed_modrinth_version,
|
||||||
profile_repair_managed_modrinth,
|
profile_repair_managed_modrinth,
|
||||||
profile_run,
|
profile_run,
|
||||||
profile_run_credentials,
|
|
||||||
profile_kill,
|
profile_kill,
|
||||||
profile_edit,
|
profile_edit,
|
||||||
profile_edit_icon,
|
profile_edit_icon,
|
||||||
@ -256,22 +255,6 @@ pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
|
|||||||
Ok(process)
|
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]
|
#[tauri::command]
|
||||||
pub async fn profile_kill(path: &str) -> Result<()> {
|
pub async fn profile_kill(path: &str) -> Result<()> {
|
||||||
profile::kill(path).await?;
|
profile::kill(path).await?;
|
||||||
|
|||||||
@ -249,6 +249,7 @@ fn main() {
|
|||||||
.plugin(api::logs::init())
|
.plugin(api::logs::init())
|
||||||
.plugin(api::jre::init())
|
.plugin(api::jre::init())
|
||||||
.plugin(api::metadata::init())
|
.plugin(api::metadata::init())
|
||||||
|
.plugin(api::minecraft_skins::init())
|
||||||
.plugin(api::pack::init())
|
.plugin(api::pack::init())
|
||||||
.plugin(api::process::init())
|
.plugin(api::process::init())
|
||||||
.plugin(api::profile::init())
|
.plugin(api::profile::init())
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
"title": "Creator Updates, July 2025",
|
"title": "Creator Updates, July 2025",
|
||||||
"summary": "Addressing recent growth and growing pains that have been affecting creators.",
|
"summary": "Addressing recent growth and growing pains that have been affecting creators.",
|
||||||
"thumbnail": "https://modrinth.com/news/default.webp",
|
"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"
|
"link": "https://modrinth.com/news/article/creator-updates-july-2025"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
12
packages/app-lib/.sqlx/query-27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22.json
generated
Normal file
12
packages/app-lib/.sqlx/query-27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22.json
generated
Normal 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"
|
||||||
|
}
|
||||||
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"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": {
|
"describe": {
|
||||||
"columns": [],
|
"columns": [],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Right": 27
|
"Right": 28
|
||||||
},
|
},
|
||||||
"nullable": []
|
"nullable": []
|
||||||
},
|
},
|
||||||
"hash": "759e4ffe30ebc4f8602256cb419ef15732d84bcebb9ca15225dbabdc0f46ba2d"
|
"hash": "3613473fb4d836ee0fb3c292e6bf5e50912064c29ebf1a1e5ead79c44c37e64c"
|
||||||
}
|
}
|
||||||
12
packages/app-lib/.sqlx/query-3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944.json
generated
Normal file
12
packages/app-lib/.sqlx/query-3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944.json
generated
Normal 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"
|
||||||
|
}
|
||||||
20
packages/app-lib/.sqlx/query-3f3d3c2d77c1bcaf4044b612c3822546583aa19ea7088682d718c64ed5d5f1c5.json
generated
Normal file
20
packages/app-lib/.sqlx/query-3f3d3c2d77c1bcaf4044b612c3822546583aa19ea7088682d718c64ed5d5f1c5.json
generated
Normal 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"
|
||||||
|
}
|
||||||
12
packages/app-lib/.sqlx/query-4c8063f9ce2fd7deec9b69e0b2c1055fe47287d7f99be41215c25c1019d439b9.json
generated
Normal file
12
packages/app-lib/.sqlx/query-4c8063f9ce2fd7deec9b69e0b2c1055fe47287d7f99be41215c25c1019d439b9.json
generated
Normal 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"
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@ -29,113 +29,118 @@
|
|||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "advanced_rendering",
|
"name": "hide_nametag_skins_page",
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "native_decorations",
|
"name": "advanced_rendering",
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "discord_rpc",
|
"name": "native_decorations",
|
||||||
"ordinal": 7,
|
"ordinal": 7,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "developer_mode",
|
"name": "discord_rpc",
|
||||||
"ordinal": 8,
|
"ordinal": 8,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "telemetry",
|
"name": "developer_mode",
|
||||||
"ordinal": 9,
|
"ordinal": 9,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "personalized_ads",
|
"name": "telemetry",
|
||||||
"ordinal": 10,
|
"ordinal": 10,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "onboarded",
|
"name": "personalized_ads",
|
||||||
"ordinal": 11,
|
"ordinal": 11,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "extra_launch_args",
|
"name": "onboarded",
|
||||||
"ordinal": 12,
|
"ordinal": 12,
|
||||||
"type_info": "Text"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "custom_env_vars",
|
"name": "extra_launch_args",
|
||||||
"ordinal": 13,
|
"ordinal": 13,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mc_memory_max",
|
"name": "custom_env_vars",
|
||||||
"ordinal": 14,
|
"ordinal": 14,
|
||||||
"type_info": "Integer"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mc_force_fullscreen",
|
"name": "mc_memory_max",
|
||||||
"ordinal": 15,
|
"ordinal": 15,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mc_game_resolution_x",
|
"name": "mc_force_fullscreen",
|
||||||
"ordinal": 16,
|
"ordinal": 16,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mc_game_resolution_y",
|
"name": "mc_game_resolution_x",
|
||||||
"ordinal": 17,
|
"ordinal": 17,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "hide_on_process_start",
|
"name": "mc_game_resolution_y",
|
||||||
"ordinal": 18,
|
"ordinal": 18,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "hook_pre_launch",
|
"name": "hide_on_process_start",
|
||||||
"ordinal": 19,
|
"ordinal": 19,
|
||||||
"type_info": "Text"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "hook_wrapper",
|
"name": "hook_pre_launch",
|
||||||
"ordinal": 20,
|
"ordinal": 20,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "hook_post_exit",
|
"name": "hook_wrapper",
|
||||||
"ordinal": 21,
|
"ordinal": 21,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "custom_dir",
|
"name": "hook_post_exit",
|
||||||
"ordinal": 22,
|
"ordinal": 22,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "prev_custom_dir",
|
"name": "custom_dir",
|
||||||
"ordinal": 23,
|
"ordinal": 23,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "migrated",
|
"name": "prev_custom_dir",
|
||||||
"ordinal": 24,
|
"ordinal": 24,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "migrated",
|
||||||
|
"ordinal": 25,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "feature_flags",
|
"name": "feature_flags",
|
||||||
"ordinal": 25,
|
"ordinal": 26,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "toggle_sidebar",
|
"name": "toggle_sidebar",
|
||||||
"ordinal": 26,
|
"ordinal": 27,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -155,6 +160,7 @@
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
false,
|
false,
|
||||||
@ -172,5 +178,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "d90a2f2f823fc546661a94af07249758c5ca82db396268bca5087bac88f733d9"
|
"hash": "5193f519f021b2e7013cdb67a6e1a31ae4bd7532d02f8b00b43d5645351941ca"
|
||||||
}
|
}
|
||||||
12
packages/app-lib/.sqlx/query-545b01d8cc1e79ff5d4136887fbb712aba58908a66dd7bbd64c293b9ee7a1523.json
generated
Normal file
12
packages/app-lib/.sqlx/query-545b01d8cc1e79ff5d4136887fbb712aba58908a66dd7bbd64c293b9ee7a1523.json
generated
Normal 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"
|
||||||
|
}
|
||||||
@ -41,7 +41,7 @@
|
|||||||
{
|
{
|
||||||
"name": "display_claims!: serde_json::Value",
|
"name": "display_claims!: serde_json::Value",
|
||||||
"ordinal": 7,
|
"ordinal": 7,
|
||||||
"type_info": "Text"
|
"type_info": "Null"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
20
packages/app-lib/.sqlx/query-957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246.json
generated
Normal file
20
packages/app-lib/.sqlx/query-957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246.json
generated
Normal 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"
|
||||||
|
}
|
||||||
32
packages/app-lib/.sqlx/query-aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac.json
generated
Normal file
32
packages/app-lib/.sqlx/query-aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac.json
generated
Normal 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"
|
||||||
|
}
|
||||||
12
packages/app-lib/.sqlx/query-e9449930a74c6a6151c3d868042b878b6789927df5adf50986fe642c8afcb681.json
generated
Normal file
12
packages/app-lib/.sqlx/query-e9449930a74c6a6151c3d868042b878b6789927df5adf50986fe642c8afcb681.json
generated
Normal 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"
|
||||||
|
}
|
||||||
12
packages/app-lib/.sqlx/query-faa8437519571b147b0135054847674be5035f385e0d85e759d4bbf9bca54f20.json
generated
Normal file
12
packages/app-lib/.sqlx/query-faa8437519571b147b0135054847674be5035f385e0d85e759d4bbf9bca54f20.json
generated
Normal 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"
|
||||||
|
}
|
||||||
12
packages/app-lib/.sqlx/query-fd494269d944b179ade61876669d1821b46d60f5cb79685c489aaebf13b35d24.json
generated
Normal file
12
packages/app-lib/.sqlx/query-fd494269d944b179ade61876669d1821b46d60f5cb79685c489aaebf13b35d24.json
generated
Normal 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"
|
||||||
|
}
|
||||||
@ -5,7 +5,7 @@ authors = ["Jai A <jaiagr+gpg@pm.me>"]
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bytes.workspace = true
|
bytes = { workspace = true, features = ["serde"] }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
serde_ini.workspace = true
|
serde_ini.workspace = true
|
||||||
@ -24,6 +24,9 @@ enumset.workspace = true
|
|||||||
chardetng.workspace = true
|
chardetng.workspace = true
|
||||||
encoding_rs.workspace = true
|
encoding_rs.workspace = true
|
||||||
hashlink.workspace = true
|
hashlink.workspace = true
|
||||||
|
png.workspace = true
|
||||||
|
bytemuck.workspace = true
|
||||||
|
rgb.workspace = true
|
||||||
|
|
||||||
chrono = { workspace = true, features = ["serde"] }
|
chrono = { workspace = true, features = ["serde"] }
|
||||||
daedalus.workspace = true
|
daedalus.workspace = true
|
||||||
@ -33,21 +36,23 @@ regex.workspace = true
|
|||||||
sysinfo = { workspace = true, features = ["system", "disk"] }
|
sysinfo = { workspace = true, features = ["system", "disk"] }
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
either.workspace = true
|
either.workspace = true
|
||||||
|
data-url.workspace = true
|
||||||
|
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber = { workspace = true, features = ["chrono", "env-filter"] }
|
tracing-subscriber = { workspace = true, features = ["chrono", "env-filter"] }
|
||||||
tracing-error.workspace = true
|
tracing-error.workspace = true
|
||||||
|
|
||||||
paste.workspace = true
|
paste.workspace = true
|
||||||
|
heck.workspace = true
|
||||||
|
|
||||||
tauri = { workspace = true, optional = true, features = ["unstable"] }
|
tauri = { workspace = true, optional = true, features = ["unstable"] }
|
||||||
indicatif = { workspace = true, optional = true }
|
indicatif = { workspace = true, optional = true }
|
||||||
|
|
||||||
async-tungstenite = { workspace = true, features = ["tokio-runtime", "tokio-rustls-webpki-roots"] }
|
async-tungstenite = { workspace = true, features = ["tokio-runtime", "tokio-rustls-webpki-roots"] }
|
||||||
futures = { workspace = true, features = ["async-await", "alloc"] }
|
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 = { 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
|
async-recursion.workspace = true
|
||||||
fs4 = { workspace = true, features = ["tokio"] }
|
fs4 = { workspace = true, features = ["tokio"] }
|
||||||
async-walkdir.workspace = true
|
async-walkdir.workspace = true
|
||||||
@ -66,7 +71,7 @@ p256 = { workspace = true, features = ["ecdsa"] }
|
|||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
base64.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"] }
|
quartz_nbt = { workspace = true, features = ["serde"] }
|
||||||
hickory-resolver.workspace = true
|
hickory-resolver.workspace = true
|
||||||
|
|||||||
80
packages/app-lib/migrations/20250413162050_skin-selector.sql
Normal file
80
packages/app-lib/migrations/20250413162050_skin-selector.sql
Normal 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;
|
||||||
@ -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));
|
||||||
@ -39,21 +39,27 @@ pub struct LatestLogCursor {
|
|||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
pub struct CensoredString(String);
|
pub struct CensoredString(String);
|
||||||
impl CensoredString {
|
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();
|
let username = whoami::username();
|
||||||
s = s
|
s = s
|
||||||
.replace(&format!("/{username}/"), "/{COMPUTER_USERNAME}/")
|
.replace(&format!("/{username}/"), "/{COMPUTER_USERNAME}/")
|
||||||
.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
|
s = s
|
||||||
.replace(&credentials.access_token, "{MINECRAFT_ACCESS_TOKEN}")
|
.replace(&credentials.access_token, "{MINECRAFT_ACCESS_TOKEN}")
|
||||||
.replace(&credentials.username, "{MINECRAFT_USERNAME}")
|
|
||||||
.replace(
|
.replace(
|
||||||
&credentials.id.as_simple().to_string(),
|
&credentials.offline_profile.name,
|
||||||
|
"{MINECRAFT_USERNAME}",
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
&credentials.offline_profile.id.as_simple().to_string(),
|
||||||
"{MINECRAFT_UUID}",
|
"{MINECRAFT_UUID}",
|
||||||
)
|
)
|
||||||
.replace(
|
.replace(
|
||||||
&credentials.id.as_hyphenated().to_string(),
|
&credentials.offline_profile.id.as_hyphenated().to_string(),
|
||||||
"{MINECRAFT_UUID}",
|
"{MINECRAFT_UUID}",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -210,7 +216,7 @@ pub async fn get_output_by_filename(
|
|||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|x| x.1)
|
.map(|x| x.1)
|
||||||
.collect();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
// Load .gz file into String
|
// Load .gz file into String
|
||||||
if let Some(ext) = path.extension() {
|
if let Some(ext) = path.extension() {
|
||||||
@ -350,7 +356,7 @@ pub async fn get_generic_live_log_cursor(
|
|||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|x| x.1)
|
.map(|x| x.1)
|
||||||
.collect();
|
.collect::<Vec<_>>();
|
||||||
let output = CensoredString::censor(output, &credentials);
|
let output = CensoredString::censor(output, &credentials);
|
||||||
Ok(LatestLogCursor {
|
Ok(LatestLogCursor {
|
||||||
cursor,
|
cursor,
|
||||||
|
|||||||
@ -23,8 +23,8 @@ pub async fn finish_login(
|
|||||||
#[tracing::instrument]
|
#[tracing::instrument]
|
||||||
pub async fn get_default_user() -> crate::Result<Option<uuid::Uuid>> {
|
pub async fn get_default_user() -> crate::Result<Option<uuid::Uuid>> {
|
||||||
let state = State::get().await?;
|
let state = State::get().await?;
|
||||||
let users = Credentials::get_active(&state.pool).await?;
|
let user = Credentials::get_active(&state.pool).await?;
|
||||||
Ok(users.map(|x| x.id))
|
Ok(user.map(|user| user.offline_profile.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument]
|
#[tracing::instrument]
|
||||||
|
|||||||
530
packages/app-lib/src/api/minecraft_skins.rs
Normal file
530
packages/app-lib/src/api/minecraft_skins.rs
Normal 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(¤t_skin.url),
|
||||||
|
source: SkinSource::CustomExternal,
|
||||||
|
is_equipped: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(available_skins)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a custom skin to the app database and equips it for the currently selected
|
||||||
|
/// Minecraft profile.
|
||||||
|
#[tracing::instrument(skip(texture_blob))]
|
||||||
|
pub async fn add_and_equip_custom_skin(
|
||||||
|
texture_blob: Bytes,
|
||||||
|
variant: MinecraftSkinVariant,
|
||||||
|
cape_override: Option<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 |
@ -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 |
323
packages/app-lib/src/api/minecraft_skins/png_util.rs
Normal file
323
packages/app-lib/src/api/minecraft_skins/png_util.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ pub mod jre;
|
|||||||
pub mod logs;
|
pub mod logs;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
pub mod minecraft_auth;
|
pub mod minecraft_auth;
|
||||||
|
pub mod minecraft_skins;
|
||||||
pub mod mr_auth;
|
pub mod mr_auth;
|
||||||
pub mod pack;
|
pub mod pack;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
|
|||||||
@ -642,9 +642,8 @@ pub async fn run(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Run Minecraft using a profile, and credentials for authentication
|
/// Run Minecraft using a profile, and credentials for authentication
|
||||||
/// Returns Arc pointer to RwLock to Child
|
|
||||||
#[tracing::instrument(skip(credentials))]
|
#[tracing::instrument(skip(credentials))]
|
||||||
pub async fn run_credentials(
|
async fn run_credentials(
|
||||||
path: &str,
|
path: &str,
|
||||||
credentials: &Credentials,
|
credentials: &Credentials,
|
||||||
quick_play_type: &QuickPlayType,
|
quick_play_type: &QuickPlayType,
|
||||||
|
|||||||
@ -24,6 +24,8 @@ pub async fn set(settings: Settings) -> crate::Result<()> {
|
|||||||
|
|
||||||
#[tracing::instrument]
|
#[tracing::instrument]
|
||||||
pub async fn cancel_directory_change() -> crate::Result<()> {
|
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 pool = crate::state::db::connect().await?;
|
||||||
let mut settings = Settings::get(&pool).await?;
|
let mut settings = Settings::get(&pool).await?;
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
//! Theseus error type
|
//! Theseus error type
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::{profile, util};
|
use crate::{profile, util};
|
||||||
|
use data_url::DataUrlError;
|
||||||
use tracing_error::InstrumentError;
|
use tracing_error::InstrumentError;
|
||||||
|
|
||||||
#[derive(thiserror::Error, Debug)]
|
#[derive(thiserror::Error, Debug)]
|
||||||
@ -125,12 +128,35 @@ pub enum ErrorKind {
|
|||||||
|
|
||||||
#[error("Error resolving DNS: {0}")]
|
#[error("Error resolving DNS: {0}")]
|
||||||
DNSError(#[from] hickory_resolver::ResolveError),
|
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)]
|
#[derive(Debug)]
|
||||||
pub struct Error {
|
pub struct Error {
|
||||||
pub raw: std::sync::Arc<ErrorKind>,
|
pub raw: Arc<ErrorKind>,
|
||||||
pub source: tracing_error::TracedError<std::sync::Arc<ErrorKind>>,
|
pub source: tracing_error::TracedError<Arc<ErrorKind>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for Error {
|
impl std::error::Error for Error {
|
||||||
@ -148,7 +174,7 @@ impl std::fmt::Display for Error {
|
|||||||
impl<E: Into<ErrorKind>> From<E> for Error {
|
impl<E: Into<ErrorKind>> From<E> for Error {
|
||||||
fn from(source: E) -> Self {
|
fn from(source: E) -> Self {
|
||||||
let error = Into::<ErrorKind>::into(source);
|
let error = Into::<ErrorKind>::into(source);
|
||||||
let boxed_error = std::sync::Arc::new(error);
|
let boxed_error = Arc::new(error);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
raw: boxed_error.clone(),
|
raw: boxed_error.clone(),
|
||||||
|
|||||||
@ -213,7 +213,7 @@ fn parse_jvm_argument(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn get_minecraft_arguments(
|
pub async fn get_minecraft_arguments(
|
||||||
arguments: Option<&[Argument]>,
|
arguments: Option<&[Argument]>,
|
||||||
legacy_arguments: Option<&str>,
|
legacy_arguments: Option<&str>,
|
||||||
credentials: &Credentials,
|
credentials: &Credentials,
|
||||||
@ -226,6 +226,9 @@ pub fn get_minecraft_arguments(
|
|||||||
java_arch: &str,
|
java_arch: &str,
|
||||||
quick_play_type: &QuickPlayType,
|
quick_play_type: &QuickPlayType,
|
||||||
) -> crate::Result<Vec<String>> {
|
) -> crate::Result<Vec<String>> {
|
||||||
|
let access_token = credentials.access_token.clone();
|
||||||
|
let profile = credentials.maybe_online_profile().await;
|
||||||
|
|
||||||
if let Some(arguments) = arguments {
|
if let Some(arguments) = arguments {
|
||||||
let mut parsed_arguments = Vec::new();
|
let mut parsed_arguments = Vec::new();
|
||||||
|
|
||||||
@ -235,9 +238,9 @@ pub fn get_minecraft_arguments(
|
|||||||
|arg| {
|
|arg| {
|
||||||
parse_minecraft_argument(
|
parse_minecraft_argument(
|
||||||
arg,
|
arg,
|
||||||
&credentials.access_token,
|
&access_token,
|
||||||
&credentials.username,
|
&profile.name,
|
||||||
credentials.id,
|
profile.id,
|
||||||
version,
|
version,
|
||||||
asset_index_name,
|
asset_index_name,
|
||||||
game_directory,
|
game_directory,
|
||||||
@ -257,9 +260,9 @@ pub fn get_minecraft_arguments(
|
|||||||
for x in legacy_arguments.split(' ') {
|
for x in legacy_arguments.split(' ') {
|
||||||
parsed_arguments.push(parse_minecraft_argument(
|
parsed_arguments.push(parse_minecraft_argument(
|
||||||
&x.replace(' ', TEMPORARY_REPLACE_CHAR),
|
&x.replace(' ', TEMPORARY_REPLACE_CHAR),
|
||||||
&credentials.access_token,
|
&access_token,
|
||||||
&credentials.username,
|
&profile.name,
|
||||||
credentials.id,
|
profile.id,
|
||||||
version,
|
version,
|
||||||
asset_index_name,
|
asset_index_name,
|
||||||
game_directory,
|
game_directory,
|
||||||
|
|||||||
@ -641,7 +641,8 @@ pub async fn launch_minecraft(
|
|||||||
*resolution,
|
*resolution,
|
||||||
&java_version.architecture,
|
&java_version.architecture,
|
||||||
quick_play_type,
|
quick_play_type,
|
||||||
)?
|
)
|
||||||
|
.await?
|
||||||
.into_iter(),
|
.into_iter(),
|
||||||
)
|
)
|
||||||
.current_dir(instance_path.clone());
|
.current_dir(instance_path.clone());
|
||||||
@ -651,7 +652,7 @@ pub async fn launch_minecraft(
|
|||||||
if std::env::var("CARGO").is_ok() {
|
if std::env::var("CARGO").is_ok() {
|
||||||
command.env_remove("DYLD_FALLBACK_LIBRARY_PATH");
|
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.env_remove("_JAVA_OPTIONS");
|
||||||
|
|
||||||
command.envs(env_args);
|
command.envs(env_args);
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
use crate::state::DirectoryInfo;
|
use crate::state::DirectoryInfo;
|
||||||
use sqlx::migrate::MigrateDatabase;
|
|
||||||
use sqlx::sqlite::{
|
use sqlx::sqlite::{
|
||||||
SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions,
|
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());
|
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)?
|
let conn_options = SqliteConnectOptions::from_str(&uri)?
|
||||||
.busy_timeout(Duration::from_secs(30))
|
.busy_timeout(Duration::from_secs(30))
|
||||||
.journal_mode(SqliteJournalMode::Wal)
|
.journal_mode(SqliteJournalMode::Wal)
|
||||||
.optimize_on_close(true, None);
|
.optimize_on_close(true, None)
|
||||||
|
.create_if_missing(true);
|
||||||
|
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
.max_connections(100)
|
.max_connections(100)
|
||||||
@ -36,5 +32,33 @@ pub(crate) async fn connect() -> crate::Result<Pool<Sqlite>> {
|
|||||||
|
|
||||||
sqlx::migrate!().run(&pool).await?;
|
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)
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
@ -19,6 +19,8 @@ use std::path::PathBuf;
|
|||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::MinecraftProfile;
|
||||||
|
|
||||||
pub async fn migrate_legacy_data<'a, E>(exec: E) -> crate::Result<()>
|
pub async fn migrate_legacy_data<'a, E>(exec: E) -> crate::Result<()>
|
||||||
where
|
where
|
||||||
E: sqlx::Executor<'a, Database = sqlx::Sqlite> + Copy,
|
E: sqlx::Executor<'a, Database = sqlx::Sqlite> + Copy,
|
||||||
@ -117,13 +119,16 @@ where
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
let minecraft_users_len = minecraft_auth.users.len();
|
let minecraft_users_len = minecraft_auth.users.len();
|
||||||
for (uuid, credential) in minecraft_auth.users {
|
for (uuid, legacy_credentials) in minecraft_auth.users {
|
||||||
Credentials {
|
Credentials {
|
||||||
id: credential.id,
|
offline_profile: MinecraftProfile {
|
||||||
username: credential.username,
|
id: legacy_credentials.id,
|
||||||
access_token: credential.access_token,
|
name: legacy_credentials.username,
|
||||||
refresh_token: credential.refresh_token,
|
..MinecraftProfile::default()
|
||||||
expires: credential.expires,
|
},
|
||||||
|
access_token: legacy_credentials.access_token,
|
||||||
|
refresh_token: legacy_credentials.refresh_token,
|
||||||
|
expires: legacy_credentials.expires,
|
||||||
active: minecraft_auth.default_user == Some(uuid)
|
active: minecraft_auth.default_user == Some(uuid)
|
||||||
|| minecraft_users_len == 1,
|
|| minecraft_users_len == 1,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,25 +5,38 @@ use base64::prelude::{BASE64_STANDARD, BASE64_URL_SAFE_NO_PAD};
|
|||||||
use chrono::{DateTime, Duration, TimeZone, Utc};
|
use chrono::{DateTime, Duration, TimeZone, Utc};
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
|
use heck::ToTitleCase;
|
||||||
use p256::ecdsa::signature::Signer;
|
use p256::ecdsa::signature::Signer;
|
||||||
use p256::ecdsa::{Signature, SigningKey, VerifyingKey};
|
use p256::ecdsa::{Signature, SigningKey, VerifyingKey};
|
||||||
use p256::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding};
|
use p256::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding};
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use reqwest::Response;
|
|
||||||
use reqwest::header::HeaderMap;
|
use reqwest::header::HeaderMap;
|
||||||
|
use reqwest::{Response, StatusCode};
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::ser::SerializeStruct;
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use sha2::Digest;
|
use sha2::Digest;
|
||||||
|
use std::borrow::Cow;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::collections::hash_map::Entry;
|
||||||
use std::future::Future;
|
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;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub enum MinecraftAuthStep {
|
pub enum MinecraftAuthStep {
|
||||||
GetDeviceToken,
|
GetDeviceToken,
|
||||||
SisuAuthenicate,
|
SisuAuthenticate,
|
||||||
GetOAuthToken,
|
GetOAuthToken,
|
||||||
RefreshOAuthToken,
|
RefreshOAuthToken,
|
||||||
SisuAuthorize,
|
SisuAuthorize,
|
||||||
@ -53,7 +66,7 @@ pub enum MinecraftAuthenticationError {
|
|||||||
raw: String,
|
raw: String,
|
||||||
#[source]
|
#[source]
|
||||||
source: serde_json::Error,
|
source: serde_json::Error,
|
||||||
status_code: reqwest::StatusCode,
|
status_code: StatusCode,
|
||||||
},
|
},
|
||||||
#[error("Request failed during step {step:?}: {source}")]
|
#[error("Request failed during step {step:?}: {source}")]
|
||||||
Request {
|
Request {
|
||||||
@ -172,36 +185,87 @@ pub async fn login_finish(
|
|||||||
minecraft_entitlements(&minecraft_token.access_token).await?;
|
minecraft_entitlements(&minecraft_token.access_token).await?;
|
||||||
|
|
||||||
let mut credentials = Credentials {
|
let mut credentials = Credentials {
|
||||||
id: Uuid::default(),
|
offline_profile: MinecraftProfile::default(),
|
||||||
username: String::default(),
|
|
||||||
access_token: minecraft_token.access_token,
|
access_token: minecraft_token.access_token,
|
||||||
refresh_token: oauth_token.value.refresh_token,
|
refresh_token: oauth_token.value.refresh_token,
|
||||||
expires: oauth_token.date
|
expires: oauth_token.date
|
||||||
+ Duration::seconds(oauth_token.value.expires_in as i64),
|
+ Duration::seconds(oauth_token.value.expires_in as i64),
|
||||||
active: true,
|
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?;
|
credentials.upsert(exec).await?;
|
||||||
|
|
||||||
Ok(credentials)
|
Ok(credentials)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
pub struct Credentials {
|
pub struct Credentials {
|
||||||
pub id: Uuid,
|
/// The offline profile of the user these credentials are for.
|
||||||
pub username: String,
|
///
|
||||||
|
/// 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 access_token: String,
|
||||||
pub refresh_token: String,
|
pub refresh_token: String,
|
||||||
pub expires: DateTime<Utc>,
|
pub expires: DateTime<Utc>,
|
||||||
pub active: bool,
|
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 {
|
impl Credentials {
|
||||||
|
/// Refreshes the authentication tokens for this user if they are expired, or
|
||||||
|
/// very close to expiration.
|
||||||
async fn refresh(
|
async fn refresh(
|
||||||
&mut self,
|
&mut self,
|
||||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||||
) -> crate::Result<()> {
|
) -> 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 oauth_token = oauth_refresh(&self.refresh_token).await?;
|
||||||
let (pair, current_date, _) =
|
let (pair, current_date, _) =
|
||||||
DeviceTokenPair::refresh_and_get_device_token(
|
DeviceTokenPair::refresh_and_get_device_token(
|
||||||
@ -235,22 +299,118 @@ impl Credentials {
|
|||||||
self.expires = oauth_token.date
|
self.expires = oauth_token.date
|
||||||
+ Duration::seconds(oauth_token.value.expires_in as i64);
|
+ Duration::seconds(oauth_token.value.expires_in as i64);
|
||||||
|
|
||||||
self.get_profile().await?;
|
|
||||||
|
|
||||||
self.upsert(exec).await?;
|
self.upsert(exec).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_profile(&mut self) -> crate::Result<()> {
|
#[tracing::instrument(skip(self))]
|
||||||
let profile = minecraft_profile(&self.access_token).await?;
|
pub async fn online_profile(&self) -> Option<Arc<MinecraftProfile>> {
|
||||||
|
let mut profile_cache = PROFILE_CACHE.lock().await;
|
||||||
|
|
||||||
self.id = profile.id.unwrap_or_default();
|
loop {
|
||||||
self.username = profile.name;
|
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]
|
#[tracing::instrument]
|
||||||
pub async fn get_default_credential(
|
pub async fn get_default_credential(
|
||||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||||
@ -258,7 +418,6 @@ impl Credentials {
|
|||||||
let credentials = Self::get_active(exec).await?;
|
let credentials = Self::get_active(exec).await?;
|
||||||
|
|
||||||
if let Some(mut creds) = credentials {
|
if let Some(mut creds) = credentials {
|
||||||
if creds.expires < Utc::now() {
|
|
||||||
let res = creds.refresh(exec).await;
|
let res = creds.refresh(exec).await;
|
||||||
|
|
||||||
match res {
|
match res {
|
||||||
@ -279,16 +438,15 @@ impl Credentials {
|
|||||||
Err(err)
|
Err(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Ok(Some(creds))
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetches the currently selected credentials from the database, attempting
|
||||||
|
/// to refresh them if they are expired.
|
||||||
pub async fn get_active(
|
pub async fn get_active(
|
||||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||||
) -> crate::Result<Option<Self>> {
|
) -> crate::Result<Option<Self>> {
|
||||||
let res = sqlx::query!(
|
let res = sqlx::query!(
|
||||||
"
|
"
|
||||||
@ -301,9 +459,14 @@ impl Credentials {
|
|||||||
.fetch_optional(exec)
|
.fetch_optional(exec)
|
||||||
.await?;
|
.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(),
|
id: Uuid::parse_str(&x.uuid).unwrap_or_default(),
|
||||||
username: x.username,
|
name: x.username,
|
||||||
|
..MinecraftProfile::default()
|
||||||
|
},
|
||||||
access_token: x.access_token,
|
access_token: x.access_token,
|
||||||
refresh_token: x.refresh_token,
|
refresh_token: x.refresh_token,
|
||||||
expires: Utc
|
expires: Utc
|
||||||
@ -311,11 +474,16 @@ impl Credentials {
|
|||||||
.single()
|
.single()
|
||||||
.unwrap_or_else(Utc::now),
|
.unwrap_or_else(Utc::now),
|
||||||
active: x.active == 1,
|
active: x.active == 1,
|
||||||
}))
|
};
|
||||||
|
credentials.refresh(exec).await.ok();
|
||||||
|
Some(credentials)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_all(
|
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>> {
|
) -> crate::Result<DashMap<Uuid, Self>> {
|
||||||
let res = sqlx::query!(
|
let res = sqlx::query!(
|
||||||
"
|
"
|
||||||
@ -327,12 +495,12 @@ impl Credentials {
|
|||||||
.fetch(exec)
|
.fetch(exec)
|
||||||
.try_fold(DashMap::new(), |acc, x| {
|
.try_fold(DashMap::new(), |acc, x| {
|
||||||
let uuid = Uuid::parse_str(&x.uuid).unwrap_or_default();
|
let uuid = Uuid::parse_str(&x.uuid).unwrap_or_default();
|
||||||
|
let mut credentials = Self {
|
||||||
acc.insert(
|
offline_profile: MinecraftProfile {
|
||||||
uuid,
|
|
||||||
Self {
|
|
||||||
id: uuid,
|
id: uuid,
|
||||||
username: x.username,
|
name: x.username,
|
||||||
|
..MinecraftProfile::default()
|
||||||
|
},
|
||||||
access_token: x.access_token,
|
access_token: x.access_token,
|
||||||
refresh_token: x.refresh_token,
|
refresh_token: x.refresh_token,
|
||||||
expires: Utc
|
expires: Utc
|
||||||
@ -340,10 +508,14 @@ impl Credentials {
|
|||||||
.single()
|
.single()
|
||||||
.unwrap_or_else(Utc::now),
|
.unwrap_or_else(Utc::now),
|
||||||
active: x.active == 1,
|
active: x.active == 1,
|
||||||
},
|
};
|
||||||
);
|
|
||||||
|
|
||||||
async move { Ok(acc) }
|
async move {
|
||||||
|
credentials.refresh(exec).await.ok();
|
||||||
|
acc.insert(uuid, credentials);
|
||||||
|
|
||||||
|
Ok(acc)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@ -354,8 +526,9 @@ impl Credentials {
|
|||||||
&self,
|
&self,
|
||||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||||
) -> crate::Result<()> {
|
) -> crate::Result<()> {
|
||||||
|
let profile = self.maybe_online_profile().await;
|
||||||
let expires = self.expires.timestamp();
|
let expires = self.expires.timestamp();
|
||||||
let uuid = self.id.as_hyphenated().to_string();
|
let uuid = profile.id.as_hyphenated().to_string();
|
||||||
|
|
||||||
if self.active {
|
if self.active {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
@ -381,7 +554,7 @@ impl Credentials {
|
|||||||
",
|
",
|
||||||
uuid,
|
uuid,
|
||||||
self.active,
|
self.active,
|
||||||
self.username,
|
profile.name,
|
||||||
self.access_token,
|
self.access_token,
|
||||||
self.refresh_token,
|
self.refresh_token,
|
||||||
expires,
|
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 struct DeviceTokenPair {
|
||||||
pub token: DeviceToken,
|
pub token: DeviceToken,
|
||||||
pub key: DeviceTokenKey,
|
pub key: DeviceTokenKey,
|
||||||
@ -639,7 +852,7 @@ async fn sisu_authenticate(
|
|||||||
"TitleId": "1794566092",
|
"TitleId": "1794566092",
|
||||||
}),
|
}),
|
||||||
key,
|
key,
|
||||||
MinecraftAuthStep::SisuAuthenicate,
|
MinecraftAuthStep::SisuAuthenticate,
|
||||||
current_date,
|
current_date,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@ -911,13 +1124,197 @@ async fn minecraft_token(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(
|
||||||
struct MinecraftProfile {
|
sqlx::Type, Deserialize, Serialize, Debug, Copy, Clone, PartialEq, Eq,
|
||||||
pub id: Option<Uuid>,
|
)]
|
||||||
pub name: String,
|
#[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(
|
async fn minecraft_profile(
|
||||||
token: &str,
|
token: &str,
|
||||||
) -> Result<MinecraftProfile, MinecraftAuthenticationError> {
|
) -> Result<MinecraftProfile, MinecraftAuthenticationError> {
|
||||||
@ -926,6 +1323,9 @@ async fn minecraft_profile(
|
|||||||
.get("https://api.minecraftservices.com/minecraft/profile")
|
.get("https://api.minecraftservices.com/minecraft/profile")
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.bearer_auth(token)
|
.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()
|
.send()
|
||||||
})
|
})
|
||||||
.await
|
.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 {
|
MinecraftAuthenticationError::DeserializeResponse {
|
||||||
source,
|
source,
|
||||||
raw: text,
|
raw: text,
|
||||||
step: MinecraftAuthStep::MinecraftProfile,
|
step: MinecraftAuthStep::MinecraftProfile,
|
||||||
status_code: status,
|
status_code: status,
|
||||||
}
|
}
|
||||||
})
|
})?;
|
||||||
|
profile.fetch_time = Some(Instant::now());
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"Successfully fetched Minecraft profile for {}",
|
||||||
|
profile.name
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
180
packages/app-lib/src/state/minecraft_skins/mod.rs
Normal file
180
packages/app-lib/src/state/minecraft_skins/mod.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
142
packages/app-lib/src/state/minecraft_skins/mojang_api.rs
Normal file
142
packages/app-lib/src/state/minecraft_skins/mojang_api.rs
Normal 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)));
|
||||||
|
}
|
||||||
@ -28,6 +28,8 @@ pub use self::discord::*;
|
|||||||
mod minecraft_auth;
|
mod minecraft_auth;
|
||||||
pub use self::minecraft_auth::*;
|
pub use self::minecraft_auth::*;
|
||||||
|
|
||||||
|
pub mod minecraft_skins;
|
||||||
|
|
||||||
mod cache;
|
mod cache;
|
||||||
pub use self::cache::*;
|
pub use self::cache::*;
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ pub struct Settings {
|
|||||||
pub theme: Theme,
|
pub theme: Theme,
|
||||||
pub default_page: DefaultPage,
|
pub default_page: DefaultPage,
|
||||||
pub collapsed_navigation: bool,
|
pub collapsed_navigation: bool,
|
||||||
|
pub hide_nametag_skins_page: bool,
|
||||||
pub advanced_rendering: bool,
|
pub advanced_rendering: bool,
|
||||||
pub native_decorations: bool,
|
pub native_decorations: bool,
|
||||||
pub toggle_sidebar: bool,
|
pub toggle_sidebar: bool,
|
||||||
@ -56,7 +57,7 @@ impl Settings {
|
|||||||
"
|
"
|
||||||
SELECT
|
SELECT
|
||||||
max_concurrent_writes, max_concurrent_downloads,
|
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,
|
discord_rpc, developer_mode, telemetry, personalized_ads,
|
||||||
onboarded,
|
onboarded,
|
||||||
json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,
|
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),
|
theme: Theme::from_string(&res.theme),
|
||||||
default_page: DefaultPage::from_string(&res.default_page),
|
default_page: DefaultPage::from_string(&res.default_page),
|
||||||
collapsed_navigation: res.collapsed_navigation == 1,
|
collapsed_navigation: res.collapsed_navigation == 1,
|
||||||
|
hide_nametag_skins_page: res.hide_nametag_skins_page == 1,
|
||||||
advanced_rendering: res.advanced_rendering == 1,
|
advanced_rendering: res.advanced_rendering == 1,
|
||||||
native_decorations: res.native_decorations == 1,
|
native_decorations: res.native_decorations == 1,
|
||||||
toggle_sidebar: res.toggle_sidebar == 1,
|
toggle_sidebar: res.toggle_sidebar == 1,
|
||||||
@ -167,7 +169,8 @@ impl Settings {
|
|||||||
migrated = $25,
|
migrated = $25,
|
||||||
|
|
||||||
toggle_sidebar = $26,
|
toggle_sidebar = $26,
|
||||||
feature_flags = $27
|
feature_flags = $27,
|
||||||
|
hide_nametag_skins_page = $28
|
||||||
",
|
",
|
||||||
max_concurrent_writes,
|
max_concurrent_writes,
|
||||||
max_concurrent_downloads,
|
max_concurrent_downloads,
|
||||||
@ -195,7 +198,8 @@ impl Settings {
|
|||||||
self.prev_custom_dir,
|
self.prev_custom_dir,
|
||||||
self.migrated,
|
self.migrated,
|
||||||
self.toggle_sidebar,
|
self.toggle_sidebar,
|
||||||
feature_flags
|
feature_flags,
|
||||||
|
self.hide_nametag_skins_page
|
||||||
)
|
)
|
||||||
.execute(exec)
|
.execute(exec)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import _BoxIcon from './icons/box.svg?component'
|
|||||||
import _BracesIcon from './icons/braces.svg?component'
|
import _BracesIcon from './icons/braces.svg?component'
|
||||||
import _CalendarIcon from './icons/calendar.svg?component'
|
import _CalendarIcon from './icons/calendar.svg?component'
|
||||||
import _CardIcon from './icons/card.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 _ChartIcon from './icons/chart.svg?component'
|
||||||
import _CheckCheckIcon from './icons/check-check.svg?component'
|
import _CheckCheckIcon from './icons/check-check.svg?component'
|
||||||
import _CheckCircleIcon from './icons/check-circle.svg?component'
|
import _CheckCircleIcon from './icons/check-circle.svg?component'
|
||||||
@ -207,6 +208,7 @@ export const BoxIcon = _BoxIcon
|
|||||||
export const BracesIcon = _BracesIcon
|
export const BracesIcon = _BracesIcon
|
||||||
export const CalendarIcon = _CalendarIcon
|
export const CalendarIcon = _CalendarIcon
|
||||||
export const CardIcon = _CardIcon
|
export const CardIcon = _CardIcon
|
||||||
|
export const ChangeSkinIcon = _ChangeSkinIcon
|
||||||
export const ChartIcon = _ChartIcon
|
export const ChartIcon = _ChartIcon
|
||||||
export const CheckCheckIcon = _CheckCheckIcon
|
export const CheckCheckIcon = _CheckCheckIcon
|
||||||
export const CheckCircleIcon = _CheckCircleIcon
|
export const CheckCircleIcon = _CheckCircleIcon
|
||||||
|
|||||||
5
packages/assets/icons/change-skin.svg
Normal file
5
packages/assets/icons/change-skin.svg
Normal 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 |
@ -68,6 +68,8 @@
|
|||||||
--color-button-bg-selected: var(--color-brand);
|
--color-button-bg-selected: var(--color-brand);
|
||||||
--color-button-text-selected: var(--color-accent-contrast);
|
--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%);
|
--loading-bar-gradient: linear-gradient(to right, var(--color-brand) 0%, #00af5c 100%);
|
||||||
|
|
||||||
--color-platform-fabric: #8a7b71;
|
--color-platform-fabric: #8a7b71;
|
||||||
@ -186,6 +188,8 @@ html {
|
|||||||
--color-button-bg-selected: var(--color-brand-highlight);
|
--color-button-bg-selected: var(--color-brand-highlight);
|
||||||
--color-button-text-selected: var(--color-brand);
|
--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%);
|
--loading-bar-gradient: linear-gradient(to right, var(--color-brand) 0%, #1ffa9a 100%);
|
||||||
|
|
||||||
--color-platform-fabric: #dbb69b;
|
--color-platform-fabric: #dbb69b;
|
||||||
@ -230,6 +234,8 @@ html {
|
|||||||
rgba(9, 18, 14, 0.6) 10%,
|
rgba(9, 18, 14, 0.6) 10%,
|
||||||
rgba(19, 31, 23, 0.5) 100%
|
rgba(19, 31, 23, 0.5) 100%
|
||||||
);
|
);
|
||||||
|
|
||||||
|
--color-gradient-button-bg: linear-gradient(180deg, #1b1b20 0%, #25262b 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.retro-mode {
|
.retro-mode {
|
||||||
|
|||||||
@ -3,7 +3,7 @@ export const article = {
|
|||||||
html: () => import(`./creator_updates_july_2025.content`).then((m) => m.html),
|
html: () => import(`./creator_updates_july_2025.content`).then((m) => m.html),
|
||||||
title: 'Creator Updates, July 2025',
|
title: 'Creator Updates, July 2025',
|
||||||
summary: 'Addressing recent growth and growing pains that have been affecting creators.',
|
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',
|
slug: 'creator-updates-july-2025',
|
||||||
thumbnail: false,
|
thumbnail: false,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,14 +30,19 @@
|
|||||||
"@codemirror/view": "^6.22.1",
|
"@codemirror/view": "^6.22.1",
|
||||||
"@modrinth/assets": "workspace:*",
|
"@modrinth/assets": "workspace:*",
|
||||||
"@modrinth/utils": "workspace:*",
|
"@modrinth/utils": "workspace:*",
|
||||||
|
"@tresjs/cientos": "^4.3.0",
|
||||||
|
"@tresjs/core": "^4.3.4",
|
||||||
"@types/markdown-it": "^14.1.1",
|
"@types/markdown-it": "^14.1.1",
|
||||||
|
"@types/three": "^0.172.0",
|
||||||
"@vintl/how-ago": "^3.0.1",
|
"@vintl/how-ago": "^3.0.1",
|
||||||
|
"@vueuse/core": "^11.1.0",
|
||||||
"apexcharts": "^3.44.0",
|
"apexcharts": "^3.44.0",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"floating-vue": "^5.2.2",
|
"floating-vue": "^5.2.2",
|
||||||
"highlight.js": "^11.9.0",
|
"highlight.js": "^11.9.0",
|
||||||
"markdown-it": "^13.0.2",
|
"markdown-it": "^13.0.2",
|
||||||
"qrcode.vue": "^3.4.1",
|
"qrcode.vue": "^3.4.1",
|
||||||
|
"three": "^0.172.0",
|
||||||
"vue-multiselect": "3.0.0",
|
"vue-multiselect": "3.0.0",
|
||||||
"vue-select": "4.0.0-beta.6",
|
"vue-select": "4.0.0-beta.6",
|
||||||
"vue-typed-virtual-list": "^1.0.10",
|
"vue-typed-virtual-list": "^1.0.10",
|
||||||
|
|||||||
@ -55,6 +55,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
function updateFade(scrollTop, offsetHeight, scrollHeight) {
|
function updateFade(scrollTop, offsetHeight, scrollHeight) {
|
||||||
|
console.log(scrollTop, offsetHeight, scrollHeight)
|
||||||
scrollableAtBottom.value = Math.ceil(scrollTop + offsetHeight) >= scrollHeight
|
scrollableAtBottom.value = Math.ceil(scrollTop + offsetHeight) >= scrollHeight
|
||||||
scrollableAtTop.value = scrollTop <= 0
|
scrollableAtTop.value = scrollTop <= 0
|
||||||
}
|
}
|
||||||
@ -64,6 +65,18 @@ function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<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 {
|
.scrollable-pane-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -75,27 +88,25 @@ function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
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 {
|
&.top-fade {
|
||||||
mask-image: linear-gradient(transparent, rgb(0 0 0 / 100%) var(--_fade-height));
|
--_top-fade-height: var(--_fade-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.bottom-fade {
|
&.bottom-fade {
|
||||||
mask-image: linear-gradient(
|
--_bottom-fade-height: var(--_fade-height);
|
||||||
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%
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scrollable-pane {
|
.scrollable-pane {
|
||||||
|
|||||||
@ -102,6 +102,13 @@ export { default as PurchaseModal } from './billing/PurchaseModal.vue'
|
|||||||
export { default as AddPaymentMethodModal } from './billing/AddPaymentMethodModal.vue'
|
export { default as AddPaymentMethodModal } from './billing/AddPaymentMethodModal.vue'
|
||||||
export { default as ModrinthServersPurchaseModal } from './billing/ModrinthServersPurchaseModal.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
|
// Version
|
||||||
export { default as VersionChannelIndicator } from './version/VersionChannelIndicator.vue'
|
export { default as VersionChannelIndicator } from './version/VersionChannelIndicator.vue'
|
||||||
export { default as VersionFilterControl } from './version/VersionFilterControl.vue'
|
export { default as VersionFilterControl } from './version/VersionFilterControl.vue'
|
||||||
|
|||||||
108
packages/ui/src/components/skin/CapeButton.vue
Normal file
108
packages/ui/src/components/skin/CapeButton.vue
Normal 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>
|
||||||
63
packages/ui/src/components/skin/CapeLikeTextButton.vue
Normal file
63
packages/ui/src/components/skin/CapeLikeTextButton.vue
Normal 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>
|
||||||
142
packages/ui/src/components/skin/SkinButton.vue
Normal file
142
packages/ui/src/components/skin/SkinButton.vue
Normal 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>
|
||||||
67
packages/ui/src/components/skin/SkinLikeTextButton.vue
Normal file
67
packages/ui/src/components/skin/SkinLikeTextButton.vue
Normal 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>
|
||||||
666
packages/ui/src/components/skin/SkinPreviewRenderer.vue
Normal file
666
packages/ui/src/components/skin/SkinPreviewRenderer.vue
Normal 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>
|
||||||
117
packages/ui/src/composables/dynamic-font-size.ts
Normal file
117
packages/ui/src/composables/dynamic-font-size.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1 +1,2 @@
|
|||||||
export * from './how-ago'
|
export * from './how-ago'
|
||||||
|
export * from './dynamic-font-size'
|
||||||
|
|||||||
5
packages/ui/src/vue-shims.d.ts
vendored
5
packages/ui/src/vue-shims.d.ts
vendored
@ -4,3 +4,8 @@ declare module '*.vue' {
|
|||||||
const component: ReturnType<typeof defineComponent>
|
const component: ReturnType<typeof defineComponent>
|
||||||
export default component
|
export default component
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module '*.glsl' {
|
||||||
|
const value: string
|
||||||
|
export default value
|
||||||
|
}
|
||||||
|
|||||||
@ -8,3 +8,4 @@ export * from './types'
|
|||||||
export * from './users'
|
export * from './users'
|
||||||
export * from './utils'
|
export * from './utils'
|
||||||
export * from './servers'
|
export * from './servers'
|
||||||
|
export * from './three/skin-rendering'
|
||||||
|
|||||||
@ -20,10 +20,12 @@
|
|||||||
"@codemirror/state": "^6.3.2",
|
"@codemirror/state": "^6.3.2",
|
||||||
"@codemirror/view": "^6.22.1",
|
"@codemirror/view": "^6.22.1",
|
||||||
"@types/markdown-it": "^14.1.1",
|
"@types/markdown-it": "^14.1.1",
|
||||||
|
"@types/three": "^0.172.0",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"highlight.js": "^11.9.0",
|
"highlight.js": "^11.9.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"ofetch": "^1.3.4",
|
"ofetch": "^1.3.4",
|
||||||
|
"three": "^0.172.0",
|
||||||
"xss": "^1.0.14"
|
"xss": "^1.0.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
207
packages/utils/three/skin-rendering.ts
Normal file
207
packages/utils/three/skin-rendering.ts
Normal 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()
|
||||||
|
}
|
||||||
@ -368,3 +368,8 @@ export function getPingLevel(ping: number) {
|
|||||||
return 1
|
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
256
pnpm-lock.yaml
generated
@ -80,9 +80,15 @@ importers:
|
|||||||
'@tauri-apps/plugin-window-state':
|
'@tauri-apps/plugin-window-state':
|
||||||
specifier: ^2.2.2
|
specifier: ^2.2.2
|
||||||
version: 2.2.2
|
version: 2.2.2
|
||||||
|
'@types/three':
|
||||||
|
specifier: ^0.172.0
|
||||||
|
version: 0.172.0
|
||||||
'@vintl/vintl':
|
'@vintl/vintl':
|
||||||
specifier: ^4.4.1
|
specifier: ^4.4.1
|
||||||
version: 4.4.1(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))
|
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:
|
dayjs:
|
||||||
specifier: ^1.11.10
|
specifier: ^1.11.10
|
||||||
version: 1.11.11
|
version: 1.11.11
|
||||||
@ -98,6 +104,9 @@ importers:
|
|||||||
posthog-js:
|
posthog-js:
|
||||||
specifier: ^1.158.2
|
specifier: ^1.158.2
|
||||||
version: 1.158.2
|
version: 1.158.2
|
||||||
|
three:
|
||||||
|
specifier: ^0.172.0
|
||||||
|
version: 0.172.0
|
||||||
vite-svg-loader:
|
vite-svg-loader:
|
||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.1.0(vue@3.5.13(typescript@5.5.4))
|
version: 5.1.0(vue@3.5.13(typescript@5.5.4))
|
||||||
@ -472,12 +481,24 @@ importers:
|
|||||||
'@modrinth/utils':
|
'@modrinth/utils':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../utils
|
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':
|
'@types/markdown-it':
|
||||||
specifier: ^14.1.1
|
specifier: ^14.1.1
|
||||||
version: 14.1.1
|
version: 14.1.1
|
||||||
|
'@types/three':
|
||||||
|
specifier: ^0.172.0
|
||||||
|
version: 0.172.0
|
||||||
'@vintl/how-ago':
|
'@vintl/how-ago':
|
||||||
specifier: ^3.0.1
|
specifier: ^3.0.1
|
||||||
version: 3.0.1(@formatjs/intl@2.10.4(typescript@5.5.4))
|
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:
|
apexcharts:
|
||||||
specifier: ^3.44.0
|
specifier: ^3.44.0
|
||||||
version: 3.49.2
|
version: 3.49.2
|
||||||
@ -496,6 +517,9 @@ importers:
|
|||||||
qrcode.vue:
|
qrcode.vue:
|
||||||
specifier: ^3.4.1
|
specifier: ^3.4.1
|
||||||
version: 3.4.1(vue@3.5.13(typescript@5.5.4))
|
version: 3.4.1(vue@3.5.13(typescript@5.5.4))
|
||||||
|
three:
|
||||||
|
specifier: ^0.172.0
|
||||||
|
version: 0.172.0
|
||||||
vue-multiselect:
|
vue-multiselect:
|
||||||
specifier: 3.0.0
|
specifier: 3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
@ -566,6 +590,9 @@ importers:
|
|||||||
'@types/markdown-it':
|
'@types/markdown-it':
|
||||||
specifier: ^14.1.1
|
specifier: ^14.1.1
|
||||||
version: 14.1.1
|
version: 14.1.1
|
||||||
|
'@types/three':
|
||||||
|
specifier: ^0.172.0
|
||||||
|
version: 0.172.0
|
||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.10
|
specifier: ^1.11.10
|
||||||
version: 1.11.11
|
version: 1.11.11
|
||||||
@ -578,6 +605,9 @@ importers:
|
|||||||
ofetch:
|
ofetch:
|
||||||
specifier: ^1.3.4
|
specifier: ^1.3.4
|
||||||
version: 1.4.1
|
version: 1.4.1
|
||||||
|
three:
|
||||||
|
specifier: ^0.172.0
|
||||||
|
version: 0.172.0
|
||||||
xss:
|
xss:
|
||||||
specifier: ^1.0.14
|
specifier: ^1.0.14
|
||||||
version: 1.0.15
|
version: 1.0.15
|
||||||
@ -598,6 +628,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
'@alvarosabu/utils@3.2.0':
|
||||||
|
resolution: {integrity: sha512-aoGWRfaQjOo9TUwrBA6W0zwTHktgrXy69GIFNILT4gHsqscw6+X8P6uoSlZVQFr887SPm8x3aDin5EBVq8y4pw==}
|
||||||
|
|
||||||
'@ampproject/remapping@2.3.0':
|
'@ampproject/remapping@2.3.0':
|
||||||
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
@ -2516,6 +2549,19 @@ packages:
|
|||||||
'@tauri-apps/plugin-window-state@2.2.2':
|
'@tauri-apps/plugin-window-state@2.2.2':
|
||||||
resolution: {integrity: sha512-7pFwmMtGhhhE/WgmM7PUrj0BSSWVAQMfDdYbRalphIqqF1tWBvxtlxclx8bTutpXHLJTQoCpIeWtBEIXsoAlGw==}
|
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':
|
'@trysound/sax@0.2.0':
|
||||||
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
|
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@ -2535,6 +2581,9 @@ packages:
|
|||||||
'@types/dompurify@3.0.5':
|
'@types/dompurify@3.0.5':
|
||||||
resolution: {integrity: sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==}
|
resolution: {integrity: sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==}
|
||||||
|
|
||||||
|
'@types/draco3d@1.4.10':
|
||||||
|
resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==}
|
||||||
|
|
||||||
'@types/eslint-scope@3.7.7':
|
'@types/eslint-scope@3.7.7':
|
||||||
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
|
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
|
||||||
|
|
||||||
@ -2607,6 +2656,9 @@ packages:
|
|||||||
'@types/normalize-package-data@2.4.4':
|
'@types/normalize-package-data@2.4.4':
|
||||||
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
|
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
|
||||||
|
|
||||||
|
'@types/offscreencanvas@2019.7.3':
|
||||||
|
resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==}
|
||||||
|
|
||||||
'@types/resolve@1.20.2':
|
'@types/resolve@1.20.2':
|
||||||
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
|
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
|
||||||
|
|
||||||
@ -2640,6 +2692,9 @@ packages:
|
|||||||
'@types/web-bluetooth@0.0.20':
|
'@types/web-bluetooth@0.0.20':
|
||||||
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
||||||
|
|
||||||
|
'@types/web-bluetooth@0.0.21':
|
||||||
|
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||||
|
|
||||||
'@types/webxr@0.5.21':
|
'@types/webxr@0.5.21':
|
||||||
resolution: {integrity: sha512-geZIAtLzjGmgY2JUi6VxXdCrTb99A7yP49lxLr2Nm/uIK0PkkxcEi4OGhoGDO4pxCf3JwGz2GiJL2Ej4K2bKaA==}
|
resolution: {integrity: sha512-geZIAtLzjGmgY2JUi6VxXdCrTb99A7yP49lxLr2Nm/uIK0PkkxcEi4OGhoGDO4pxCf3JwGz2GiJL2Ej4K2bKaA==}
|
||||||
|
|
||||||
@ -3037,18 +3092,27 @@ packages:
|
|||||||
'@vueuse/core@11.1.0':
|
'@vueuse/core@11.1.0':
|
||||||
resolution: {integrity: sha512-P6dk79QYA6sKQnghrUz/1tHi0n9mrb/iO1WTMk/ElLmTyNqgDeSZ3wcDf6fRBGzRJbeG1dxzEOvLENMjr+E3fg==}
|
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':
|
'@vueuse/core@9.13.0':
|
||||||
resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==}
|
resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==}
|
||||||
|
|
||||||
'@vueuse/metadata@11.1.0':
|
'@vueuse/metadata@11.1.0':
|
||||||
resolution: {integrity: sha512-l9Q502TBTaPYGanl1G+hPgd3QX5s4CGnpXriVBR5fEZ/goI6fvDaVmIl3Td8oKFurOxTmbXvBPSsgrd6eu6HYg==}
|
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':
|
'@vueuse/metadata@9.13.0':
|
||||||
resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==}
|
resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==}
|
||||||
|
|
||||||
'@vueuse/shared@11.1.0':
|
'@vueuse/shared@11.1.0':
|
||||||
resolution: {integrity: sha512-YUtIpY122q7osj+zsNMFAfMTubGz0sn5QzE5gPzAIiCmtt2ha3uQUY1+JPyL4gRCTsLPX82Y9brNbo/aqlA91w==}
|
resolution: {integrity: sha512-YUtIpY122q7osj+zsNMFAfMTubGz0sn5QzE5gPzAIiCmtt2ha3uQUY1+JPyL4gRCTsLPX82Y9brNbo/aqlA91w==}
|
||||||
|
|
||||||
|
'@vueuse/shared@12.8.2':
|
||||||
|
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
|
||||||
|
|
||||||
'@vueuse/shared@9.13.0':
|
'@vueuse/shared@9.13.0':
|
||||||
resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==}
|
resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==}
|
||||||
|
|
||||||
@ -3464,6 +3528,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==}
|
resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==}
|
||||||
engines: {node: '>=16'}
|
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:
|
caniuse-api@3.0.0:
|
||||||
resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==}
|
resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==}
|
||||||
|
|
||||||
@ -4014,6 +4083,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
draco3d@1.5.7:
|
||||||
|
resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
|
||||||
|
|
||||||
dset@3.1.4:
|
dset@3.1.4:
|
||||||
resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==}
|
resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@ -4559,6 +4631,9 @@ packages:
|
|||||||
fflate@0.4.8:
|
fflate@0.4.8:
|
||||||
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
|
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
|
||||||
|
|
||||||
|
fflate@0.6.10:
|
||||||
|
resolution: {integrity: sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==}
|
||||||
|
|
||||||
fflate@0.8.2:
|
fflate@0.8.2:
|
||||||
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
|
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
|
||||||
|
|
||||||
@ -4788,6 +4863,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==}
|
resolution: {integrity: sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==}
|
||||||
engines: {node: '>=18'}
|
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:
|
gopd@1.0.1:
|
||||||
resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==}
|
resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==}
|
||||||
|
|
||||||
@ -5243,6 +5327,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==}
|
resolution: {integrity: sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
isarray@0.0.1:
|
||||||
|
resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==}
|
||||||
|
|
||||||
isarray@1.0.0:
|
isarray@1.0.0:
|
||||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||||
|
|
||||||
@ -6482,6 +6569,9 @@ packages:
|
|||||||
posthog-js@1.158.2:
|
posthog-js@1.158.2:
|
||||||
resolution: {integrity: sha512-ovb7GHHRNDf6vmuL+8lbDukewzDzQlLZXg3d475hrfHSBgidYeTxtLGtoBcUz4x6558BLDFjnSip+f3m4rV9LA==}
|
resolution: {integrity: sha512-ovb7GHHRNDf6vmuL+8lbDukewzDzQlLZXg3d475hrfHSBgidYeTxtLGtoBcUz4x6558BLDFjnSip+f3m4rV9LA==}
|
||||||
|
|
||||||
|
potpack@1.0.2:
|
||||||
|
resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==}
|
||||||
|
|
||||||
preact@10.23.2:
|
preact@10.23.2:
|
||||||
resolution: {integrity: sha512-kKYfePf9rzKnxOAKDpsWhg/ysrHPqT+yQ7UW4JjdnqjFIeNUnNcEJvhuA8fDenxAGWzUqtd51DfVg7xp/8T9NA==}
|
resolution: {integrity: sha512-kKYfePf9rzKnxOAKDpsWhg/ysrHPqT+yQ7UW4JjdnqjFIeNUnNcEJvhuA8fDenxAGWzUqtd51DfVg7xp/8T9NA==}
|
||||||
|
|
||||||
@ -6640,6 +6730,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==}
|
resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
readable-stream@1.0.34:
|
||||||
|
resolution: {integrity: sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==}
|
||||||
|
|
||||||
readable-stream@2.3.8:
|
readable-stream@2.3.8:
|
||||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||||
|
|
||||||
@ -7109,6 +7202,15 @@ packages:
|
|||||||
'@astrojs/starlight': '>=0.30.0'
|
'@astrojs/starlight': '>=0.30.0'
|
||||||
astro: '>=5.1.5'
|
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:
|
statuses@2.0.1:
|
||||||
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
|
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@ -7148,6 +7250,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
|
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
string_decoder@0.10.31:
|
||||||
|
resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==}
|
||||||
|
|
||||||
string_decoder@1.1.1:
|
string_decoder@1.1.1:
|
||||||
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||||
|
|
||||||
@ -7345,9 +7450,29 @@ packages:
|
|||||||
thenify@3.3.1:
|
thenify@3.3.1:
|
||||||
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
|
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:
|
three@0.172.0:
|
||||||
resolution: {integrity: sha512-6HMgMlzU97MsV7D/tY8Va38b83kz8YJX+BefKjspMNAv0Vx6dxMogHOrnRl/sbMIs3BPUKijPqDqJ/+UwJbIow==}
|
resolution: {integrity: sha512-6HMgMlzU97MsV7D/tY8Va38b83kz8YJX+BefKjspMNAv0Vx6dxMogHOrnRl/sbMIs3BPUKijPqDqJ/+UwJbIow==}
|
||||||
|
|
||||||
|
through2@0.6.5:
|
||||||
|
resolution: {integrity: sha512-RkK/CCESdTKQZHdmKICijdKKsCRVHs5KsLZ6pACAmF/1GPUQhonHSXWNERctxEp7RmvjdNbZTL5z9V7nSCXKcg==}
|
||||||
|
|
||||||
tiny-invariant@1.3.3:
|
tiny-invariant@1.3.3:
|
||||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||||
|
|
||||||
@ -8281,6 +8406,10 @@ packages:
|
|||||||
engines: {node: '>= 0.10.0'}
|
engines: {node: '>= 0.10.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
xtend@4.0.2:
|
||||||
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||||
|
engines: {node: '>=0.4'}
|
||||||
|
|
||||||
xxhash-wasm@1.1.0:
|
xxhash-wasm@1.1.0:
|
||||||
resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==}
|
resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==}
|
||||||
|
|
||||||
@ -8370,6 +8499,8 @@ snapshots:
|
|||||||
|
|
||||||
'@alloc/quick-lru@5.2.0': {}
|
'@alloc/quick-lru@5.2.0': {}
|
||||||
|
|
||||||
|
'@alvarosabu/utils@3.2.0': {}
|
||||||
|
|
||||||
'@ampproject/remapping@2.3.0':
|
'@ampproject/remapping@2.3.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/gen-mapping': 0.3.5
|
'@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)':
|
'@nuxtjs/eslint-config-typescript@12.1.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4)':
|
||||||
dependencies:
|
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/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)
|
'@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)
|
eslint: 9.13.0(jiti@2.4.2)
|
||||||
@ -9845,10 +9976,10 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- typescript
|
- 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:
|
dependencies:
|
||||||
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)(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-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-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))
|
eslint-plugin-node: 11.1.0(eslint@9.13.0(jiti@2.4.2))
|
||||||
@ -10395,6 +10526,33 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.5.0
|
'@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': {}
|
'@trysound/sax@0.2.0': {}
|
||||||
|
|
||||||
'@tweenjs/tween.js@23.1.3': {}
|
'@tweenjs/tween.js@23.1.3': {}
|
||||||
@ -10413,6 +10571,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/trusted-types': 2.0.7
|
'@types/trusted-types': 2.0.7
|
||||||
|
|
||||||
|
'@types/draco3d@1.4.10': {}
|
||||||
|
|
||||||
'@types/eslint-scope@3.7.7':
|
'@types/eslint-scope@3.7.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/eslint': 9.6.1
|
'@types/eslint': 9.6.1
|
||||||
@ -10490,6 +10650,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/normalize-package-data@2.4.4': {}
|
'@types/normalize-package-data@2.4.4': {}
|
||||||
|
|
||||||
|
'@types/offscreencanvas@2019.7.3': {}
|
||||||
|
|
||||||
'@types/resolve@1.20.2': {}
|
'@types/resolve@1.20.2': {}
|
||||||
|
|
||||||
'@types/rss@0.0.32': {}
|
'@types/rss@0.0.32': {}
|
||||||
@ -10521,6 +10683,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/web-bluetooth@0.0.20': {}
|
'@types/web-bluetooth@0.0.20': {}
|
||||||
|
|
||||||
|
'@types/web-bluetooth@0.0.21': {}
|
||||||
|
|
||||||
'@types/webxr@0.5.21': {}
|
'@types/webxr@0.5.21': {}
|
||||||
|
|
||||||
'@types/xml2js@0.4.14':
|
'@types/xml2js@0.4.14':
|
||||||
@ -11182,6 +11346,15 @@ snapshots:
|
|||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
- vue
|
- 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))':
|
'@vueuse/core@9.13.0(vue@3.5.13(typescript@5.5.4))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/web-bluetooth': 0.0.16
|
'@types/web-bluetooth': 0.0.16
|
||||||
@ -11194,6 +11367,8 @@ snapshots:
|
|||||||
|
|
||||||
'@vueuse/metadata@11.1.0': {}
|
'@vueuse/metadata@11.1.0': {}
|
||||||
|
|
||||||
|
'@vueuse/metadata@12.8.2': {}
|
||||||
|
|
||||||
'@vueuse/metadata@9.13.0': {}
|
'@vueuse/metadata@9.13.0': {}
|
||||||
|
|
||||||
'@vueuse/shared@11.1.0(vue@3.5.13(typescript@5.5.4))':
|
'@vueuse/shared@11.1.0(vue@3.5.13(typescript@5.5.4))':
|
||||||
@ -11203,6 +11378,12 @@ snapshots:
|
|||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
- vue
|
- 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))':
|
'@vueuse/shared@9.13.0(vue@3.5.13(typescript@5.5.4))':
|
||||||
dependencies:
|
dependencies:
|
||||||
vue-demi: 0.14.10(vue@3.5.13(typescript@5.5.4))
|
vue-demi: 0.14.10(vue@3.5.13(typescript@5.5.4))
|
||||||
@ -11807,6 +11988,10 @@ snapshots:
|
|||||||
|
|
||||||
camelcase@8.0.0: {}
|
camelcase@8.0.0: {}
|
||||||
|
|
||||||
|
camera-controls@2.10.1(three@0.172.0):
|
||||||
|
dependencies:
|
||||||
|
three: 0.172.0
|
||||||
|
|
||||||
caniuse-api@3.0.0:
|
caniuse-api@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.24.2
|
browserslist: 4.24.2
|
||||||
@ -12272,6 +12457,8 @@ snapshots:
|
|||||||
dotenv@16.6.1:
|
dotenv@16.6.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
draco3d@1.5.7: {}
|
||||||
|
|
||||||
dset@3.1.4: {}
|
dset@3.1.4: {}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
@ -12566,10 +12753,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
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)(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:
|
dependencies:
|
||||||
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))
|
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-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-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)
|
debug: 4.4.0(supports-color@9.4.0)
|
||||||
enhanced-resolve: 5.17.1
|
enhanced-resolve: 5.17.1
|
||||||
eslint: 9.13.0(jiti@2.4.2)
|
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))
|
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
|
fast-glob: 3.3.2
|
||||||
get-tsconfig: 4.7.5
|
get-tsconfig: 4.7.5
|
||||||
@ -12607,7 +12794,7 @@ snapshots:
|
|||||||
- eslint-import-resolver-webpack
|
- eslint-import-resolver-webpack
|
||||||
- supports-color
|
- 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:
|
dependencies:
|
||||||
debug: 3.2.7
|
debug: 3.2.7
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@ -13156,6 +13343,8 @@ snapshots:
|
|||||||
|
|
||||||
fflate@0.4.8: {}
|
fflate@0.4.8: {}
|
||||||
|
|
||||||
|
fflate@0.6.10: {}
|
||||||
|
|
||||||
fflate@0.8.2: {}
|
fflate@0.8.2: {}
|
||||||
|
|
||||||
file-entry-cache@6.0.1:
|
file-entry-cache@6.0.1:
|
||||||
@ -13423,6 +13612,14 @@ snapshots:
|
|||||||
slash: 5.1.0
|
slash: 5.1.0
|
||||||
unicorn-magic: 0.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:
|
gopd@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
get-intrinsic: 1.2.4
|
get-intrinsic: 1.2.4
|
||||||
@ -13996,6 +14193,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
system-architecture: 0.1.0
|
system-architecture: 0.1.0
|
||||||
|
|
||||||
|
isarray@0.0.1: {}
|
||||||
|
|
||||||
isarray@1.0.0: {}
|
isarray@1.0.0: {}
|
||||||
|
|
||||||
isarray@2.0.5: {}
|
isarray@2.0.5: {}
|
||||||
@ -15686,6 +15885,8 @@ snapshots:
|
|||||||
preact: 10.23.2
|
preact: 10.23.2
|
||||||
web-vitals: 4.2.3
|
web-vitals: 4.2.3
|
||||||
|
|
||||||
|
potpack@1.0.2: {}
|
||||||
|
|
||||||
preact@10.23.2: {}
|
preact@10.23.2: {}
|
||||||
|
|
||||||
preferred-pm@4.1.1:
|
preferred-pm@4.1.1:
|
||||||
@ -15782,6 +15983,13 @@ snapshots:
|
|||||||
parse-json: 5.2.0
|
parse-json: 5.2.0
|
||||||
type-fest: 0.6.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):
|
readable-stream@2.3.8(patch_hash=h52dazg37p4h3yox67pw36akse):
|
||||||
dependencies:
|
dependencies:
|
||||||
core-util-is: 1.0.3
|
core-util-is: 1.0.3
|
||||||
@ -16458,6 +16666,13 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- openapi-types
|
- 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: {}
|
statuses@2.0.1: {}
|
||||||
|
|
||||||
std-env@3.8.0: {}
|
std-env@3.8.0: {}
|
||||||
@ -16512,6 +16727,8 @@ snapshots:
|
|||||||
define-properties: 1.2.1
|
define-properties: 1.2.1
|
||||||
es-object-atoms: 1.0.0
|
es-object-atoms: 1.0.0
|
||||||
|
|
||||||
|
string_decoder@0.10.31: {}
|
||||||
|
|
||||||
string_decoder@1.1.1:
|
string_decoder@1.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.1.2
|
safe-buffer: 5.1.2
|
||||||
@ -16733,8 +16950,31 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
any-promise: 1.3.0
|
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: {}
|
three@0.172.0: {}
|
||||||
|
|
||||||
|
through2@0.6.5:
|
||||||
|
dependencies:
|
||||||
|
readable-stream: 1.0.34
|
||||||
|
xtend: 4.0.2
|
||||||
|
|
||||||
tiny-invariant@1.3.3: {}
|
tiny-invariant@1.3.3: {}
|
||||||
|
|
||||||
tinyexec@0.3.1: {}
|
tinyexec@0.3.1: {}
|
||||||
@ -17675,6 +17915,8 @@ snapshots:
|
|||||||
commander: 2.20.3
|
commander: 2.20.3
|
||||||
cssfilter: 0.0.10
|
cssfilter: 0.0.10
|
||||||
|
|
||||||
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
xxhash-wasm@1.1.0: {}
|
xxhash-wasm@1.1.0: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user