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

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