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 QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
||||
import SkinSiteSessionFrame from '@/components/ui/SkinSiteSessionFrame.vue'
|
||||
import WindowControls from '@/components/ui/WindowControls.vue'
|
||||
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
||||
import { useDropImport } from '@/composables/useDropImport'
|
||||
@ -2151,6 +2152,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
|
||||
<template>
|
||||
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
|
||||
<SkinSiteSessionFrame v-if="stateInitialized" />
|
||||
<div id="teleports"></div>
|
||||
<div
|
||||
v-if="stateInitialized && themeStore.customBackgroundPath && !themeStore.transparentBackground"
|
||||
|
||||
@ -35,24 +35,44 @@
|
||||
/>
|
||||
</div>
|
||||
<div v-if="skinSitePlayers.length > 0" class="border-0 border-t border-solid border-surface-5">
|
||||
<div
|
||||
<button
|
||||
v-for="player in skinSitePlayers"
|
||||
: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" />
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="truncate text-sm text-primary">{{ player.name }}</span>
|
||||
<span class="truncate text-xs text-secondary">{{ player.uuid }}</span>
|
||||
</div>
|
||||
<span class="shrink-0 text-xs text-secondary">
|
||||
{{
|
||||
launcherAccountIds.has(player.uuid)
|
||||
? formatMessage(messages.skinSitePlayerReady)
|
||||
: formatMessage(messages.skinSitePlayerSynced)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<Avatar
|
||||
v-if="player.skinState === 'ready' && player.headDataUrl"
|
||||
:src="player.headDataUrl"
|
||||
size="32px"
|
||||
pixelated
|
||||
:unframed-natural-width="36"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="h-8 w-8 shrink-0 rounded-sm border-2 border-dashed border-secondary/45 bg-surface-5/50 text-secondary"
|
||||
style="
|
||||
background-image:
|
||||
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>
|
||||
<p v-else-if="skinSitePlayersStatus === 'ready'" class="m-0 px-3 pb-3 text-sm text-secondary">
|
||||
{{ formatMessage(messages.noSkinSitePlayers) }}
|
||||
@ -250,11 +270,12 @@ import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import axolotlLogo from '@/assets/netherstar.png'
|
||||
import steveSkinTexture from '@/assets/skins/steve.png?inline'
|
||||
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
|
||||
import {
|
||||
openSkinSiteLogin,
|
||||
requestSkinSitePlayers,
|
||||
selectedSkinSitePlayerId,
|
||||
selectSkinSitePlayer,
|
||||
skinSitePlayers,
|
||||
skinSitePlayersStatus,
|
||||
skinSiteStatus,
|
||||
@ -347,25 +368,14 @@ let refreshGeneration = 0
|
||||
let headRefreshTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let defaultUserUpdateQueue = Promise.resolve()
|
||||
const minecraftLoginModal = ref<InstanceType<typeof MinecraftLoginModal> | null>(null)
|
||||
const launcherAccountIds = computed(() => new Set(accounts.value.map((account) => account.profile.id)))
|
||||
|
||||
async function refreshSkinSitePlayers() {
|
||||
await requestSkinSitePlayers().catch(() => {})
|
||||
}
|
||||
|
||||
function createSkinHeadDataUrl(textureUrl: string) {
|
||||
const escapedTextureUrl = textureUrl
|
||||
.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)}`
|
||||
function setSkinSitePlayer(playerId: string) {
|
||||
selectSkinSitePlayer(playerId)
|
||||
}
|
||||
|
||||
const defaultSteveHeadUrl = createSkinHeadDataUrl(steveSkinTexture)
|
||||
|
||||
const HEAD_REFRESH_RETRY_DELAYS = [1500, 5000, 15000, 30000] as const
|
||||
const HEAD_REFRESH_CONTINUOUS_DELAY = 60_000
|
||||
|
||||
@ -609,6 +619,7 @@ function persistDefaultUser(userId: string) {
|
||||
async function setAccount(account: MinecraftCredential) {
|
||||
const userId = account.profile.id
|
||||
refreshGeneration += 1
|
||||
selectSkinSitePlayer(null)
|
||||
defaultUser.value = userId
|
||||
equippedSkin.value = null
|
||||
|
||||
@ -618,6 +629,16 @@ async function setAccount(account: MinecraftCredential) {
|
||||
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() {
|
||||
if (offline.value) return
|
||||
loginDisabled.value = true
|
||||
@ -708,14 +729,6 @@ const messages = defineMessages({
|
||||
id: 'minecraft-account.skin-site.players.error',
|
||||
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: {
|
||||
id: 'minecraft-account.offline-mode',
|
||||
defaultMessage: 'Offline mode',
|
||||
|
||||
@ -1,14 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
receiveSkinSiteMessage,
|
||||
resetSkinSiteSession,
|
||||
setSkinSiteFrame,
|
||||
SKIN_SITE_ORIGIN,
|
||||
skinSiteFrameUrl,
|
||||
} from '@/composables/skin-site-session'
|
||||
import { skinSiteFrameUrl } from '@/composables/skin-site-session'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
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>
|
||||
|
||||
<template>
|
||||
<iframe
|
||||
ref="frame"
|
||||
:src="skinSiteFrameUrl"
|
||||
:title="formatMessage(messages.frameTitle)"
|
||||
class="block h-full min-h-0 w-full border-0"
|
||||
@load="connect"
|
||||
/>
|
||||
</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
|
||||
isAddSkinButtonDragActive: boolean
|
||||
readOnly?: boolean
|
||||
manageSavedSkins?: boolean
|
||||
activeTab?: 'saved' | 'default'
|
||||
}>()
|
||||
|
||||
@ -182,7 +183,9 @@ const sections = computed<SkinSection[]>(() => {
|
||||
|
||||
const draggableSavedSkins = ref<Skin[]>([])
|
||||
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 sectionLayouts = computed(() => {
|
||||
@ -486,7 +489,7 @@ defineExpose({ getAddSkinButtonElement })
|
||||
:is-dragging="isDraggingSavedSkin"
|
||||
@select="emit('select', skin)"
|
||||
>
|
||||
<template v-if="!readOnly" #overlay-buttons>
|
||||
<template v-if="!readOnly && manageSavedSkins !== false" #overlay-buttons>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:aria-label="formatMessage(messages.editSkinButton)"
|
||||
@ -526,7 +529,7 @@ defineExpose({ getAddSkinButtonElement })
|
||||
:is-dragging="isDraggingSavedSkin"
|
||||
@select="emit('select', skin)"
|
||||
>
|
||||
<template v-if="!readOnly" #overlay-buttons>
|
||||
<template v-if="!readOnly && manageSavedSkins !== false" #overlay-buttons>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:aria-label="formatMessage(messages.editSkinButton)"
|
||||
@ -568,7 +571,7 @@ defineExpose({ getAddSkinButtonElement })
|
||||
:is-dragging="isDraggingSavedSkin"
|
||||
@select="emit('select', skin)"
|
||||
>
|
||||
<template #overlay-buttons>
|
||||
<template v-if="manageSavedSkins !== false" #overlay-buttons>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:aria-label="formatMessage(messages.editSkinButton)"
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
receiveSkinSiteMessage,
|
||||
requestSkinSiteLuck,
|
||||
requestSkinSitePlayers,
|
||||
requestSkinSiteSkinUpdate,
|
||||
resetSkinSiteSession,
|
||||
setSkinSiteFrame,
|
||||
SKIN_SITE_ORIGIN,
|
||||
@ -150,7 +151,21 @@ test('player requests validate identities and replace the collection atomically'
|
||||
requestId: automaticRequest.requestId,
|
||||
ok: true,
|
||||
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 },
|
||||
],
|
||||
},
|
||||
@ -160,10 +175,50 @@ test('player requests validate identities and replace the collection atomically'
|
||||
true,
|
||||
)
|
||||
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')
|
||||
|
||||
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 retryRequest = sent.at(-1)?.data as { requestId: string }
|
||||
receiveSkinSiteMessage(
|
||||
@ -184,3 +239,75 @@ test('player requests validate identities and replace the collection atomically'
|
||||
setSkinSiteFrame(null)
|
||||
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 type SkinSiteUser = { uuid: string; username: string }
|
||||
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'
|
||||
|
||||
const user = ref<SkinSiteUser | null>(null)
|
||||
const status = ref<SkinSiteStatus>('signed-out')
|
||||
const players = ref<SkinSitePlayer[]>([])
|
||||
const playersStatus = ref<SkinSitePlayersStatus>('idle')
|
||||
const selectedPlayerId = ref<string | null>(null)
|
||||
const frameUrl = ref(`${SKIN_SITE_ORIGIN}/`)
|
||||
let connectedFrame: Window | null = null
|
||||
let luckRequestSequence = 0
|
||||
let playersRequestSequence = 0
|
||||
let skinUpdateRequestSequence = 0
|
||||
|
||||
type PendingLuckRequest = {
|
||||
resolve: (luck: number) => void
|
||||
@ -28,11 +38,36 @@ type PendingPlayersRequest = {
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
}
|
||||
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 skinSiteStatus = readonly(status)
|
||||
export const skinSitePlayers = readonly(players)
|
||||
export const skinSitePlayersStatus = readonly(playersStatus)
|
||||
export const selectedSkinSitePlayerId = readonly(selectedPlayerId)
|
||||
export const skinSiteFrameUrl = readonly(frameUrl)
|
||||
|
||||
function rejectPendingLuckRequests(message: string) {
|
||||
@ -51,13 +86,30 @@ function rejectPendingPlayersRequests(message: string) {
|
||||
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) {
|
||||
if (connectedFrame === frame) return
|
||||
rejectPendingLuckRequests('The skin site connection changed.')
|
||||
rejectPendingPlayersRequests('The skin site connection changed.')
|
||||
rejectPendingSkinUpdateRequests('The skin site connection changed.')
|
||||
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() {
|
||||
frameUrl.value = `${SKIN_SITE_ORIGIN}/login`
|
||||
}
|
||||
@ -65,10 +117,54 @@ export function openSkinSiteLogin() {
|
||||
export function resetSkinSiteSession() {
|
||||
rejectPendingLuckRequests('The skin site session ended.')
|
||||
rejectPendingPlayersRequests('The skin site session ended.')
|
||||
rejectPendingSkinUpdateRequests('The skin site session ended.')
|
||||
user.value = null
|
||||
status.value = 'signed-out'
|
||||
players.value = []
|
||||
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() {
|
||||
@ -83,7 +179,7 @@ export function requestSkinSitePlayers() {
|
||||
pendingPlayersRequests.delete(requestId)
|
||||
playersStatus.value = 'error'
|
||||
reject(new Error('The skin site player request timed out.'))
|
||||
}, 12_000)
|
||||
}, 30_000)
|
||||
pendingPlayersRequests.set(requestId, { resolve, reject, timeout })
|
||||
try {
|
||||
connectedFrame?.postMessage(
|
||||
@ -145,11 +241,24 @@ export function receiveSkinSiteMessage(event: MessageEvent, frame: Window | null
|
||||
typeof (player as SkinSitePlayer).name === 'string' &&
|
||||
(player as SkinSitePlayer).name.length > 0 &&
|
||||
(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'
|
||||
pending.resolve(nextPlayers)
|
||||
pending.resolve(players.value)
|
||||
} else {
|
||||
playersStatus.value = 'error'
|
||||
pending.reject(
|
||||
@ -189,6 +298,25 @@ export function receiveSkinSiteMessage(event: MessageEvent, frame: Window | null
|
||||
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 (!['checking', 'signed-out', 'signed-in', 'error'].includes(data.status)) return false
|
||||
let refreshPlayers = false
|
||||
@ -204,8 +332,10 @@ export function receiveSkinSiteMessage(event: MessageEvent, frame: Window | null
|
||||
return false
|
||||
if (user.value?.uuid && user.value.uuid !== data.user.uuid) {
|
||||
rejectPendingPlayersRequests('The skin site account changed.')
|
||||
rejectPendingSkinUpdateRequests('The skin site account changed.')
|
||||
players.value = []
|
||||
playersStatus.value = 'idle'
|
||||
selectedPlayerId.value = null
|
||||
}
|
||||
user.value = { uuid: data.user.uuid, username: data.user.username }
|
||||
if ([`${SKIN_SITE_ORIGIN}/`, `${SKIN_SITE_ORIGIN}/login`].includes(frameUrl.value)) {
|
||||
@ -215,9 +345,11 @@ export function receiveSkinSiteMessage(event: MessageEvent, frame: Window | null
|
||||
} else {
|
||||
rejectPendingLuckRequests('The skin site session is unavailable.')
|
||||
rejectPendingPlayersRequests('The skin site session is unavailable.')
|
||||
rejectPendingSkinUpdateRequests('The skin site session is unavailable.')
|
||||
user.value = null
|
||||
players.value = []
|
||||
playersStatus.value = 'idle'
|
||||
selectedPlayerId.value = null
|
||||
}
|
||||
status.value = data.status
|
||||
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', {})
|
||||
}
|
||||
|
||||
export async function get_default_skins(): Promise<Skin[]> {
|
||||
return invoke('plugin:minecraft-skins|get_default_skins', {})
|
||||
}
|
||||
|
||||
export async function add_and_equip_custom_skin(
|
||||
textureBlob: Uint8Array,
|
||||
variant: SkinModel,
|
||||
|
||||
@ -48,8 +48,6 @@
|
||||
"minecraft-account.skin-site.players.error": {
|
||||
"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": {
|
||||
"message": "Starlight Mine: Chromatic Realms"
|
||||
},
|
||||
@ -7796,6 +7794,27 @@
|
||||
"app.skins.section.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": {
|
||||
"message": "Starlight Launcher"
|
||||
},
|
||||
|
||||
@ -44,8 +44,6 @@
|
||||
"minecraft-account.skin-site.players.error": {
|
||||
"message": "暂时无法刷新玩家列表,已保留上一次结果。"
|
||||
},
|
||||
"minecraft-account.skin-site.player.ready": { "message": "可启动" },
|
||||
"minecraft-account.skin-site.player.synced": { "message": "来自皮肤站" },
|
||||
"app.easteregg.color-mine.title": {
|
||||
"message": "星光矿井:彩域"
|
||||
},
|
||||
@ -7867,6 +7865,27 @@
|
||||
"app.skins.section.tiny-takeover": {
|
||||
"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": {
|
||||
"message": "Starlight Launcher"
|
||||
},
|
||||
|
||||
@ -44,8 +44,6 @@
|
||||
"minecraft-account.skin-site.players.error": {
|
||||
"message": "暫時無法重新整理玩家清單,已保留上一次結果。"
|
||||
},
|
||||
"minecraft-account.skin-site.player.ready": { "message": "可啟動" },
|
||||
"minecraft-account.skin-site.player.synced": { "message": "來自皮膚站" },
|
||||
"app.easteregg.color-mine.title": {
|
||||
"message": "星光礦井:彩域"
|
||||
},
|
||||
@ -7558,6 +7556,27 @@
|
||||
"app.skins.section.tiny-takeover": {
|
||||
"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": {
|
||||
"message": "Starlight Launcher"
|
||||
},
|
||||
|
||||
@ -31,12 +31,23 @@ import { onBeforeRouteLeave, useRouter } from 'vue-router'
|
||||
import type AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||
import EditSkinModal from '@/components/ui/skin/EditSkinModal.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 { check_reachable, get_default_user, users } from '@/helpers/auth'
|
||||
import type { RenderResult } 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 {
|
||||
determineModelType,
|
||||
equip_skin,
|
||||
filterDefaultSkins,
|
||||
filterSavedSkins,
|
||||
@ -44,6 +55,7 @@ import {
|
||||
flush_pending_skin_change_for_profile,
|
||||
get_available_capes,
|
||||
get_available_skins,
|
||||
get_default_skins,
|
||||
get_normalized_skin_texture,
|
||||
normalize_skin_texture,
|
||||
remove_custom_skin,
|
||||
@ -179,6 +191,22 @@ const messages = defineMessages({
|
||||
id: 'app.skins.sign-in.button',
|
||||
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: {
|
||||
id: 'app.skins.offline-account.compatibility',
|
||||
defaultMessage:
|
||||
@ -197,6 +225,20 @@ const messages = defineMessages({
|
||||
defaultMessage:
|
||||
'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: {
|
||||
id: 'app.skins.tabs.saved',
|
||||
defaultMessage: 'Saved skins',
|
||||
@ -228,7 +270,16 @@ const currentUser = ref(undefined)
|
||||
const currentUserId = ref<string | 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 isApplyingSkin = ref(false)
|
||||
|
||||
@ -314,10 +365,16 @@ const capeTexture = computed(() => currentCape.value?.texture)
|
||||
const skinVariant = computed(() => selectedSkin.value?.variant)
|
||||
const skinNametag = computed(() => (themeStore.hideNametagSkinsPage ? undefined : username.value))
|
||||
const isSkinManagementReadOnly = computed(
|
||||
() =>
|
||||
currentAccountType.value === 'yggdrasil' ||
|
||||
(currentAccountType.value !== 'offline' &&
|
||||
(offline.value || (authServerQuery.isError.value && !authServerQuery.isLoading.value))),
|
||||
() => {
|
||||
if (isSkinSiteProfile.value) {
|
||||
return activeSkinSitePlayer.value?.isMojang === true || skinSiteStatus.value !== 'signed-in'
|
||||
}
|
||||
return (
|
||||
currentAccountType.value === 'yggdrasil' ||
|
||||
(currentAccountType.value !== 'offline' &&
|
||||
(offline.value || (authServerQuery.isError.value && !authServerQuery.isLoading.value)))
|
||||
)
|
||||
},
|
||||
)
|
||||
const hasPendingSkinChange = computed(
|
||||
() => !skinsMatch(selectedSkin.value, originalSelectedSkin.value),
|
||||
@ -356,7 +413,7 @@ function confirmDeleteSkin(skin: Skin) {
|
||||
}
|
||||
|
||||
async function deleteSkin() {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
if (isSkinManagementReadOnly.value || isSkinSiteProfile.value) return
|
||||
|
||||
const deletedSkin = skinToDelete.value
|
||||
if (!deletedSkin) return
|
||||
@ -372,10 +429,17 @@ async function deleteSkin() {
|
||||
}
|
||||
|
||||
async function loadCapes() {
|
||||
if (isSkinSiteProfile.value) {
|
||||
capes.value = []
|
||||
return
|
||||
}
|
||||
const profileId = currentUserId.value
|
||||
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) {
|
||||
if (currentUser.value && error instanceof Error) {
|
||||
if (hasCurrentProfile.value && error instanceof Error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
@ -383,7 +447,36 @@ async function loadCapes() {
|
||||
|
||||
async function loadSkins() {
|
||||
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()) ?? []
|
||||
if (isSkinSiteProfile.value || currentUserId.value !== profileId) return
|
||||
const loadedEquippedSkin = loadedSkins.find((s) => s.is_equipped)
|
||||
const locallyKnownEquippedSkin =
|
||||
originalSelectedSkin.value &&
|
||||
@ -404,7 +497,7 @@ async function loadSkins() {
|
||||
selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null
|
||||
originalSelectedSkin.value = selectedSkin.value
|
||||
} catch (error) {
|
||||
if (currentUser.value && error instanceof Error) {
|
||||
if (hasCurrentProfile.value && error instanceof Error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
@ -612,6 +705,7 @@ function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin)
|
||||
}
|
||||
|
||||
async function reorderSavedSkins(orderedSkins: Skin[]) {
|
||||
if (isSkinSiteProfile.value) return
|
||||
const previousSkins = skins.value
|
||||
const previousSelectedSkin = selectedSkin.value
|
||||
const previousOriginalSelectedSkin = originalSelectedSkin.value
|
||||
@ -733,6 +827,19 @@ async function applySelectedSkin() {
|
||||
|
||||
isApplyingSkin.value = true
|
||||
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)
|
||||
setLocallyEquippedSkin(skinToApply)
|
||||
schedulePendingSkinRefresh()
|
||||
@ -776,7 +883,7 @@ async function loadCurrentUser() {
|
||||
currentAccountType.value = selectedAccount?.account_type
|
||||
currentUser.value = selectedAccount
|
||||
} catch (e) {
|
||||
handleError(e as Error)
|
||||
if (!isSkinSiteProfile.value) handleError(e as Error)
|
||||
currentUser.value = undefined
|
||||
currentUserId.value = undefined
|
||||
currentAccountType.value = undefined
|
||||
@ -794,14 +901,22 @@ watch(accountChangeRevision, (revision, previousRevision) => {
|
||||
void refreshSelectedAccount()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [selectedSkinSitePlayerId.value, activeSkinSitePlayer.value?.skinDataUrl] as const,
|
||||
() => {
|
||||
void loadCapes()
|
||||
void loadSkins()
|
||||
},
|
||||
)
|
||||
|
||||
function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
|
||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||
return skinBlobUrlMap.get(key)
|
||||
}
|
||||
|
||||
async function login() {
|
||||
if (offline.value) return
|
||||
accountsCard.value?.login()
|
||||
async function loginToSkinSite() {
|
||||
openSkinSiteLogin()
|
||||
await router.push('/')
|
||||
}
|
||||
|
||||
function openAddSkinFileBrowser() {
|
||||
@ -877,7 +992,6 @@ async function onAddSkinDrop(event: DragEvent) {
|
||||
async function processSkinFileBuffer(buffer: Uint8Array | ArrayBuffer) {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
|
||||
const fakeEvent = new MouseEvent('click')
|
||||
const originalSkinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(buffer)
|
||||
try {
|
||||
const skinTextureNormalized = await normalize_skin_texture(originalSkinTexUrl)
|
||||
@ -885,6 +999,24 @@ async function processSkinFileBuffer(buffer: Uint8Array | ArrayBuffer) {
|
||||
original: originalSkinTexUrl,
|
||||
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)
|
||||
} catch (error) {
|
||||
handleError(error as Error)
|
||||
@ -984,6 +1116,7 @@ onUnmounted(() => {
|
||||
})
|
||||
|
||||
async function checkUserChanges() {
|
||||
if (isSkinSiteProfile.value) return
|
||||
try {
|
||||
const defaultId = await get_default_user(offline.value)
|
||||
if (defaultId !== currentUserId.value) {
|
||||
@ -1047,9 +1180,26 @@ await loadSkins()
|
||||
</p>
|
||||
</section>
|
||||
</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
|
||||
v-if="currentUser"
|
||||
v-if="hasCurrentProfile"
|
||||
data-onboarding-id="skins-page"
|
||||
class="skin-layout box-border min-h-full p-4"
|
||||
>
|
||||
@ -1100,7 +1250,7 @@ await loadSkins()
|
||||
</button>
|
||||
</div>
|
||||
<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"
|
||||
:disabled="!selectedSkin || isSkinManagementReadOnly"
|
||||
@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')">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.createSkinButton) }}
|
||||
@ -1142,8 +1292,9 @@ await loadSkins()
|
||||
:is-skin-active="isSkinActive"
|
||||
:is-add-skin-button-drag-active="isAddSkinButtonDragActive"
|
||||
:read-only="isSkinManagementReadOnly"
|
||||
:manage-saved-skins="!isSkinSiteProfile"
|
||||
@select="changeSkin"
|
||||
@edit="(skin, event) => editSkinModal?.show(event, skin)"
|
||||
@edit="(skin, event) => !isSkinSiteProfile && editSkinModal?.show(event, skin)"
|
||||
@delete="confirmDeleteSkin"
|
||||
@reorder-saved-skins="reorderSavedSkins"
|
||||
@add-skin="openAddSkinFileBrowser"
|
||||
@ -1183,20 +1334,37 @@ await loadSkins()
|
||||
|
||||
<div class="flex flex-col gap-5">
|
||||
<h1 class="text-3xl font-extrabold m-0">
|
||||
{{ formatMessage(messages.signInTitle) }}
|
||||
{{
|
||||
formatMessage(
|
||||
skinSiteUser
|
||||
? skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle'
|
||||
? messages.loadingSkinSitePlayersTitle
|
||||
: messages.noSkinSitePlayersTitle
|
||||
: messages.signInTitle,
|
||||
)
|
||||
}}
|
||||
</h1>
|
||||
<p class="text-lg m-0">
|
||||
{{ formatMessage(messages.signInDescription) }}
|
||||
{{
|
||||
formatMessage(
|
||||
skinSiteUser
|
||||
? skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle'
|
||||
? messages.loadingSkinSitePlayersDescription
|
||||
: messages.noSkinSitePlayersDescription
|
||||
: messages.signInDescription,
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<ButtonStyled
|
||||
v-if="!offline"
|
||||
v-show="accountsCard"
|
||||
color="brand"
|
||||
:disabled="accountsCard.loginDisabled"
|
||||
>
|
||||
<button :disabled="accountsCard.loginDisabled" @click="login">
|
||||
<LogInIcon v-if="!accountsCard.loginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
<SpinnerIcon
|
||||
v-if="
|
||||
skinSiteUser &&
|
||||
(skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle')
|
||||
"
|
||||
class="h-8 w-8 animate-spin text-brand"
|
||||
/>
|
||||
<ButtonStyled v-if="!offline && !skinSiteUser" color="brand">
|
||||
<button @click="loginToSkinSite">
|
||||
<LogInIcon />
|
||||
{{ formatMessage(messages.signInButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
@ -320,6 +320,7 @@ fn main() {
|
||||
.commands(&[
|
||||
"get_available_capes",
|
||||
"get_available_skins",
|
||||
"get_default_skins",
|
||||
"add_and_equip_custom_skin",
|
||||
"equip_skin",
|
||||
"remove_custom_skin",
|
||||
|
||||
@ -10,6 +10,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_available_capes,
|
||||
get_available_skins,
|
||||
get_default_skins,
|
||||
add_and_equip_custom_skin,
|
||||
equip_skin,
|
||||
remove_custom_skin,
|
||||
@ -40,6 +41,12 @@ pub async fn get_available_skins() -> Result<Vec<Skin>> {
|
||||
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)`
|
||||
///
|
||||
/// See also: [minecraft_skins::add_and_equip_custom_skin]
|
||||
|
||||
@ -15,6 +15,8 @@
|
||||
let pending
|
||||
const pendingLuck = new Map()
|
||||
const pendingPlayers = new Map()
|
||||
const pendingSkinUpdates = new Map()
|
||||
let knownPlayers = new Map()
|
||||
let snapshot = { status: 'checking', 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) {
|
||||
if (pendingPlayers.has(requestId)) return
|
||||
let token
|
||||
@ -57,7 +159,6 @@
|
||||
|
||||
const controller = new AbortController()
|
||||
pendingPlayers.set(requestId, controller)
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000)
|
||||
try {
|
||||
const response = await fetch('/starlight/skin/player', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@ -84,7 +185,59 @@
|
||||
return [{ uuid: player.uuid, name: player.name, isMojang: player.isMojang === true }]
|
||||
})
|
||||
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 {
|
||||
publishPlayers(requestId, {
|
||||
ok: false,
|
||||
@ -97,11 +250,79 @@
|
||||
} catch {
|
||||
publishPlayers(requestId, { ok: false, error: 'Unable to reach the player service.' })
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
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) {
|
||||
if (pendingLuck.has(requestId)) return
|
||||
let token
|
||||
@ -168,6 +389,7 @@
|
||||
pending?.abort()
|
||||
pending = undefined
|
||||
lastToken = token
|
||||
if (changed) knownPlayers = new Map()
|
||||
lastCheck = Date.now()
|
||||
if (!token) {
|
||||
publish('signed-out')
|
||||
@ -231,6 +453,23 @@
|
||||
/^skin-players-\d+-\d+$/.test(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.
|
||||
@ -245,6 +484,8 @@
|
||||
pendingLuck.clear()
|
||||
for (const controller of pendingPlayers.values()) controller.abort()
|
||||
pendingPlayers.clear()
|
||||
for (const controller of pendingSkinUpdates.values()) controller.abort()
|
||||
pendingSkinUpdates.clear()
|
||||
})
|
||||
window.addEventListener('pageshow', (event) => {
|
||||
if (!event.persisted) return
|
||||
|
||||
@ -4,11 +4,14 @@ const vm = require('node:vm')
|
||||
const test = require('node:test')
|
||||
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
|
||||
const listeners = {},
|
||||
messages = [],
|
||||
requests = []
|
||||
requests = [],
|
||||
drawCalls = []
|
||||
const skinWidth = typeof skinSize === 'number' ? skinSize : skinSize.width
|
||||
const skinHeight = typeof skinSize === 'number' ? skinSize : skinSize.height
|
||||
let tick
|
||||
let result = async () => ({
|
||||
ok: true,
|
||||
@ -38,7 +41,34 @@ function harness(origin = 'https://skin.starlight.cool') {
|
||||
return result(...args)
|
||||
},
|
||||
AbortController,
|
||||
Blob,
|
||||
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,
|
||||
clearTimeout,
|
||||
setInterval(callback) {
|
||||
@ -49,6 +79,7 @@ function harness(origin = 'https://skin.starlight.cool') {
|
||||
}
|
||||
vm.runInNewContext(source, context)
|
||||
return {
|
||||
drawCalls,
|
||||
messages,
|
||||
requests,
|
||||
listeners,
|
||||
@ -71,6 +102,19 @@ function harness(origin = 'https://skin.starlight.cool') {
|
||||
listeners.message?.({ source, origin, data })
|
||||
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()
|
||||
h.token('test-token')
|
||||
await h.connect()
|
||||
h.result(async () => ({
|
||||
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 },
|
||||
],
|
||||
}),
|
||||
}))
|
||||
h.result(async (url) => {
|
||||
if (url.startsWith('/starlight/skin/player/skin/')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
payload: {
|
||||
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' })
|
||||
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[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)
|
||||
assert.equal(message.data.type, 'starlight-skin-players-result')
|
||||
assert.equal(message.data.ok, true)
|
||||
assert.equal(message.data.players.length, 2)
|
||||
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'))
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Bundled default skins are only persisted when they have an associated cape.
|
||||
#[tracing::instrument(skip(texture_blob))]
|
||||
|
||||
Reference in New Issue
Block a user