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

View File

@ -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('&', '&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)}`
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',

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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