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

This commit is contained in:
2026-09-14 23:06:03 +08:00
parent 2a1dfa3c42
commit 0b30fc96a0
17 changed files with 1063 additions and 138 deletions

View File

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

View File

@ -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('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
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',

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(
() => () => {
if (isSkinSiteProfile.value) {
return activeSkinSitePlayer.value?.isMojang === true || skinSiteStatus.value !== 'signed-in'
}
return (
currentAccountType.value === 'yggdrasil' || currentAccountType.value === 'yggdrasil' ||
(currentAccountType.value !== 'offline' && (currentAccountType.value !== 'offline' &&
(offline.value || (authServerQuery.isError.value && !authServerQuery.isLoading.value))), (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>

View File

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

View File

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

View File

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

View File

@ -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,7 +230,21 @@ 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) => {
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, ok: true,
status: 200, status: 200,
json: async () => ({ json: async () => ({
@ -196,15 +254,106 @@ test('player requests return a sanitized complete player collection without expo
{ uuid: 'bad', name: 'Ignored', isMojang: false }, { 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])
})

View File

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