fix: 复用皮肤站逻辑并稳定玩家头像
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
This commit is contained in:
@ -90,6 +90,7 @@ import NavRail from '@/components/ui/NavRail.vue'
|
|||||||
import OnboardingOverlay from '@/components/ui/onboarding/OnboardingOverlay.vue'
|
import OnboardingOverlay from '@/components/ui/onboarding/OnboardingOverlay.vue'
|
||||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||||
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
||||||
|
import SkinSiteSessionFrame from '@/components/ui/SkinSiteSessionFrame.vue'
|
||||||
import WindowControls from '@/components/ui/WindowControls.vue'
|
import WindowControls from '@/components/ui/WindowControls.vue'
|
||||||
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
||||||
import { useDropImport } from '@/composables/useDropImport'
|
import { useDropImport } from '@/composables/useDropImport'
|
||||||
@ -2151,6 +2152,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
|
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
|
||||||
|
<SkinSiteSessionFrame v-if="stateInitialized" />
|
||||||
<div id="teleports"></div>
|
<div id="teleports"></div>
|
||||||
<div
|
<div
|
||||||
v-if="stateInitialized && themeStore.customBackgroundPath && !themeStore.transparentBackground"
|
v-if="stateInitialized && themeStore.customBackgroundPath && !themeStore.transparentBackground"
|
||||||
|
|||||||
@ -35,24 +35,44 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="skinSitePlayers.length > 0" class="border-0 border-t border-solid border-surface-5">
|
<div v-if="skinSitePlayers.length > 0" class="border-0 border-t border-solid border-surface-5">
|
||||||
<div
|
<button
|
||||||
v-for="player in skinSitePlayers"
|
v-for="player in skinSitePlayers"
|
||||||
:key="player.uuid"
|
:key="player.uuid"
|
||||||
class="flex items-center gap-2 border-0 border-b border-solid border-surface-5 px-3 py-2 last:border-b-0"
|
type="button"
|
||||||
|
class="flex w-full cursor-pointer items-center gap-2 border-0 border-b border-solid border-surface-5 px-3 py-2 text-left transition-colors last:border-b-0 hover:bg-button-hover"
|
||||||
|
:class="selectedSkinSitePlayerId === player.uuid ? 'bg-button-hover' : 'bg-transparent'"
|
||||||
|
:aria-pressed="selectedSkinSitePlayerId === player.uuid"
|
||||||
|
@click="setSkinSitePlayer(player.uuid)"
|
||||||
>
|
>
|
||||||
<Avatar :src="defaultSteveHeadUrl" size="24px" pixelated :unframed-natural-width="72" />
|
<Avatar
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
v-if="player.skinState === 'ready' && player.headDataUrl"
|
||||||
<span class="truncate text-sm text-primary">{{ player.name }}</span>
|
:src="player.headDataUrl"
|
||||||
<span class="truncate text-xs text-secondary">{{ player.uuid }}</span>
|
size="32px"
|
||||||
</div>
|
pixelated
|
||||||
<span class="shrink-0 text-xs text-secondary">
|
:unframed-natural-width="36"
|
||||||
{{
|
/>
|
||||||
launcherAccountIds.has(player.uuid)
|
<div
|
||||||
? formatMessage(messages.skinSitePlayerReady)
|
v-else
|
||||||
: formatMessage(messages.skinSitePlayerSynced)
|
class="h-8 w-8 shrink-0 rounded-sm border-2 border-dashed border-secondary/45 bg-surface-5/50 text-secondary"
|
||||||
}}
|
style="
|
||||||
</span>
|
background-image:
|
||||||
</div>
|
linear-gradient(currentColor, currentColor),
|
||||||
|
linear-gradient(currentColor, currentColor),
|
||||||
|
linear-gradient(currentColor, currentColor);
|
||||||
|
background-position:
|
||||||
|
6px 8px,
|
||||||
|
22px 8px,
|
||||||
|
8px 20px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size:
|
||||||
|
4px 4px,
|
||||||
|
4px 4px,
|
||||||
|
16px 2px;
|
||||||
|
"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 flex-1 truncate text-sm text-primary">{{ player.name }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-else-if="skinSitePlayersStatus === 'ready'" class="m-0 px-3 pb-3 text-sm text-secondary">
|
<p v-else-if="skinSitePlayersStatus === 'ready'" class="m-0 px-3 pb-3 text-sm text-secondary">
|
||||||
{{ formatMessage(messages.noSkinSitePlayers) }}
|
{{ formatMessage(messages.noSkinSitePlayers) }}
|
||||||
@ -250,11 +270,12 @@ import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import axolotlLogo from '@/assets/netherstar.png'
|
import axolotlLogo from '@/assets/netherstar.png'
|
||||||
import steveSkinTexture from '@/assets/skins/steve.png?inline'
|
|
||||||
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
|
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
|
||||||
import {
|
import {
|
||||||
openSkinSiteLogin,
|
openSkinSiteLogin,
|
||||||
requestSkinSitePlayers,
|
requestSkinSitePlayers,
|
||||||
|
selectedSkinSitePlayerId,
|
||||||
|
selectSkinSitePlayer,
|
||||||
skinSitePlayers,
|
skinSitePlayers,
|
||||||
skinSitePlayersStatus,
|
skinSitePlayersStatus,
|
||||||
skinSiteStatus,
|
skinSiteStatus,
|
||||||
@ -347,25 +368,14 @@ let refreshGeneration = 0
|
|||||||
let headRefreshTimer: ReturnType<typeof setTimeout> | undefined
|
let headRefreshTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
let defaultUserUpdateQueue = Promise.resolve()
|
let defaultUserUpdateQueue = Promise.resolve()
|
||||||
const minecraftLoginModal = ref<InstanceType<typeof MinecraftLoginModal> | null>(null)
|
const minecraftLoginModal = ref<InstanceType<typeof MinecraftLoginModal> | null>(null)
|
||||||
const launcherAccountIds = computed(() => new Set(accounts.value.map((account) => account.profile.id)))
|
|
||||||
|
|
||||||
async function refreshSkinSitePlayers() {
|
async function refreshSkinSitePlayers() {
|
||||||
await requestSkinSitePlayers().catch(() => {})
|
await requestSkinSitePlayers().catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSkinHeadDataUrl(textureUrl: string) {
|
function setSkinSitePlayer(playerId: string) {
|
||||||
const escapedTextureUrl = textureUrl
|
selectSkinSitePlayer(playerId)
|
||||||
.replaceAll('&', '&')
|
|
||||||
.replaceAll('"', '"')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>')
|
|
||||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 8 8" shape-rendering="crispEdges"><image href="${escapedTextureUrl}" x="-8" y="-8" width="64" height="64" style="image-rendering:pixelated"/><image href="${escapedTextureUrl}" x="-40" y="-8" width="64" height="64" style="image-rendering:pixelated"/></svg>`
|
|
||||||
|
|
||||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSteveHeadUrl = createSkinHeadDataUrl(steveSkinTexture)
|
|
||||||
|
|
||||||
const HEAD_REFRESH_RETRY_DELAYS = [1500, 5000, 15000, 30000] as const
|
const HEAD_REFRESH_RETRY_DELAYS = [1500, 5000, 15000, 30000] as const
|
||||||
const HEAD_REFRESH_CONTINUOUS_DELAY = 60_000
|
const HEAD_REFRESH_CONTINUOUS_DELAY = 60_000
|
||||||
|
|
||||||
@ -609,6 +619,7 @@ function persistDefaultUser(userId: string) {
|
|||||||
async function setAccount(account: MinecraftCredential) {
|
async function setAccount(account: MinecraftCredential) {
|
||||||
const userId = account.profile.id
|
const userId = account.profile.id
|
||||||
refreshGeneration += 1
|
refreshGeneration += 1
|
||||||
|
selectSkinSitePlayer(null)
|
||||||
defaultUser.value = userId
|
defaultUser.value = userId
|
||||||
equippedSkin.value = null
|
equippedSkin.value = null
|
||||||
|
|
||||||
@ -618,6 +629,16 @@ async function setAccount(account: MinecraftCredential) {
|
|||||||
if (defaultUser.value === userId) notifyAccountChange()
|
if (defaultUser.value === userId) notifyAccountChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[skinSitePlayers, defaultUser],
|
||||||
|
([availablePlayers, selectedLocalUser]) => {
|
||||||
|
if (!selectedLocalUser && !selectedSkinSitePlayerId.value && availablePlayers.length > 0) {
|
||||||
|
selectSkinSitePlayer(availablePlayers[0].uuid)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
if (offline.value) return
|
if (offline.value) return
|
||||||
loginDisabled.value = true
|
loginDisabled.value = true
|
||||||
@ -708,14 +729,6 @@ const messages = defineMessages({
|
|||||||
id: 'minecraft-account.skin-site.players.error',
|
id: 'minecraft-account.skin-site.players.error',
|
||||||
defaultMessage: 'Could not refresh the player list. Previously loaded players are kept.',
|
defaultMessage: 'Could not refresh the player list. Previously loaded players are kept.',
|
||||||
},
|
},
|
||||||
skinSitePlayerReady: {
|
|
||||||
id: 'minecraft-account.skin-site.player.ready',
|
|
||||||
defaultMessage: 'Ready to launch',
|
|
||||||
},
|
|
||||||
skinSitePlayerSynced: {
|
|
||||||
id: 'minecraft-account.skin-site.player.synced',
|
|
||||||
defaultMessage: 'Synced from skin site',
|
|
||||||
},
|
|
||||||
offlineMode: {
|
offlineMode: {
|
||||||
id: 'minecraft-account.offline-mode',
|
id: 'minecraft-account.offline-mode',
|
||||||
defaultMessage: 'Offline mode',
|
defaultMessage: 'Offline mode',
|
||||||
|
|||||||
@ -1,14 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
|
||||||
|
|
||||||
import {
|
import { skinSiteFrameUrl } from '@/composables/skin-site-session'
|
||||||
receiveSkinSiteMessage,
|
|
||||||
resetSkinSiteSession,
|
|
||||||
setSkinSiteFrame,
|
|
||||||
SKIN_SITE_ORIGIN,
|
|
||||||
skinSiteFrameUrl,
|
|
||||||
} from '@/composables/skin-site-session'
|
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
@ -18,42 +11,12 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const frame = ref<HTMLIFrameElement>()
|
|
||||||
let lastMessage = 0
|
|
||||||
let expiryTimer: ReturnType<typeof setInterval> | undefined
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
const contentWindow = frame.value?.contentWindow ?? null
|
|
||||||
setSkinSiteFrame(contentWindow)
|
|
||||||
contentWindow?.postMessage({ type: 'starlight-skin-session-connect' }, SKIN_SITE_ORIGIN)
|
|
||||||
}
|
|
||||||
|
|
||||||
function receive(event: MessageEvent) {
|
|
||||||
if (receiveSkinSiteMessage(event, frame.value?.contentWindow ?? null)) lastMessage = Date.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
window.addEventListener('message', receive)
|
|
||||||
connect()
|
|
||||||
expiryTimer = setInterval(() => {
|
|
||||||
if (lastMessage && Date.now() - lastMessage > 90_000) resetSkinSiteSession()
|
|
||||||
}, 10_000)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
window.removeEventListener('message', receive)
|
|
||||||
clearInterval(expiryTimer)
|
|
||||||
setSkinSiteFrame(null)
|
|
||||||
resetSkinSiteSession()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<iframe
|
<iframe
|
||||||
ref="frame"
|
|
||||||
:src="skinSiteFrameUrl"
|
:src="skinSiteFrameUrl"
|
||||||
:title="formatMessage(messages.frameTitle)"
|
:title="formatMessage(messages.frameTitle)"
|
||||||
class="block h-full min-h-0 w-full border-0"
|
class="block h-full min-h-0 w-full border-0"
|
||||||
@load="connect"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
51
apps/app-frontend/src/components/ui/SkinSiteSessionFrame.vue
Normal file
51
apps/app-frontend/src/components/ui/SkinSiteSessionFrame.vue
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
receiveSkinSiteMessage,
|
||||||
|
resetSkinSiteSession,
|
||||||
|
setSkinSiteFrame,
|
||||||
|
SKIN_SITE_ORIGIN,
|
||||||
|
} from '@/composables/skin-site-session'
|
||||||
|
|
||||||
|
const frame = ref<HTMLIFrameElement>()
|
||||||
|
let lastMessage = 0
|
||||||
|
let expiryTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
const contentWindow = frame.value?.contentWindow ?? null
|
||||||
|
setSkinSiteFrame(contentWindow)
|
||||||
|
contentWindow?.postMessage({ type: 'starlight-skin-session-connect' }, SKIN_SITE_ORIGIN)
|
||||||
|
}
|
||||||
|
|
||||||
|
function receive(event: MessageEvent) {
|
||||||
|
if (receiveSkinSiteMessage(event, frame.value?.contentWindow ?? null)) lastMessage = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('message', receive)
|
||||||
|
connect()
|
||||||
|
expiryTimer = setInterval(() => {
|
||||||
|
if (lastMessage && Date.now() - lastMessage > 90_000) resetSkinSiteSession()
|
||||||
|
}, 10_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('message', receive)
|
||||||
|
clearInterval(expiryTimer)
|
||||||
|
setSkinSiteFrame(null)
|
||||||
|
resetSkinSiteSession()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<iframe
|
||||||
|
ref="frame"
|
||||||
|
:src="`${SKIN_SITE_ORIGIN}/`"
|
||||||
|
title="StarLight Skin Site session"
|
||||||
|
aria-hidden="true"
|
||||||
|
tabindex="-1"
|
||||||
|
class="pointer-events-none fixed -left-[10000px] top-0 h-px w-px opacity-0"
|
||||||
|
@load="connect"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@ -84,6 +84,7 @@ const props = defineProps<{
|
|||||||
isSkinActive: (skin: Skin) => boolean
|
isSkinActive: (skin: Skin) => boolean
|
||||||
isAddSkinButtonDragActive: boolean
|
isAddSkinButtonDragActive: boolean
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
|
manageSavedSkins?: boolean
|
||||||
activeTab?: 'saved' | 'default'
|
activeTab?: 'saved' | 'default'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@ -182,7 +183,9 @@ const sections = computed<SkinSection[]>(() => {
|
|||||||
|
|
||||||
const draggableSavedSkins = ref<Skin[]>([])
|
const draggableSavedSkins = ref<Skin[]>([])
|
||||||
const isDraggingSavedSkin = ref(false)
|
const isDraggingSavedSkin = ref(false)
|
||||||
const canReorderSavedSkins = computed(() => draggableSavedSkins.value.length > 1)
|
const canReorderSavedSkins = computed(
|
||||||
|
() => props.manageSavedSkins !== false && draggableSavedSkins.value.length > 1,
|
||||||
|
)
|
||||||
const fixedSavedSkins = computed(() => props.savedSkins.filter((skin) => !canDragSavedSkin(skin)))
|
const fixedSavedSkins = computed(() => props.savedSkins.filter((skin) => !canDragSavedSkin(skin)))
|
||||||
|
|
||||||
const sectionLayouts = computed(() => {
|
const sectionLayouts = computed(() => {
|
||||||
@ -486,7 +489,7 @@ defineExpose({ getAddSkinButtonElement })
|
|||||||
:is-dragging="isDraggingSavedSkin"
|
:is-dragging="isDraggingSavedSkin"
|
||||||
@select="emit('select', skin)"
|
@select="emit('select', skin)"
|
||||||
>
|
>
|
||||||
<template v-if="!readOnly" #overlay-buttons>
|
<template v-if="!readOnly && manageSavedSkins !== false" #overlay-buttons>
|
||||||
<ButtonStyled color="brand">
|
<ButtonStyled color="brand">
|
||||||
<button
|
<button
|
||||||
:aria-label="formatMessage(messages.editSkinButton)"
|
:aria-label="formatMessage(messages.editSkinButton)"
|
||||||
@ -526,7 +529,7 @@ defineExpose({ getAddSkinButtonElement })
|
|||||||
:is-dragging="isDraggingSavedSkin"
|
:is-dragging="isDraggingSavedSkin"
|
||||||
@select="emit('select', skin)"
|
@select="emit('select', skin)"
|
||||||
>
|
>
|
||||||
<template v-if="!readOnly" #overlay-buttons>
|
<template v-if="!readOnly && manageSavedSkins !== false" #overlay-buttons>
|
||||||
<ButtonStyled color="brand">
|
<ButtonStyled color="brand">
|
||||||
<button
|
<button
|
||||||
:aria-label="formatMessage(messages.editSkinButton)"
|
:aria-label="formatMessage(messages.editSkinButton)"
|
||||||
@ -568,7 +571,7 @@ defineExpose({ getAddSkinButtonElement })
|
|||||||
:is-dragging="isDraggingSavedSkin"
|
:is-dragging="isDraggingSavedSkin"
|
||||||
@select="emit('select', skin)"
|
@select="emit('select', skin)"
|
||||||
>
|
>
|
||||||
<template #overlay-buttons>
|
<template v-if="manageSavedSkins !== false" #overlay-buttons>
|
||||||
<ButtonStyled color="brand">
|
<ButtonStyled color="brand">
|
||||||
<button
|
<button
|
||||||
:aria-label="formatMessage(messages.editSkinButton)"
|
:aria-label="formatMessage(messages.editSkinButton)"
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
receiveSkinSiteMessage,
|
receiveSkinSiteMessage,
|
||||||
requestSkinSiteLuck,
|
requestSkinSiteLuck,
|
||||||
requestSkinSitePlayers,
|
requestSkinSitePlayers,
|
||||||
|
requestSkinSiteSkinUpdate,
|
||||||
resetSkinSiteSession,
|
resetSkinSiteSession,
|
||||||
setSkinSiteFrame,
|
setSkinSiteFrame,
|
||||||
SKIN_SITE_ORIGIN,
|
SKIN_SITE_ORIGIN,
|
||||||
@ -150,7 +151,21 @@ test('player requests validate identities and replace the collection atomically'
|
|||||||
requestId: automaticRequest.requestId,
|
requestId: automaticRequest.requestId,
|
||||||
ok: true,
|
ok: true,
|
||||||
players: [
|
players: [
|
||||||
{ uuid: '0123456789abcdef0123456789abcdef', name: 'PlayerOne', isMojang: false },
|
{
|
||||||
|
uuid: '0123456789abcdef0123456789abcdef',
|
||||||
|
name: 'PlayerOne',
|
||||||
|
isMojang: false,
|
||||||
|
skinState: 'ready',
|
||||||
|
headDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
|
||||||
|
skinDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
|
||||||
|
model: 'slim',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uuid: 'fedcba9876543210fedcba9876543210',
|
||||||
|
name: 'NoSkinPlayer',
|
||||||
|
isMojang: false,
|
||||||
|
skinState: 'empty',
|
||||||
|
},
|
||||||
{ uuid: 'invalid', name: 'Ignored', isMojang: false },
|
{ uuid: 'invalid', name: 'Ignored', isMojang: false },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -160,10 +175,50 @@ test('player requests validate identities and replace the collection atomically'
|
|||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
assert.deepEqual(skinSitePlayers.value, [
|
assert.deepEqual(skinSitePlayers.value, [
|
||||||
{ uuid: '0123456789abcdef0123456789abcdef', name: 'PlayerOne', isMojang: false },
|
{
|
||||||
|
uuid: '0123456789abcdef0123456789abcdef',
|
||||||
|
name: 'PlayerOne',
|
||||||
|
isMojang: false,
|
||||||
|
skinState: 'ready',
|
||||||
|
headDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
|
||||||
|
skinDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
|
||||||
|
model: 'slim',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uuid: 'fedcba9876543210fedcba9876543210',
|
||||||
|
name: 'NoSkinPlayer',
|
||||||
|
isMojang: false,
|
||||||
|
skinState: 'empty',
|
||||||
|
},
|
||||||
])
|
])
|
||||||
assert.equal(skinSitePlayersStatus.value, 'ready')
|
assert.equal(skinSitePlayersStatus.value, 'ready')
|
||||||
|
|
||||||
|
const transientFailure = requestSkinSitePlayers()
|
||||||
|
const transientFailureRequest = sent.at(-1)?.data as { requestId: string }
|
||||||
|
receiveSkinSiteMessage(
|
||||||
|
{
|
||||||
|
origin: SKIN_SITE_ORIGIN,
|
||||||
|
source: frame,
|
||||||
|
data: {
|
||||||
|
type: 'starlight-skin-players-result',
|
||||||
|
requestId: transientFailureRequest.requestId,
|
||||||
|
ok: true,
|
||||||
|
players: [
|
||||||
|
{
|
||||||
|
uuid: '0123456789abcdef0123456789abcdef',
|
||||||
|
name: 'PlayerOne Renamed',
|
||||||
|
isMojang: false,
|
||||||
|
skinState: 'error',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as MessageEvent,
|
||||||
|
frame,
|
||||||
|
)
|
||||||
|
assert.equal((await transientFailure)[0].skinState, 'ready')
|
||||||
|
assert.equal(skinSitePlayers.value[0].name, 'PlayerOne Renamed')
|
||||||
|
assert.equal(skinSitePlayers.value[0].headDataUrl, 'data:image/png;base64,SEVBRERBVEE=')
|
||||||
|
|
||||||
const retry = requestSkinSitePlayers()
|
const retry = requestSkinSitePlayers()
|
||||||
const retryRequest = sent.at(-1)?.data as { requestId: string }
|
const retryRequest = sent.at(-1)?.data as { requestId: string }
|
||||||
receiveSkinSiteMessage(
|
receiveSkinSiteMessage(
|
||||||
@ -184,3 +239,75 @@ test('player requests validate identities and replace the collection atomically'
|
|||||||
setSkinSiteFrame(null)
|
setSkinSiteFrame(null)
|
||||||
resetSkinSiteSession()
|
resetSkinSiteSession()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('skin updates are restricted to known non-Mojang players and validated results', async () => {
|
||||||
|
const sent: Array<{ data: unknown; targetOrigin: string }> = []
|
||||||
|
const frame = {
|
||||||
|
postMessage(data: unknown, targetOrigin: string) {
|
||||||
|
sent.push({ data, targetOrigin })
|
||||||
|
},
|
||||||
|
} as unknown as Window
|
||||||
|
|
||||||
|
resetSkinSiteSession()
|
||||||
|
setSkinSiteFrame(frame)
|
||||||
|
receiveSkinSiteMessage(
|
||||||
|
{
|
||||||
|
origin: SKIN_SITE_ORIGIN,
|
||||||
|
source: frame,
|
||||||
|
data: {
|
||||||
|
type: 'starlight-skin-session',
|
||||||
|
status: 'signed-in',
|
||||||
|
user: { uuid: 'skin-user', username: 'Skin User' },
|
||||||
|
},
|
||||||
|
} as MessageEvent,
|
||||||
|
frame,
|
||||||
|
)
|
||||||
|
const automaticRequest = sent.at(-1)?.data as { requestId: string }
|
||||||
|
receiveSkinSiteMessage(
|
||||||
|
{
|
||||||
|
origin: SKIN_SITE_ORIGIN,
|
||||||
|
source: frame,
|
||||||
|
data: {
|
||||||
|
type: 'starlight-skin-players-result',
|
||||||
|
requestId: automaticRequest.requestId,
|
||||||
|
ok: true,
|
||||||
|
players: [
|
||||||
|
{
|
||||||
|
uuid: '0123456789abcdef0123456789abcdef',
|
||||||
|
name: 'PlayerOne',
|
||||||
|
isMojang: false,
|
||||||
|
skinState: 'empty',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as MessageEvent,
|
||||||
|
frame,
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = requestSkinSiteSkinUpdate(
|
||||||
|
'0123456789abcdef0123456789abcdef',
|
||||||
|
'data:image/png;base64,SEVBRERBVEE=',
|
||||||
|
'slim',
|
||||||
|
)
|
||||||
|
const request = sent.at(-1)?.data as { type: string; requestId: string; playerId: string }
|
||||||
|
assert.equal(request.type, 'starlight-skin-update-request')
|
||||||
|
assert.equal(request.playerId, '0123456789abcdef0123456789abcdef')
|
||||||
|
receiveSkinSiteMessage(
|
||||||
|
{
|
||||||
|
origin: SKIN_SITE_ORIGIN,
|
||||||
|
source: frame,
|
||||||
|
data: { type: 'starlight-skin-update-result', requestId: request.requestId, ok: true },
|
||||||
|
} as MessageEvent,
|
||||||
|
frame,
|
||||||
|
)
|
||||||
|
await result
|
||||||
|
await assert.rejects(
|
||||||
|
requestSkinSiteSkinUpdate(
|
||||||
|
'fedcba9876543210fedcba9876543210',
|
||||||
|
'data:image/png;base64,SEVBRERBVEE=',
|
||||||
|
'default',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
setSkinSiteFrame(null)
|
||||||
|
resetSkinSiteSession()
|
||||||
|
})
|
||||||
|
|||||||
@ -3,17 +3,27 @@ import { readonly, ref } from 'vue'
|
|||||||
export const SKIN_SITE_ORIGIN = 'https://skin.starlight.cool'
|
export const SKIN_SITE_ORIGIN = 'https://skin.starlight.cool'
|
||||||
export type SkinSiteUser = { uuid: string; username: string }
|
export type SkinSiteUser = { uuid: string; username: string }
|
||||||
export type SkinSiteStatus = 'checking' | 'signed-out' | 'signed-in' | 'error'
|
export type SkinSiteStatus = 'checking' | 'signed-out' | 'signed-in' | 'error'
|
||||||
export type SkinSitePlayer = { uuid: string; name: string; isMojang: boolean }
|
export type SkinSitePlayer = {
|
||||||
|
uuid: string
|
||||||
|
name: string
|
||||||
|
isMojang: boolean
|
||||||
|
skinState: 'ready' | 'empty' | 'error'
|
||||||
|
headDataUrl?: string
|
||||||
|
skinDataUrl?: string
|
||||||
|
model?: 'default' | 'slim'
|
||||||
|
}
|
||||||
export type SkinSitePlayersStatus = 'idle' | 'checking' | 'ready' | 'error'
|
export type SkinSitePlayersStatus = 'idle' | 'checking' | 'ready' | 'error'
|
||||||
|
|
||||||
const user = ref<SkinSiteUser | null>(null)
|
const user = ref<SkinSiteUser | null>(null)
|
||||||
const status = ref<SkinSiteStatus>('signed-out')
|
const status = ref<SkinSiteStatus>('signed-out')
|
||||||
const players = ref<SkinSitePlayer[]>([])
|
const players = ref<SkinSitePlayer[]>([])
|
||||||
const playersStatus = ref<SkinSitePlayersStatus>('idle')
|
const playersStatus = ref<SkinSitePlayersStatus>('idle')
|
||||||
|
const selectedPlayerId = ref<string | null>(null)
|
||||||
const frameUrl = ref(`${SKIN_SITE_ORIGIN}/`)
|
const frameUrl = ref(`${SKIN_SITE_ORIGIN}/`)
|
||||||
let connectedFrame: Window | null = null
|
let connectedFrame: Window | null = null
|
||||||
let luckRequestSequence = 0
|
let luckRequestSequence = 0
|
||||||
let playersRequestSequence = 0
|
let playersRequestSequence = 0
|
||||||
|
let skinUpdateRequestSequence = 0
|
||||||
|
|
||||||
type PendingLuckRequest = {
|
type PendingLuckRequest = {
|
||||||
resolve: (luck: number) => void
|
resolve: (luck: number) => void
|
||||||
@ -28,11 +38,36 @@ type PendingPlayersRequest = {
|
|||||||
timeout: ReturnType<typeof setTimeout>
|
timeout: ReturnType<typeof setTimeout>
|
||||||
}
|
}
|
||||||
const pendingPlayersRequests = new Map<string, PendingPlayersRequest>()
|
const pendingPlayersRequests = new Map<string, PendingPlayersRequest>()
|
||||||
|
type PendingSkinUpdateRequest = {
|
||||||
|
resolve: () => void
|
||||||
|
reject: (error: Error) => void
|
||||||
|
timeout: ReturnType<typeof setTimeout>
|
||||||
|
}
|
||||||
|
const pendingSkinUpdateRequests = new Map<string, PendingSkinUpdateRequest>()
|
||||||
|
|
||||||
|
function isSafePngDataUrl(value: unknown, maxLength: number) {
|
||||||
|
return (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
value.length <= maxLength &&
|
||||||
|
/^data:image\/png;base64,[a-z0-9+/]+={0,2}$/i.test(value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSafeSkinHead(player: SkinSitePlayer) {
|
||||||
|
if (!['ready', 'empty', 'error'].includes(player.skinState)) return false
|
||||||
|
if (player.skinState !== 'ready') return player.headDataUrl === undefined
|
||||||
|
return (
|
||||||
|
isSafePngDataUrl(player.headDataUrl, 100_000) &&
|
||||||
|
(player.skinDataUrl === undefined || isSafePngDataUrl(player.skinDataUrl, 2_000_000)) &&
|
||||||
|
(player.model === undefined || player.model === 'default' || player.model === 'slim')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const skinSiteUser = readonly(user)
|
export const skinSiteUser = readonly(user)
|
||||||
export const skinSiteStatus = readonly(status)
|
export const skinSiteStatus = readonly(status)
|
||||||
export const skinSitePlayers = readonly(players)
|
export const skinSitePlayers = readonly(players)
|
||||||
export const skinSitePlayersStatus = readonly(playersStatus)
|
export const skinSitePlayersStatus = readonly(playersStatus)
|
||||||
|
export const selectedSkinSitePlayerId = readonly(selectedPlayerId)
|
||||||
export const skinSiteFrameUrl = readonly(frameUrl)
|
export const skinSiteFrameUrl = readonly(frameUrl)
|
||||||
|
|
||||||
function rejectPendingLuckRequests(message: string) {
|
function rejectPendingLuckRequests(message: string) {
|
||||||
@ -51,13 +86,30 @@ function rejectPendingPlayersRequests(message: string) {
|
|||||||
pendingPlayersRequests.clear()
|
pendingPlayersRequests.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rejectPendingSkinUpdateRequests(message: string) {
|
||||||
|
for (const request of pendingSkinUpdateRequests.values()) {
|
||||||
|
clearTimeout(request.timeout)
|
||||||
|
request.reject(new Error(message))
|
||||||
|
}
|
||||||
|
pendingSkinUpdateRequests.clear()
|
||||||
|
}
|
||||||
|
|
||||||
export function setSkinSiteFrame(frame: Window | null) {
|
export function setSkinSiteFrame(frame: Window | null) {
|
||||||
if (connectedFrame === frame) return
|
if (connectedFrame === frame) return
|
||||||
rejectPendingLuckRequests('The skin site connection changed.')
|
rejectPendingLuckRequests('The skin site connection changed.')
|
||||||
rejectPendingPlayersRequests('The skin site connection changed.')
|
rejectPendingPlayersRequests('The skin site connection changed.')
|
||||||
|
rejectPendingSkinUpdateRequests('The skin site connection changed.')
|
||||||
connectedFrame = frame
|
connectedFrame = frame
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function selectSkinSitePlayer(playerId: string | null) {
|
||||||
|
if (playerId === null) {
|
||||||
|
selectedPlayerId.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (players.value.some((player) => player.uuid === playerId)) selectedPlayerId.value = playerId
|
||||||
|
}
|
||||||
|
|
||||||
export function openSkinSiteLogin() {
|
export function openSkinSiteLogin() {
|
||||||
frameUrl.value = `${SKIN_SITE_ORIGIN}/login`
|
frameUrl.value = `${SKIN_SITE_ORIGIN}/login`
|
||||||
}
|
}
|
||||||
@ -65,10 +117,54 @@ export function openSkinSiteLogin() {
|
|||||||
export function resetSkinSiteSession() {
|
export function resetSkinSiteSession() {
|
||||||
rejectPendingLuckRequests('The skin site session ended.')
|
rejectPendingLuckRequests('The skin site session ended.')
|
||||||
rejectPendingPlayersRequests('The skin site session ended.')
|
rejectPendingPlayersRequests('The skin site session ended.')
|
||||||
|
rejectPendingSkinUpdateRequests('The skin site session ended.')
|
||||||
user.value = null
|
user.value = null
|
||||||
status.value = 'signed-out'
|
status.value = 'signed-out'
|
||||||
players.value = []
|
players.value = []
|
||||||
playersStatus.value = 'idle'
|
playersStatus.value = 'idle'
|
||||||
|
selectedPlayerId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestSkinSiteSkinUpdate(
|
||||||
|
playerId: string,
|
||||||
|
textureDataUrl: string,
|
||||||
|
model: 'default' | 'slim',
|
||||||
|
) {
|
||||||
|
const player = players.value.find((candidate) => candidate.uuid === playerId)
|
||||||
|
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
|
||||||
|
return Promise.reject(new Error('Sign in to the skin site before changing a skin.'))
|
||||||
|
}
|
||||||
|
if (!player || player.isMojang) {
|
||||||
|
return Promise.reject(new Error('This skin site player cannot be changed.'))
|
||||||
|
}
|
||||||
|
if (!isSafePngDataUrl(textureDataUrl, 2_000_000)) {
|
||||||
|
return Promise.reject(new Error('The selected skin texture is invalid or too large.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = `skin-update-${Date.now()}-${++skinUpdateRequestSequence}`
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
pendingSkinUpdateRequests.delete(requestId)
|
||||||
|
reject(new Error('The skin site update timed out.'))
|
||||||
|
}, 20_000)
|
||||||
|
pendingSkinUpdateRequests.set(requestId, { resolve, reject, timeout })
|
||||||
|
try {
|
||||||
|
connectedFrame?.postMessage(
|
||||||
|
{
|
||||||
|
type: 'starlight-skin-update-request',
|
||||||
|
requestId,
|
||||||
|
playerId,
|
||||||
|
textureDataUrl,
|
||||||
|
model,
|
||||||
|
},
|
||||||
|
SKIN_SITE_ORIGIN,
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
pendingSkinUpdateRequests.delete(requestId)
|
||||||
|
reject(error instanceof Error ? error : new Error(String(error)))
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requestSkinSitePlayers() {
|
export function requestSkinSitePlayers() {
|
||||||
@ -83,7 +179,7 @@ export function requestSkinSitePlayers() {
|
|||||||
pendingPlayersRequests.delete(requestId)
|
pendingPlayersRequests.delete(requestId)
|
||||||
playersStatus.value = 'error'
|
playersStatus.value = 'error'
|
||||||
reject(new Error('The skin site player request timed out.'))
|
reject(new Error('The skin site player request timed out.'))
|
||||||
}, 12_000)
|
}, 30_000)
|
||||||
pendingPlayersRequests.set(requestId, { resolve, reject, timeout })
|
pendingPlayersRequests.set(requestId, { resolve, reject, timeout })
|
||||||
try {
|
try {
|
||||||
connectedFrame?.postMessage(
|
connectedFrame?.postMessage(
|
||||||
@ -145,11 +241,24 @@ export function receiveSkinSiteMessage(event: MessageEvent, frame: Window | null
|
|||||||
typeof (player as SkinSitePlayer).name === 'string' &&
|
typeof (player as SkinSitePlayer).name === 'string' &&
|
||||||
(player as SkinSitePlayer).name.length > 0 &&
|
(player as SkinSitePlayer).name.length > 0 &&
|
||||||
(player as SkinSitePlayer).name.length <= 64 &&
|
(player as SkinSitePlayer).name.length <= 64 &&
|
||||||
typeof (player as SkinSitePlayer).isMojang === 'boolean',
|
typeof (player as SkinSitePlayer).isMojang === 'boolean' &&
|
||||||
|
hasSafeSkinHead(player as SkinSitePlayer),
|
||||||
)
|
)
|
||||||
players.value = nextPlayers
|
const previousPlayers = new Map(players.value.map((player) => [player.uuid, player]))
|
||||||
|
players.value = nextPlayers.map((player) => {
|
||||||
|
const previous = previousPlayers.get(player.uuid)
|
||||||
|
return player.skinState === 'error' && previous?.skinState === 'ready'
|
||||||
|
? { ...player, ...previous, name: player.name, isMojang: player.isMojang }
|
||||||
|
: player
|
||||||
|
})
|
||||||
|
if (
|
||||||
|
selectedPlayerId.value &&
|
||||||
|
!nextPlayers.some((player) => player.uuid === selectedPlayerId.value)
|
||||||
|
) {
|
||||||
|
selectedPlayerId.value = null
|
||||||
|
}
|
||||||
playersStatus.value = 'ready'
|
playersStatus.value = 'ready'
|
||||||
pending.resolve(nextPlayers)
|
pending.resolve(players.value)
|
||||||
} else {
|
} else {
|
||||||
playersStatus.value = 'error'
|
playersStatus.value = 'error'
|
||||||
pending.reject(
|
pending.reject(
|
||||||
@ -189,6 +298,25 @@ export function receiveSkinSiteMessage(event: MessageEvent, frame: Window | null
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.type === 'starlight-skin-update-result') {
|
||||||
|
if (typeof data.requestId !== 'string') return false
|
||||||
|
const pending = pendingSkinUpdateRequests.get(data.requestId)
|
||||||
|
if (!pending) return false
|
||||||
|
clearTimeout(pending.timeout)
|
||||||
|
pendingSkinUpdateRequests.delete(data.requestId)
|
||||||
|
if (data.ok === true) pending.resolve()
|
||||||
|
else {
|
||||||
|
pending.reject(
|
||||||
|
new Error(
|
||||||
|
typeof data.error === 'string' && data.error
|
||||||
|
? data.error.slice(0, 300)
|
||||||
|
: 'The skin site rejected the skin update.',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if (data.type !== 'starlight-skin-session') return false
|
if (data.type !== 'starlight-skin-session') return false
|
||||||
if (!['checking', 'signed-out', 'signed-in', 'error'].includes(data.status)) return false
|
if (!['checking', 'signed-out', 'signed-in', 'error'].includes(data.status)) return false
|
||||||
let refreshPlayers = false
|
let refreshPlayers = false
|
||||||
@ -204,8 +332,10 @@ export function receiveSkinSiteMessage(event: MessageEvent, frame: Window | null
|
|||||||
return false
|
return false
|
||||||
if (user.value?.uuid && user.value.uuid !== data.user.uuid) {
|
if (user.value?.uuid && user.value.uuid !== data.user.uuid) {
|
||||||
rejectPendingPlayersRequests('The skin site account changed.')
|
rejectPendingPlayersRequests('The skin site account changed.')
|
||||||
|
rejectPendingSkinUpdateRequests('The skin site account changed.')
|
||||||
players.value = []
|
players.value = []
|
||||||
playersStatus.value = 'idle'
|
playersStatus.value = 'idle'
|
||||||
|
selectedPlayerId.value = null
|
||||||
}
|
}
|
||||||
user.value = { uuid: data.user.uuid, username: data.user.username }
|
user.value = { uuid: data.user.uuid, username: data.user.username }
|
||||||
if ([`${SKIN_SITE_ORIGIN}/`, `${SKIN_SITE_ORIGIN}/login`].includes(frameUrl.value)) {
|
if ([`${SKIN_SITE_ORIGIN}/`, `${SKIN_SITE_ORIGIN}/login`].includes(frameUrl.value)) {
|
||||||
@ -215,9 +345,11 @@ export function receiveSkinSiteMessage(event: MessageEvent, frame: Window | null
|
|||||||
} else {
|
} else {
|
||||||
rejectPendingLuckRequests('The skin site session is unavailable.')
|
rejectPendingLuckRequests('The skin site session is unavailable.')
|
||||||
rejectPendingPlayersRequests('The skin site session is unavailable.')
|
rejectPendingPlayersRequests('The skin site session is unavailable.')
|
||||||
|
rejectPendingSkinUpdateRequests('The skin site session is unavailable.')
|
||||||
user.value = null
|
user.value = null
|
||||||
players.value = []
|
players.value = []
|
||||||
playersStatus.value = 'idle'
|
playersStatus.value = 'idle'
|
||||||
|
selectedPlayerId.value = null
|
||||||
}
|
}
|
||||||
status.value = data.status
|
status.value = data.status
|
||||||
if (refreshPlayers) void requestSkinSitePlayers().catch(() => {})
|
if (refreshPlayers) void requestSkinSitePlayers().catch(() => {})
|
||||||
|
|||||||
@ -118,6 +118,10 @@ export async function get_available_skins(): Promise<Skin[]> {
|
|||||||
return invoke('plugin:minecraft-skins|get_available_skins', {})
|
return invoke('plugin:minecraft-skins|get_available_skins', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function get_default_skins(): Promise<Skin[]> {
|
||||||
|
return invoke('plugin:minecraft-skins|get_default_skins', {})
|
||||||
|
}
|
||||||
|
|
||||||
export async function add_and_equip_custom_skin(
|
export async function add_and_equip_custom_skin(
|
||||||
textureBlob: Uint8Array,
|
textureBlob: Uint8Array,
|
||||||
variant: SkinModel,
|
variant: SkinModel,
|
||||||
|
|||||||
@ -48,8 +48,6 @@
|
|||||||
"minecraft-account.skin-site.players.error": {
|
"minecraft-account.skin-site.players.error": {
|
||||||
"message": "Could not refresh the player list. Previously loaded players are kept."
|
"message": "Could not refresh the player list. Previously loaded players are kept."
|
||||||
},
|
},
|
||||||
"minecraft-account.skin-site.player.ready": { "message": "Ready to launch" },
|
|
||||||
"minecraft-account.skin-site.player.synced": { "message": "From skin site" },
|
|
||||||
"app.easteregg.color-mine.title": {
|
"app.easteregg.color-mine.title": {
|
||||||
"message": "Starlight Mine: Chromatic Realms"
|
"message": "Starlight Mine: Chromatic Realms"
|
||||||
},
|
},
|
||||||
@ -7796,6 +7794,27 @@
|
|||||||
"app.skins.section.tiny-takeover": {
|
"app.skins.section.tiny-takeover": {
|
||||||
"message": "Tiny Takeover"
|
"message": "Tiny Takeover"
|
||||||
},
|
},
|
||||||
|
"app.skins.skin-site-account.description": {
|
||||||
|
"message": "Select a skin below and apply it directly to {player}. The skin site session remains available while you move between launcher pages."
|
||||||
|
},
|
||||||
|
"app.skins.skin-site-account.empty-description": {
|
||||||
|
"message": "This skin site account does not have an available player profile yet."
|
||||||
|
},
|
||||||
|
"app.skins.skin-site-account.empty-title": {
|
||||||
|
"message": "No players available"
|
||||||
|
},
|
||||||
|
"app.skins.skin-site-account.loading-description": {
|
||||||
|
"message": "The launcher is synchronizing the players attached to your account."
|
||||||
|
},
|
||||||
|
"app.skins.skin-site-account.loading-title": {
|
||||||
|
"message": "Loading skin site players…"
|
||||||
|
},
|
||||||
|
"app.skins.skin-site-account.mojang-description": {
|
||||||
|
"message": "{player} is an official Minecraft player. Its skin remains managed by the official account provider."
|
||||||
|
},
|
||||||
|
"app.skins.skin-site-account.title": {
|
||||||
|
"message": "StarLight skin site player"
|
||||||
|
},
|
||||||
"app.skins.sign-in.axolotl-alt": {
|
"app.skins.sign-in.axolotl-alt": {
|
||||||
"message": "Starlight Launcher"
|
"message": "Starlight Launcher"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -44,8 +44,6 @@
|
|||||||
"minecraft-account.skin-site.players.error": {
|
"minecraft-account.skin-site.players.error": {
|
||||||
"message": "暂时无法刷新玩家列表,已保留上一次结果。"
|
"message": "暂时无法刷新玩家列表,已保留上一次结果。"
|
||||||
},
|
},
|
||||||
"minecraft-account.skin-site.player.ready": { "message": "可启动" },
|
|
||||||
"minecraft-account.skin-site.player.synced": { "message": "来自皮肤站" },
|
|
||||||
"app.easteregg.color-mine.title": {
|
"app.easteregg.color-mine.title": {
|
||||||
"message": "星光矿井:彩域"
|
"message": "星光矿井:彩域"
|
||||||
},
|
},
|
||||||
@ -7867,6 +7865,27 @@
|
|||||||
"app.skins.section.tiny-takeover": {
|
"app.skins.section.tiny-takeover": {
|
||||||
"message": "小鬼当家"
|
"message": "小鬼当家"
|
||||||
},
|
},
|
||||||
|
"app.skins.skin-site-account.description": {
|
||||||
|
"message": "在下方选择皮肤后,可直接应用到 {player}。切换启动器页面时,皮肤站登录状态也会保持。"
|
||||||
|
},
|
||||||
|
"app.skins.skin-site-account.empty-description": {
|
||||||
|
"message": "这个皮肤站账号暂时没有可用的玩家角色。"
|
||||||
|
},
|
||||||
|
"app.skins.skin-site-account.empty-title": {
|
||||||
|
"message": "暂无可用玩家"
|
||||||
|
},
|
||||||
|
"app.skins.skin-site-account.loading-description": {
|
||||||
|
"message": "启动器正在同步此账号绑定的玩家。"
|
||||||
|
},
|
||||||
|
"app.skins.skin-site-account.loading-title": {
|
||||||
|
"message": "正在加载皮肤站玩家……"
|
||||||
|
},
|
||||||
|
"app.skins.skin-site-account.mojang-description": {
|
||||||
|
"message": "{player} 是正版 Minecraft 玩家,其皮肤仍由正版账号服务管理。"
|
||||||
|
},
|
||||||
|
"app.skins.skin-site-account.title": {
|
||||||
|
"message": "StarLight 皮肤站玩家"
|
||||||
|
},
|
||||||
"app.skins.sign-in.axolotl-alt": {
|
"app.skins.sign-in.axolotl-alt": {
|
||||||
"message": "Starlight Launcher"
|
"message": "Starlight Launcher"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -44,8 +44,6 @@
|
|||||||
"minecraft-account.skin-site.players.error": {
|
"minecraft-account.skin-site.players.error": {
|
||||||
"message": "暫時無法重新整理玩家清單,已保留上一次結果。"
|
"message": "暫時無法重新整理玩家清單,已保留上一次結果。"
|
||||||
},
|
},
|
||||||
"minecraft-account.skin-site.player.ready": { "message": "可啟動" },
|
|
||||||
"minecraft-account.skin-site.player.synced": { "message": "來自皮膚站" },
|
|
||||||
"app.easteregg.color-mine.title": {
|
"app.easteregg.color-mine.title": {
|
||||||
"message": "星光礦井:彩域"
|
"message": "星光礦井:彩域"
|
||||||
},
|
},
|
||||||
@ -7558,6 +7556,27 @@
|
|||||||
"app.skins.section.tiny-takeover": {
|
"app.skins.section.tiny-takeover": {
|
||||||
"message": "小鬼當家"
|
"message": "小鬼當家"
|
||||||
},
|
},
|
||||||
|
"app.skins.skin-site-account.description": {
|
||||||
|
"message": "在下方選擇皮膚後,可直接套用到 {player}。切換啟動器頁面時,皮膚站登入狀態也會保持。"
|
||||||
|
},
|
||||||
|
"app.skins.skin-site-account.empty-description": {
|
||||||
|
"message": "這個皮膚站帳號暫時沒有可用的玩家角色。"
|
||||||
|
},
|
||||||
|
"app.skins.skin-site-account.empty-title": {
|
||||||
|
"message": "暫無可用玩家"
|
||||||
|
},
|
||||||
|
"app.skins.skin-site-account.loading-description": {
|
||||||
|
"message": "啟動器正在同步此帳號綁定的玩家。"
|
||||||
|
},
|
||||||
|
"app.skins.skin-site-account.loading-title": {
|
||||||
|
"message": "正在載入皮膚站玩家……"
|
||||||
|
},
|
||||||
|
"app.skins.skin-site-account.mojang-description": {
|
||||||
|
"message": "{player} 是正版 Minecraft 玩家,其皮膚仍由正版帳號服務管理。"
|
||||||
|
},
|
||||||
|
"app.skins.skin-site-account.title": {
|
||||||
|
"message": "StarLight 皮膚站玩家"
|
||||||
|
},
|
||||||
"app.skins.sign-in.axolotl-alt": {
|
"app.skins.sign-in.axolotl-alt": {
|
||||||
"message": "Starlight Launcher"
|
"message": "Starlight Launcher"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -31,12 +31,23 @@ import { onBeforeRouteLeave, useRouter } from 'vue-router'
|
|||||||
import type AccountsCard from '@/components/ui/AccountsCard.vue'
|
import type AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||||
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
|
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
|
||||||
import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue'
|
import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue'
|
||||||
|
import {
|
||||||
|
openSkinSiteLogin,
|
||||||
|
requestSkinSitePlayers,
|
||||||
|
requestSkinSiteSkinUpdate,
|
||||||
|
selectedSkinSitePlayerId,
|
||||||
|
skinSitePlayers,
|
||||||
|
skinSitePlayersStatus,
|
||||||
|
skinSiteStatus,
|
||||||
|
skinSiteUser,
|
||||||
|
} from '@/composables/skin-site-session'
|
||||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||||
import { check_reachable, get_default_user, users } from '@/helpers/auth'
|
import { check_reachable, get_default_user, users } from '@/helpers/auth'
|
||||||
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
|
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||||
import { skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
|
import { skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||||
import type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts'
|
import type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts'
|
||||||
import {
|
import {
|
||||||
|
determineModelType,
|
||||||
equip_skin,
|
equip_skin,
|
||||||
filterDefaultSkins,
|
filterDefaultSkins,
|
||||||
filterSavedSkins,
|
filterSavedSkins,
|
||||||
@ -44,6 +55,7 @@ import {
|
|||||||
flush_pending_skin_change_for_profile,
|
flush_pending_skin_change_for_profile,
|
||||||
get_available_capes,
|
get_available_capes,
|
||||||
get_available_skins,
|
get_available_skins,
|
||||||
|
get_default_skins,
|
||||||
get_normalized_skin_texture,
|
get_normalized_skin_texture,
|
||||||
normalize_skin_texture,
|
normalize_skin_texture,
|
||||||
remove_custom_skin,
|
remove_custom_skin,
|
||||||
@ -179,6 +191,22 @@ const messages = defineMessages({
|
|||||||
id: 'app.skins.sign-in.button',
|
id: 'app.skins.sign-in.button',
|
||||||
defaultMessage: 'Sign In',
|
defaultMessage: 'Sign In',
|
||||||
},
|
},
|
||||||
|
loadingSkinSitePlayersTitle: {
|
||||||
|
id: 'app.skins.skin-site-account.loading-title',
|
||||||
|
defaultMessage: 'Loading skin site players…',
|
||||||
|
},
|
||||||
|
loadingSkinSitePlayersDescription: {
|
||||||
|
id: 'app.skins.skin-site-account.loading-description',
|
||||||
|
defaultMessage: 'The launcher is synchronizing the players attached to your account.',
|
||||||
|
},
|
||||||
|
noSkinSitePlayersTitle: {
|
||||||
|
id: 'app.skins.skin-site-account.empty-title',
|
||||||
|
defaultMessage: 'No players available',
|
||||||
|
},
|
||||||
|
noSkinSitePlayersDescription: {
|
||||||
|
id: 'app.skins.skin-site-account.empty-description',
|
||||||
|
defaultMessage: 'This skin site account does not have an available player profile yet.',
|
||||||
|
},
|
||||||
offlineCompatibility: {
|
offlineCompatibility: {
|
||||||
id: 'app.skins.offline-account.compatibility',
|
id: 'app.skins.offline-account.compatibility',
|
||||||
defaultMessage:
|
defaultMessage:
|
||||||
@ -197,6 +225,20 @@ const messages = defineMessages({
|
|||||||
defaultMessage:
|
defaultMessage:
|
||||||
'Skins for this account are managed by its Yggdrasil provider. Open the provider website to change skins or capes.',
|
'Skins for this account are managed by its Yggdrasil provider. Open the provider website to change skins or capes.',
|
||||||
},
|
},
|
||||||
|
skinSiteManagementTitle: {
|
||||||
|
id: 'app.skins.skin-site-account.title',
|
||||||
|
defaultMessage: 'StarLight skin site player',
|
||||||
|
},
|
||||||
|
skinSiteManagementDescription: {
|
||||||
|
id: 'app.skins.skin-site-account.description',
|
||||||
|
defaultMessage:
|
||||||
|
'Select a skin below and apply it directly to {player}. The skin site session remains available while you move between launcher pages.',
|
||||||
|
},
|
||||||
|
skinSiteMojangDescription: {
|
||||||
|
id: 'app.skins.skin-site-account.mojang-description',
|
||||||
|
defaultMessage:
|
||||||
|
'{player} is an official Minecraft player. Its skin remains managed by the official account provider.',
|
||||||
|
},
|
||||||
savedTab: {
|
savedTab: {
|
||||||
id: 'app.skins.tabs.saved',
|
id: 'app.skins.tabs.saved',
|
||||||
defaultMessage: 'Saved skins',
|
defaultMessage: 'Saved skins',
|
||||||
@ -228,7 +270,16 @@ const currentUser = ref(undefined)
|
|||||||
const currentUserId = ref<string | undefined>(undefined)
|
const currentUserId = ref<string | undefined>(undefined)
|
||||||
const currentAccountType = ref<'microsoft' | 'offline' | 'yggdrasil' | undefined>(undefined)
|
const currentAccountType = ref<'microsoft' | 'offline' | 'yggdrasil' | undefined>(undefined)
|
||||||
|
|
||||||
const username = computed(() => currentUser.value?.profile?.name ?? undefined)
|
const activeSkinSitePlayer = computed(() =>
|
||||||
|
selectedSkinSitePlayerId.value
|
||||||
|
? skinSitePlayers.value.find((player) => player.uuid === selectedSkinSitePlayerId.value)
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
const isSkinSiteProfile = computed(() => Boolean(activeSkinSitePlayer.value && skinSiteUser.value))
|
||||||
|
const hasCurrentProfile = computed(() => Boolean(currentUser.value || isSkinSiteProfile.value))
|
||||||
|
const username = computed(
|
||||||
|
() => activeSkinSitePlayer.value?.name ?? currentUser.value?.profile?.name ?? undefined,
|
||||||
|
)
|
||||||
const selectedSkin = ref<Skin | null>(null)
|
const selectedSkin = ref<Skin | null>(null)
|
||||||
const isApplyingSkin = ref(false)
|
const isApplyingSkin = ref(false)
|
||||||
|
|
||||||
@ -314,10 +365,16 @@ const capeTexture = computed(() => currentCape.value?.texture)
|
|||||||
const skinVariant = computed(() => selectedSkin.value?.variant)
|
const skinVariant = computed(() => selectedSkin.value?.variant)
|
||||||
const skinNametag = computed(() => (themeStore.hideNametagSkinsPage ? undefined : username.value))
|
const skinNametag = computed(() => (themeStore.hideNametagSkinsPage ? undefined : username.value))
|
||||||
const isSkinManagementReadOnly = computed(
|
const isSkinManagementReadOnly = computed(
|
||||||
() =>
|
() => {
|
||||||
currentAccountType.value === 'yggdrasil' ||
|
if (isSkinSiteProfile.value) {
|
||||||
(currentAccountType.value !== 'offline' &&
|
return activeSkinSitePlayer.value?.isMojang === true || skinSiteStatus.value !== 'signed-in'
|
||||||
(offline.value || (authServerQuery.isError.value && !authServerQuery.isLoading.value))),
|
}
|
||||||
|
return (
|
||||||
|
currentAccountType.value === 'yggdrasil' ||
|
||||||
|
(currentAccountType.value !== 'offline' &&
|
||||||
|
(offline.value || (authServerQuery.isError.value && !authServerQuery.isLoading.value)))
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
const hasPendingSkinChange = computed(
|
const hasPendingSkinChange = computed(
|
||||||
() => !skinsMatch(selectedSkin.value, originalSelectedSkin.value),
|
() => !skinsMatch(selectedSkin.value, originalSelectedSkin.value),
|
||||||
@ -356,7 +413,7 @@ function confirmDeleteSkin(skin: Skin) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSkin() {
|
async function deleteSkin() {
|
||||||
if (isSkinManagementReadOnly.value) return
|
if (isSkinManagementReadOnly.value || isSkinSiteProfile.value) return
|
||||||
|
|
||||||
const deletedSkin = skinToDelete.value
|
const deletedSkin = skinToDelete.value
|
||||||
if (!deletedSkin) return
|
if (!deletedSkin) return
|
||||||
@ -372,10 +429,17 @@ async function deleteSkin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadCapes() {
|
async function loadCapes() {
|
||||||
|
if (isSkinSiteProfile.value) {
|
||||||
|
capes.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const profileId = currentUserId.value
|
||||||
try {
|
try {
|
||||||
capes.value = (await get_available_capes()) ?? []
|
const loadedCapes = (await get_available_capes()) ?? []
|
||||||
|
if (isSkinSiteProfile.value || currentUserId.value !== profileId) return
|
||||||
|
capes.value = loadedCapes
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (currentUser.value && error instanceof Error) {
|
if (hasCurrentProfile.value && error instanceof Error) {
|
||||||
handleError(error)
|
handleError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -383,7 +447,36 @@ async function loadCapes() {
|
|||||||
|
|
||||||
async function loadSkins() {
|
async function loadSkins() {
|
||||||
try {
|
try {
|
||||||
|
if (isSkinSiteProfile.value) {
|
||||||
|
const player = activeSkinSitePlayer.value
|
||||||
|
if (!player) return
|
||||||
|
const playerId = player.uuid
|
||||||
|
const bundledSkins = (await get_default_skins()).map((skin) => ({
|
||||||
|
...skin,
|
||||||
|
is_equipped: false,
|
||||||
|
}))
|
||||||
|
if (activeSkinSitePlayer.value?.uuid !== playerId) return
|
||||||
|
const currentSkin: Skin | null =
|
||||||
|
player.skinState === 'ready' && player.skinDataUrl
|
||||||
|
? {
|
||||||
|
texture_key: `starlight:${player.uuid}:current`,
|
||||||
|
name: player.name,
|
||||||
|
variant: player.model === 'slim' ? 'SLIM' : 'CLASSIC',
|
||||||
|
texture: player.skinDataUrl,
|
||||||
|
source: 'custom_external',
|
||||||
|
is_equipped: true,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
skins.value = currentSkin ? [currentSkin, ...bundledSkins] : bundledSkins
|
||||||
|
generateSkinPreviews(skins.value, [])
|
||||||
|
selectedSkin.value = currentSkin
|
||||||
|
originalSelectedSkin.value = currentSkin
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileId = currentUserId.value
|
||||||
const loadedSkins = (await get_available_skins()) ?? []
|
const loadedSkins = (await get_available_skins()) ?? []
|
||||||
|
if (isSkinSiteProfile.value || currentUserId.value !== profileId) return
|
||||||
const loadedEquippedSkin = loadedSkins.find((s) => s.is_equipped)
|
const loadedEquippedSkin = loadedSkins.find((s) => s.is_equipped)
|
||||||
const locallyKnownEquippedSkin =
|
const locallyKnownEquippedSkin =
|
||||||
originalSelectedSkin.value &&
|
originalSelectedSkin.value &&
|
||||||
@ -404,7 +497,7 @@ async function loadSkins() {
|
|||||||
selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null
|
selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null
|
||||||
originalSelectedSkin.value = selectedSkin.value
|
originalSelectedSkin.value = selectedSkin.value
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (currentUser.value && error instanceof Error) {
|
if (hasCurrentProfile.value && error instanceof Error) {
|
||||||
handleError(error)
|
handleError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -612,6 +705,7 @@ function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin)
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function reorderSavedSkins(orderedSkins: Skin[]) {
|
async function reorderSavedSkins(orderedSkins: Skin[]) {
|
||||||
|
if (isSkinSiteProfile.value) return
|
||||||
const previousSkins = skins.value
|
const previousSkins = skins.value
|
||||||
const previousSelectedSkin = selectedSkin.value
|
const previousSelectedSkin = selectedSkin.value
|
||||||
const previousOriginalSelectedSkin = originalSelectedSkin.value
|
const previousOriginalSelectedSkin = originalSelectedSkin.value
|
||||||
@ -733,6 +827,19 @@ async function applySelectedSkin() {
|
|||||||
|
|
||||||
isApplyingSkin.value = true
|
isApplyingSkin.value = true
|
||||||
try {
|
try {
|
||||||
|
if (isSkinSiteProfile.value) {
|
||||||
|
const player = activeSkinSitePlayer.value
|
||||||
|
if (!player) return
|
||||||
|
const textureDataUrl = await get_normalized_skin_texture(skinToApply)
|
||||||
|
await requestSkinSiteSkinUpdate(
|
||||||
|
player.uuid,
|
||||||
|
textureDataUrl,
|
||||||
|
skinToApply.variant === 'SLIM' ? 'slim' : 'default',
|
||||||
|
)
|
||||||
|
await requestSkinSitePlayers()
|
||||||
|
await loadSkins()
|
||||||
|
return
|
||||||
|
}
|
||||||
await equip_skin(skinToApply)
|
await equip_skin(skinToApply)
|
||||||
setLocallyEquippedSkin(skinToApply)
|
setLocallyEquippedSkin(skinToApply)
|
||||||
schedulePendingSkinRefresh()
|
schedulePendingSkinRefresh()
|
||||||
@ -776,7 +883,7 @@ async function loadCurrentUser() {
|
|||||||
currentAccountType.value = selectedAccount?.account_type
|
currentAccountType.value = selectedAccount?.account_type
|
||||||
currentUser.value = selectedAccount
|
currentUser.value = selectedAccount
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e as Error)
|
if (!isSkinSiteProfile.value) handleError(e as Error)
|
||||||
currentUser.value = undefined
|
currentUser.value = undefined
|
||||||
currentUserId.value = undefined
|
currentUserId.value = undefined
|
||||||
currentAccountType.value = undefined
|
currentAccountType.value = undefined
|
||||||
@ -794,14 +901,22 @@ watch(accountChangeRevision, (revision, previousRevision) => {
|
|||||||
void refreshSelectedAccount()
|
void refreshSelectedAccount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [selectedSkinSitePlayerId.value, activeSkinSitePlayer.value?.skinDataUrl] as const,
|
||||||
|
() => {
|
||||||
|
void loadCapes()
|
||||||
|
void loadSkins()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
|
function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
|
||||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||||
return skinBlobUrlMap.get(key)
|
return skinBlobUrlMap.get(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login() {
|
async function loginToSkinSite() {
|
||||||
if (offline.value) return
|
openSkinSiteLogin()
|
||||||
accountsCard.value?.login()
|
await router.push('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAddSkinFileBrowser() {
|
function openAddSkinFileBrowser() {
|
||||||
@ -877,7 +992,6 @@ async function onAddSkinDrop(event: DragEvent) {
|
|||||||
async function processSkinFileBuffer(buffer: Uint8Array | ArrayBuffer) {
|
async function processSkinFileBuffer(buffer: Uint8Array | ArrayBuffer) {
|
||||||
if (isSkinManagementReadOnly.value) return
|
if (isSkinManagementReadOnly.value) return
|
||||||
|
|
||||||
const fakeEvent = new MouseEvent('click')
|
|
||||||
const originalSkinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(buffer)
|
const originalSkinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(buffer)
|
||||||
try {
|
try {
|
||||||
const skinTextureNormalized = await normalize_skin_texture(originalSkinTexUrl)
|
const skinTextureNormalized = await normalize_skin_texture(originalSkinTexUrl)
|
||||||
@ -885,6 +999,24 @@ async function processSkinFileBuffer(buffer: Uint8Array | ArrayBuffer) {
|
|||||||
original: originalSkinTexUrl,
|
original: originalSkinTexUrl,
|
||||||
normalized: `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized),
|
normalized: `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized),
|
||||||
}
|
}
|
||||||
|
if (isSkinSiteProfile.value) {
|
||||||
|
const variant = await determineModelType(skinTexUrl.normalized)
|
||||||
|
const pendingSkin: Skin = {
|
||||||
|
texture_key: `starlight-upload:${Date.now()}`,
|
||||||
|
name: username.value,
|
||||||
|
variant,
|
||||||
|
texture: skinTexUrl.normalized,
|
||||||
|
source: 'custom_external',
|
||||||
|
is_equipped: false,
|
||||||
|
}
|
||||||
|
skins.value = [pendingSkin, ...skins.value.filter((skin) => skin.source === 'default')]
|
||||||
|
selectedSkin.value = pendingSkin
|
||||||
|
skinListTab.value = 'saved'
|
||||||
|
generateSkinPreviews(skins.value, [])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeEvent = new MouseEvent('click')
|
||||||
editSkinModal.value?.showNew(fakeEvent, skinTexUrl)
|
editSkinModal.value?.showNew(fakeEvent, skinTexUrl)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error as Error)
|
handleError(error as Error)
|
||||||
@ -984,6 +1116,7 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function checkUserChanges() {
|
async function checkUserChanges() {
|
||||||
|
if (isSkinSiteProfile.value) return
|
||||||
try {
|
try {
|
||||||
const defaultId = await get_default_user(offline.value)
|
const defaultId = await get_default_user(offline.value)
|
||||||
if (defaultId !== currentUserId.value) {
|
if (defaultId !== currentUserId.value) {
|
||||||
@ -1047,9 +1180,26 @@ await loadSkins()
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
<Teleport v-if="isSkinSiteProfile" to="#sidebar-default-teleport-target">
|
||||||
|
<section class="p-4">
|
||||||
|
<h3 class="m-0 text-base font-semibold text-primary">
|
||||||
|
{{ formatMessage(messages.skinSiteManagementTitle) }}
|
||||||
|
</h3>
|
||||||
|
<p class="mb-0 mt-2 text-sm leading-6 text-secondary">
|
||||||
|
{{
|
||||||
|
formatMessage(
|
||||||
|
activeSkinSitePlayer?.isMojang
|
||||||
|
? messages.skinSiteMojangDescription
|
||||||
|
: messages.skinSiteManagementDescription,
|
||||||
|
{ player: activeSkinSitePlayer?.name ?? '' },
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="currentUser"
|
v-if="hasCurrentProfile"
|
||||||
data-onboarding-id="skins-page"
|
data-onboarding-id="skins-page"
|
||||||
class="skin-layout box-border min-h-full p-4"
|
class="skin-layout box-border min-h-full p-4"
|
||||||
>
|
>
|
||||||
@ -1100,7 +1250,7 @@ await loadSkins()
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-else
|
v-else-if="!isSkinSiteProfile"
|
||||||
class="flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
|
class="flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
|
||||||
:disabled="!selectedSkin || isSkinManagementReadOnly"
|
:disabled="!selectedSkin || isSkinManagementReadOnly"
|
||||||
@click="(e: MouseEvent) => selectedSkin && editSkinModal?.show(e, selectedSkin)"
|
@click="(e: MouseEvent) => selectedSkin && editSkinModal?.show(e, selectedSkin)"
|
||||||
@ -1125,7 +1275,7 @@ await loadSkins()
|
|||||||
}
|
}
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<ButtonStyled color="brand">
|
<ButtonStyled v-if="!isSkinSiteProfile" color="brand">
|
||||||
<button @click="router.push('/lab/skin-editor')">
|
<button @click="router.push('/lab/skin-editor')">
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
{{ formatMessage(messages.createSkinButton) }}
|
{{ formatMessage(messages.createSkinButton) }}
|
||||||
@ -1142,8 +1292,9 @@ await loadSkins()
|
|||||||
:is-skin-active="isSkinActive"
|
:is-skin-active="isSkinActive"
|
||||||
:is-add-skin-button-drag-active="isAddSkinButtonDragActive"
|
:is-add-skin-button-drag-active="isAddSkinButtonDragActive"
|
||||||
:read-only="isSkinManagementReadOnly"
|
:read-only="isSkinManagementReadOnly"
|
||||||
|
:manage-saved-skins="!isSkinSiteProfile"
|
||||||
@select="changeSkin"
|
@select="changeSkin"
|
||||||
@edit="(skin, event) => editSkinModal?.show(event, skin)"
|
@edit="(skin, event) => !isSkinSiteProfile && editSkinModal?.show(event, skin)"
|
||||||
@delete="confirmDeleteSkin"
|
@delete="confirmDeleteSkin"
|
||||||
@reorder-saved-skins="reorderSavedSkins"
|
@reorder-saved-skins="reorderSavedSkins"
|
||||||
@add-skin="openAddSkinFileBrowser"
|
@add-skin="openAddSkinFileBrowser"
|
||||||
@ -1183,20 +1334,37 @@ await loadSkins()
|
|||||||
|
|
||||||
<div class="flex flex-col gap-5">
|
<div class="flex flex-col gap-5">
|
||||||
<h1 class="text-3xl font-extrabold m-0">
|
<h1 class="text-3xl font-extrabold m-0">
|
||||||
{{ formatMessage(messages.signInTitle) }}
|
{{
|
||||||
|
formatMessage(
|
||||||
|
skinSiteUser
|
||||||
|
? skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle'
|
||||||
|
? messages.loadingSkinSitePlayersTitle
|
||||||
|
: messages.noSkinSitePlayersTitle
|
||||||
|
: messages.signInTitle,
|
||||||
|
)
|
||||||
|
}}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-lg m-0">
|
<p class="text-lg m-0">
|
||||||
{{ formatMessage(messages.signInDescription) }}
|
{{
|
||||||
|
formatMessage(
|
||||||
|
skinSiteUser
|
||||||
|
? skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle'
|
||||||
|
? messages.loadingSkinSitePlayersDescription
|
||||||
|
: messages.noSkinSitePlayersDescription
|
||||||
|
: messages.signInDescription,
|
||||||
|
)
|
||||||
|
}}
|
||||||
</p>
|
</p>
|
||||||
<ButtonStyled
|
<SpinnerIcon
|
||||||
v-if="!offline"
|
v-if="
|
||||||
v-show="accountsCard"
|
skinSiteUser &&
|
||||||
color="brand"
|
(skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle')
|
||||||
:disabled="accountsCard.loginDisabled"
|
"
|
||||||
>
|
class="h-8 w-8 animate-spin text-brand"
|
||||||
<button :disabled="accountsCard.loginDisabled" @click="login">
|
/>
|
||||||
<LogInIcon v-if="!accountsCard.loginDisabled" />
|
<ButtonStyled v-if="!offline && !skinSiteUser" color="brand">
|
||||||
<SpinnerIcon v-else class="animate-spin" />
|
<button @click="loginToSkinSite">
|
||||||
|
<LogInIcon />
|
||||||
{{ formatMessage(messages.signInButton) }}
|
{{ formatMessage(messages.signInButton) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
|
|||||||
@ -320,6 +320,7 @@ fn main() {
|
|||||||
.commands(&[
|
.commands(&[
|
||||||
"get_available_capes",
|
"get_available_capes",
|
||||||
"get_available_skins",
|
"get_available_skins",
|
||||||
|
"get_default_skins",
|
||||||
"add_and_equip_custom_skin",
|
"add_and_equip_custom_skin",
|
||||||
"equip_skin",
|
"equip_skin",
|
||||||
"remove_custom_skin",
|
"remove_custom_skin",
|
||||||
|
|||||||
@ -10,6 +10,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
|||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
get_available_capes,
|
get_available_capes,
|
||||||
get_available_skins,
|
get_available_skins,
|
||||||
|
get_default_skins,
|
||||||
add_and_equip_custom_skin,
|
add_and_equip_custom_skin,
|
||||||
equip_skin,
|
equip_skin,
|
||||||
remove_custom_skin,
|
remove_custom_skin,
|
||||||
@ -40,6 +41,12 @@ pub async fn get_available_skins() -> Result<Vec<Skin>> {
|
|||||||
Ok(minecraft_skins::get_available_skins().await?)
|
Ok(minecraft_skins::get_available_skins().await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `invoke('plugin:minecraft-skins|get_default_skins')`
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_default_skins() -> Result<Vec<Skin>> {
|
||||||
|
Ok(minecraft_skins::get_default_skins())
|
||||||
|
}
|
||||||
|
|
||||||
/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape)`
|
/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape)`
|
||||||
///
|
///
|
||||||
/// See also: [minecraft_skins::add_and_equip_custom_skin]
|
/// See also: [minecraft_skins::add_and_equip_custom_skin]
|
||||||
|
|||||||
@ -15,6 +15,8 @@
|
|||||||
let pending
|
let pending
|
||||||
const pendingLuck = new Map()
|
const pendingLuck = new Map()
|
||||||
const pendingPlayers = new Map()
|
const pendingPlayers = new Map()
|
||||||
|
const pendingSkinUpdates = new Map()
|
||||||
|
let knownPlayers = new Map()
|
||||||
let snapshot = { status: 'checking', user: null }
|
let snapshot = { status: 'checking', user: null }
|
||||||
|
|
||||||
function publish(status, user = null) {
|
function publish(status, user = null) {
|
||||||
@ -40,6 +42,106 @@
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function publishSkinUpdate(requestId, result) {
|
||||||
|
if (!parentOrigin) return
|
||||||
|
window.parent.postMessage(
|
||||||
|
{ type: 'starlight-skin-update-result', requestId, ...result },
|
||||||
|
parentOrigin,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep this geometry identical to the skin site's SkinRender.renderHead.
|
||||||
|
// In particular, the site does not infer texture dimensions before cropping.
|
||||||
|
// Rendering here keeps the source on the skin site's origin and only exposes
|
||||||
|
// the finished PNG to the launcher.
|
||||||
|
function renderPlayerHead(skinSource) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const image = new Image()
|
||||||
|
const timeout = setTimeout(() => reject(new Error('Skin rendering timed out.')), 8_000)
|
||||||
|
image.crossOrigin = 'anonymous'
|
||||||
|
image.onerror = () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
reject(new Error('Unable to load the skin texture.'))
|
||||||
|
}
|
||||||
|
image.onload = () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
try {
|
||||||
|
const buffer = document.createElement('canvas')
|
||||||
|
buffer.width = 18
|
||||||
|
buffer.height = 18
|
||||||
|
const context = buffer.getContext('2d')
|
||||||
|
if (!context) throw new Error('Unable to create the skin renderer.')
|
||||||
|
context.imageSmoothingEnabled = false
|
||||||
|
context.drawImage(image, 8, 8, 8, 8, 1, 1, 16, 16)
|
||||||
|
context.globalCompositeOperation = 'source-over'
|
||||||
|
context.drawImage(image, 40, 8, 8, 8, 0, 0, 18, 18)
|
||||||
|
|
||||||
|
const output = document.createElement('canvas')
|
||||||
|
output.width = 36
|
||||||
|
output.height = 36
|
||||||
|
const outputContext = output.getContext('2d')
|
||||||
|
if (!outputContext) throw new Error('Unable to create the skin renderer.')
|
||||||
|
outputContext.imageSmoothingEnabled = false
|
||||||
|
outputContext.drawImage(buffer, 0, 0, 18, 18, 0, 0, 36, 36)
|
||||||
|
resolve(output.toDataURL('image/png'))
|
||||||
|
} catch (error) {
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
image.src = skinSource
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializePlayerSkin(skinSource) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const image = new Image()
|
||||||
|
const timeout = setTimeout(() => reject(new Error('Skin loading timed out.')), 8_000)
|
||||||
|
image.crossOrigin = 'anonymous'
|
||||||
|
image.onerror = () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
reject(new Error('Unable to load the skin texture.'))
|
||||||
|
}
|
||||||
|
image.onload = () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
try {
|
||||||
|
const width = image.naturalWidth || image.width
|
||||||
|
const height = image.naturalHeight || image.height
|
||||||
|
if (!width || !height) throw new Error('The skin texture is empty.')
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
if (!context) throw new Error('Unable to create the skin renderer.')
|
||||||
|
context.imageSmoothingEnabled = false
|
||||||
|
context.drawImage(image, 0, 0)
|
||||||
|
const dataUrl = canvas.toDataURL('image/png')
|
||||||
|
if (dataUrl.length > 2_000_000) throw new Error('The skin texture is too large.')
|
||||||
|
resolve(dataUrl)
|
||||||
|
} catch (error) {
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
image.src = skinSource
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(delay) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, delay))
|
||||||
|
}
|
||||||
|
|
||||||
|
function pngDataUrlToBlob(dataUrl) {
|
||||||
|
if (
|
||||||
|
typeof dataUrl !== 'string' ||
|
||||||
|
dataUrl.length > 2_000_000 ||
|
||||||
|
!/^data:image\/png;base64,[a-z0-9+/]+={0,2}$/i.test(dataUrl)
|
||||||
|
)
|
||||||
|
throw new Error('Invalid skin texture.')
|
||||||
|
const bytes = atob(dataUrl.slice(dataUrl.indexOf(',') + 1))
|
||||||
|
const buffer = new Uint8Array(bytes.length)
|
||||||
|
for (let index = 0; index < bytes.length; index += 1) buffer[index] = bytes.charCodeAt(index)
|
||||||
|
return new Blob([buffer], { type: 'image/png' })
|
||||||
|
}
|
||||||
|
|
||||||
async function requestPlayers(requestId) {
|
async function requestPlayers(requestId) {
|
||||||
if (pendingPlayers.has(requestId)) return
|
if (pendingPlayers.has(requestId)) return
|
||||||
let token
|
let token
|
||||||
@ -57,7 +159,6 @@
|
|||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
pendingPlayers.set(requestId, controller)
|
pendingPlayers.set(requestId, controller)
|
||||||
const timeout = setTimeout(() => controller.abort(), 10_000)
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/starlight/skin/player', {
|
const response = await fetch('/starlight/skin/player', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
@ -84,7 +185,59 @@
|
|||||||
return [{ uuid: player.uuid, name: player.name, isMojang: player.isMojang === true }]
|
return [{ uuid: player.uuid, name: player.name, isMojang: player.isMojang === true }]
|
||||||
})
|
})
|
||||||
if (response.ok && players) {
|
if (response.ok && players) {
|
||||||
publishPlayers(requestId, { ok: true, players })
|
knownPlayers = new Map(players.map((player) => [player.uuid, player]))
|
||||||
|
// Match the skin site: stagger texture requests by 150 ms so the
|
||||||
|
// service is not hit with a burst that can drop individual players.
|
||||||
|
const skinResults = await Promise.all(
|
||||||
|
players.map(async (player, index) => {
|
||||||
|
if (index > 0) await sleep(index * 150)
|
||||||
|
try {
|
||||||
|
const skinResponse = await fetch(
|
||||||
|
`/starlight/skin/player/skin/${encodeURIComponent(player.uuid)}`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: controller.signal,
|
||||||
|
cache: 'no-store',
|
||||||
|
redirect: 'error',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const skinBody = await skinResponse.json().catch(() => null)
|
||||||
|
if (
|
||||||
|
!skinResponse.ok ||
|
||||||
|
!skinBody?.payload ||
|
||||||
|
typeof skinBody.payload !== 'object'
|
||||||
|
)
|
||||||
|
return { player, skinData: null }
|
||||||
|
return { player, skinData: skinBody.payload }
|
||||||
|
} catch {
|
||||||
|
return { player, skinData: null }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const playersWithSkins = await Promise.all(
|
||||||
|
skinResults.map(async ({ player, skinData }) => {
|
||||||
|
if (!skinData) return { ...player, skinState: 'error' }
|
||||||
|
try {
|
||||||
|
const skinSource = skinData.skin
|
||||||
|
if (typeof skinSource !== 'string' || !skinSource.trim()) {
|
||||||
|
return { ...player, skinState: 'empty' }
|
||||||
|
}
|
||||||
|
const headDataUrl = await renderPlayerHead(skinSource.trim())
|
||||||
|
const skinDataUrl = await serializePlayerSkin(skinSource.trim()).catch(
|
||||||
|
() => undefined,
|
||||||
|
)
|
||||||
|
const model = skinData.model === 'slim' ? 'slim' : 'default'
|
||||||
|
return { ...player, skinState: 'ready', headDataUrl, skinDataUrl, model }
|
||||||
|
} catch {
|
||||||
|
return { ...player, skinState: 'error' }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (localStorage.getItem('loginToken') !== token) {
|
||||||
|
publishPlayers(requestId, { ok: false, error: 'The skin site account changed.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
publishPlayers(requestId, { ok: true, players: playersWithSkins })
|
||||||
} else {
|
} else {
|
||||||
publishPlayers(requestId, {
|
publishPlayers(requestId, {
|
||||||
ok: false,
|
ok: false,
|
||||||
@ -97,11 +250,79 @@
|
|||||||
} catch {
|
} catch {
|
||||||
publishPlayers(requestId, { ok: false, error: 'Unable to reach the player service.' })
|
publishPlayers(requestId, { ok: false, error: 'Unable to reach the player service.' })
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout)
|
|
||||||
pendingPlayers.delete(requestId)
|
pendingPlayers.delete(requestId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updatePlayerSkin(requestId, playerId, textureDataUrl, model) {
|
||||||
|
if (pendingSkinUpdates.has(requestId)) return
|
||||||
|
const player = knownPlayers.get(playerId)
|
||||||
|
if (!player || player.isMojang) {
|
||||||
|
publishSkinUpdate(requestId, {
|
||||||
|
ok: false,
|
||||||
|
error: 'This skin site player cannot be changed.',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let token
|
||||||
|
try {
|
||||||
|
token = localStorage.getItem('loginToken') || ''
|
||||||
|
} catch {
|
||||||
|
publishSkinUpdate(requestId, { ok: false, error: 'Unable to read the skin site session.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
publish('signed-out')
|
||||||
|
publishSkinUpdate(requestId, { ok: false, error: 'Sign in to the skin site first.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
pendingSkinUpdates.set(requestId, controller)
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 18_000)
|
||||||
|
try {
|
||||||
|
const texture = pngDataUrlToBlob(textureDataUrl)
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', texture, `${playerId}.png`)
|
||||||
|
form.append('model', model)
|
||||||
|
const response = await fetch(
|
||||||
|
`/starlight/skin/player/skin/${encodeURIComponent(playerId)}/SKIN`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: form,
|
||||||
|
signal: controller.signal,
|
||||||
|
cache: 'no-store',
|
||||||
|
redirect: 'error',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const body = await response.json().catch(() => null)
|
||||||
|
if (localStorage.getItem('loginToken') !== token) {
|
||||||
|
publishSkinUpdate(requestId, { ok: false, error: 'The skin site account changed.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (response.status === 401 || response.status === 403) publish('signed-out')
|
||||||
|
if (response.ok && body?.success !== false) publishSkinUpdate(requestId, { ok: true })
|
||||||
|
else {
|
||||||
|
publishSkinUpdate(requestId, {
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
typeof body?.errorMessage === 'string' && body.errorMessage
|
||||||
|
? body.errorMessage.slice(0, 300)
|
||||||
|
: 'The skin site rejected the skin update.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
publishSkinUpdate(requestId, {
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unable to reach the skin service.',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
pendingSkinUpdates.delete(requestId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function requestLuck(requestId) {
|
async function requestLuck(requestId) {
|
||||||
if (pendingLuck.has(requestId)) return
|
if (pendingLuck.has(requestId)) return
|
||||||
let token
|
let token
|
||||||
@ -168,6 +389,7 @@
|
|||||||
pending?.abort()
|
pending?.abort()
|
||||||
pending = undefined
|
pending = undefined
|
||||||
lastToken = token
|
lastToken = token
|
||||||
|
if (changed) knownPlayers = new Map()
|
||||||
lastCheck = Date.now()
|
lastCheck = Date.now()
|
||||||
if (!token) {
|
if (!token) {
|
||||||
publish('signed-out')
|
publish('signed-out')
|
||||||
@ -231,6 +453,23 @@
|
|||||||
/^skin-players-\d+-\d+$/.test(event.data.requestId)
|
/^skin-players-\d+-\d+$/.test(event.data.requestId)
|
||||||
) {
|
) {
|
||||||
void requestPlayers(event.data.requestId)
|
void requestPlayers(event.data.requestId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
event.data?.type === 'starlight-skin-update-request' &&
|
||||||
|
parentOrigin === event.origin &&
|
||||||
|
typeof event.data.requestId === 'string' &&
|
||||||
|
/^skin-update-\d+-\d+$/.test(event.data.requestId) &&
|
||||||
|
typeof event.data.playerId === 'string' &&
|
||||||
|
/^[0-9a-f-]{32,36}$/i.test(event.data.playerId) &&
|
||||||
|
(event.data.model === 'default' || event.data.model === 'slim')
|
||||||
|
) {
|
||||||
|
void updatePlayerSkin(
|
||||||
|
event.data.requestId,
|
||||||
|
event.data.playerId,
|
||||||
|
event.data.textureDataUrl,
|
||||||
|
event.data.model,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// Storage events cover other tabs; polling also covers same-document SPA login/logout.
|
// Storage events cover other tabs; polling also covers same-document SPA login/logout.
|
||||||
@ -245,6 +484,8 @@
|
|||||||
pendingLuck.clear()
|
pendingLuck.clear()
|
||||||
for (const controller of pendingPlayers.values()) controller.abort()
|
for (const controller of pendingPlayers.values()) controller.abort()
|
||||||
pendingPlayers.clear()
|
pendingPlayers.clear()
|
||||||
|
for (const controller of pendingSkinUpdates.values()) controller.abort()
|
||||||
|
pendingSkinUpdates.clear()
|
||||||
})
|
})
|
||||||
window.addEventListener('pageshow', (event) => {
|
window.addEventListener('pageshow', (event) => {
|
||||||
if (!event.persisted) return
|
if (!event.persisted) return
|
||||||
|
|||||||
@ -4,11 +4,14 @@ const vm = require('node:vm')
|
|||||||
const test = require('node:test')
|
const test = require('node:test')
|
||||||
const source = fs.readFileSync(require('node:path').join(__dirname, 'skin_site_bridge.js'), 'utf8')
|
const source = fs.readFileSync(require('node:path').join(__dirname, 'skin_site_bridge.js'), 'utf8')
|
||||||
|
|
||||||
function harness(origin = 'https://skin.starlight.cool') {
|
function harness(origin = 'https://skin.starlight.cool', skinSize = 64) {
|
||||||
let token = null
|
let token = null
|
||||||
const listeners = {},
|
const listeners = {},
|
||||||
messages = [],
|
messages = [],
|
||||||
requests = []
|
requests = [],
|
||||||
|
drawCalls = []
|
||||||
|
const skinWidth = typeof skinSize === 'number' ? skinSize : skinSize.width
|
||||||
|
const skinHeight = typeof skinSize === 'number' ? skinSize : skinSize.height
|
||||||
let tick
|
let tick
|
||||||
let result = async () => ({
|
let result = async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
@ -38,7 +41,34 @@ function harness(origin = 'https://skin.starlight.cool') {
|
|||||||
return result(...args)
|
return result(...args)
|
||||||
},
|
},
|
||||||
AbortController,
|
AbortController,
|
||||||
|
Blob,
|
||||||
Date,
|
Date,
|
||||||
|
FormData,
|
||||||
|
Uint8Array,
|
||||||
|
atob,
|
||||||
|
document: {
|
||||||
|
createElement() {
|
||||||
|
return {
|
||||||
|
getContext() {
|
||||||
|
return {
|
||||||
|
drawImage(...args) {
|
||||||
|
drawCalls.push(args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toDataURL() {
|
||||||
|
return 'data:image/png;base64,SEVBRERBVEE='
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Image: class {
|
||||||
|
naturalWidth = skinWidth
|
||||||
|
naturalHeight = skinHeight
|
||||||
|
set src(_value) {
|
||||||
|
queueMicrotask(() => this.onload?.())
|
||||||
|
}
|
||||||
|
},
|
||||||
setTimeout,
|
setTimeout,
|
||||||
clearTimeout,
|
clearTimeout,
|
||||||
setInterval(callback) {
|
setInterval(callback) {
|
||||||
@ -49,6 +79,7 @@ function harness(origin = 'https://skin.starlight.cool') {
|
|||||||
}
|
}
|
||||||
vm.runInNewContext(source, context)
|
vm.runInNewContext(source, context)
|
||||||
return {
|
return {
|
||||||
|
drawCalls,
|
||||||
messages,
|
messages,
|
||||||
requests,
|
requests,
|
||||||
listeners,
|
listeners,
|
||||||
@ -71,6 +102,19 @@ function harness(origin = 'https://skin.starlight.cool') {
|
|||||||
listeners.message?.({ source, origin, data })
|
listeners.message?.({ source, origin, data })
|
||||||
await new Promise(setImmediate)
|
await new Promise(setImmediate)
|
||||||
},
|
},
|
||||||
|
async waitForMessage(type, requestId, timeout = 2_000) {
|
||||||
|
const deadline = Date.now() + timeout
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const message = messages.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.data?.type === type &&
|
||||||
|
(requestId === undefined || entry.data?.requestId === requestId),
|
||||||
|
)
|
||||||
|
if (message) return message
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for ${type}`)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,25 +230,130 @@ test('player requests return a sanitized complete player collection without expo
|
|||||||
const h = harness()
|
const h = harness()
|
||||||
h.token('test-token')
|
h.token('test-token')
|
||||||
await h.connect()
|
await h.connect()
|
||||||
h.result(async () => ({
|
h.result(async (url) => {
|
||||||
ok: true,
|
if (url.startsWith('/starlight/skin/player/skin/')) {
|
||||||
status: 200,
|
return {
|
||||||
json: async () => ({
|
ok: true,
|
||||||
payload: [
|
status: 200,
|
||||||
{ uuid: '0123456789abcdef0123456789abcdef', name: 'PlayerOne', isMojang: false },
|
json: async () => ({
|
||||||
{ uuid: 'fedcba9876543210fedcba9876543210', name: 'Official', isMojang: true },
|
payload: {
|
||||||
{ uuid: 'bad', name: 'Ignored', isMojang: false },
|
skin: url.endsWith('0123456789abcdef0123456789abcdef')
|
||||||
],
|
? '/textures/player-one.png'
|
||||||
}),
|
: null,
|
||||||
}))
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
payload: [
|
||||||
|
{ uuid: '0123456789abcdef0123456789abcdef', name: 'PlayerOne', isMojang: false },
|
||||||
|
{ uuid: 'fedcba9876543210fedcba9876543210', name: 'Official', isMojang: true },
|
||||||
|
{ uuid: 'bad', name: 'Ignored', isMojang: false },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
await h.message({ type: 'starlight-skin-players-request', requestId: 'skin-players-1-1' })
|
await h.message({ type: 'starlight-skin-players-request', requestId: 'skin-players-1-1' })
|
||||||
const request = h.requests.at(-1)
|
await h.waitForMessage('starlight-skin-players-result', 'skin-players-1-1')
|
||||||
|
const request = h.requests.find(([url]) => url === '/starlight/skin/player')
|
||||||
assert.equal(request[0], '/starlight/skin/player')
|
assert.equal(request[0], '/starlight/skin/player')
|
||||||
assert.equal(request[1].headers.Authorization, 'Bearer test-token')
|
assert.equal(request[1].headers.Authorization, 'Bearer test-token')
|
||||||
|
assert.equal(
|
||||||
|
h.requests.filter(([url]) => url.startsWith('/starlight/skin/player/skin/')).length,
|
||||||
|
2,
|
||||||
|
)
|
||||||
const message = h.messages.at(-1)
|
const message = h.messages.at(-1)
|
||||||
assert.equal(message.data.type, 'starlight-skin-players-result')
|
assert.equal(message.data.type, 'starlight-skin-players-result')
|
||||||
assert.equal(message.data.ok, true)
|
assert.equal(message.data.ok, true)
|
||||||
assert.equal(message.data.players.length, 2)
|
assert.equal(message.data.players.length, 2)
|
||||||
assert.equal(message.data.players[1].isMojang, true)
|
assert.equal(message.data.players[1].isMojang, true)
|
||||||
|
assert.equal(message.data.players[0].skinState, 'ready')
|
||||||
|
assert.equal(message.data.players[0].headDataUrl, 'data:image/png;base64,SEVBRERBVEE=')
|
||||||
|
assert.equal(message.data.players[0].skinDataUrl, 'data:image/png;base64,SEVBRERBVEE=')
|
||||||
|
assert.equal(message.data.players[0].model, 'default')
|
||||||
|
assert.equal(message.data.players[1].skinState, 'empty')
|
||||||
|
assert.equal(message.data.players[1].headDataUrl, undefined)
|
||||||
assert.ok(!JSON.stringify(h.messages).includes('test-token'))
|
assert.ok(!JSON.stringify(h.messages).includes('test-token'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('skin updates upload a PNG to the selected skin-site player without exposing the token', async () => {
|
||||||
|
const h = harness()
|
||||||
|
h.token('test-token')
|
||||||
|
await h.connect()
|
||||||
|
h.result(async (url) => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
payload:
|
||||||
|
url === '/starlight/skin/player'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
uuid: '0123456789abcdef0123456789abcdef',
|
||||||
|
name: 'PlayerOne',
|
||||||
|
isMojang: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: { skin: null },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
await h.message({ type: 'starlight-skin-players-request', requestId: 'skin-players-2-1' })
|
||||||
|
h.result(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ success: true, payload: 'updated' }),
|
||||||
|
}))
|
||||||
|
await h.message({
|
||||||
|
type: 'starlight-skin-update-request',
|
||||||
|
requestId: 'skin-update-1-1',
|
||||||
|
playerId: '0123456789abcdef0123456789abcdef',
|
||||||
|
textureDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
|
||||||
|
model: 'slim',
|
||||||
|
})
|
||||||
|
|
||||||
|
const request = h.requests.at(-1)
|
||||||
|
assert.equal(
|
||||||
|
request[0],
|
||||||
|
'/starlight/skin/player/skin/0123456789abcdef0123456789abcdef/SKIN',
|
||||||
|
)
|
||||||
|
assert.equal(request[1].method, 'PUT')
|
||||||
|
assert.equal(request[1].headers.Authorization, 'Bearer test-token')
|
||||||
|
assert.equal(request[1].body.get('model'), 'slim')
|
||||||
|
assert.equal(request[1].body.get('file').type, 'image/png')
|
||||||
|
assert.equal(h.messages.at(-1).data.type, 'starlight-skin-update-result')
|
||||||
|
assert.equal(h.messages.at(-1).data.ok, true)
|
||||||
|
assert.ok(!JSON.stringify(h.messages).includes('test-token'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('skin rendering uses the skin site crop and preserves the source texture dimensions', async () => {
|
||||||
|
const h = harness('https://skin.starlight.cool', { width: 64, height: 128 })
|
||||||
|
h.token('test-token')
|
||||||
|
await h.connect()
|
||||||
|
h.result(async (url) => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
payload:
|
||||||
|
url === '/starlight/skin/player'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
uuid: '0123456789abcdef0123456789abcdef',
|
||||||
|
name: 'HighResolutionPlayer',
|
||||||
|
isMojang: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: { skin: '/textures/hd.png', model: 'slim' },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
await h.message({ type: 'starlight-skin-players-request', requestId: 'skin-players-3-1' })
|
||||||
|
|
||||||
|
const player = h.messages.at(-1).data.players[0]
|
||||||
|
assert.equal(player.skinState, 'ready')
|
||||||
|
assert.equal(player.headDataUrl, 'data:image/png;base64,SEVBRERBVEE=')
|
||||||
|
assert.equal(player.skinDataUrl, 'data:image/png;base64,SEVBRERBVEE=')
|
||||||
|
assert.equal(player.model, 'slim')
|
||||||
|
assert.deepEqual(h.drawCalls[0].slice(1), [8, 8, 8, 8, 1, 1, 16, 16])
|
||||||
|
assert.deepEqual(h.drawCalls[1].slice(1), [40, 8, 8, 8, 0, 0, 18, 18])
|
||||||
|
})
|
||||||
|
|||||||
@ -551,6 +551,13 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
|
|||||||
Ok(available_skins)
|
Ok(available_skins)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gets the bundled skin catalogue without requiring a selected Minecraft
|
||||||
|
/// credential. This is used by external account providers that manage their
|
||||||
|
/// own player profiles while reusing the launcher's native skin picker.
|
||||||
|
pub fn get_default_skins() -> Vec<Skin> {
|
||||||
|
assets::DEFAULT_SKINS.iter().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Adds or updates a skin in the app database and equips it for the current profile.
|
/// Adds or updates a skin in the app database and equips it for the current profile.
|
||||||
/// Bundled default skins are only persisted when they have an associated cape.
|
/// Bundled default skins are only persisted when they have an associated cape.
|
||||||
#[tracing::instrument(skip(texture_blob))]
|
#[tracing::instrument(skip(texture_blob))]
|
||||||
|
|||||||
Reference in New Issue
Block a user