import { readonly, ref } from 'vue' import { clearSkinSiteToken, getSkinSiteToken, setSkinSiteToken, skinSiteCurrentUser, skinSiteLogin, skinSiteLuck, skinSitePlayerList, type SkinSitePlayerSkin, skinSitePlayerSkin, skinSiteRefresh, type SkinSiteResponse, skinSiteUploadTexture, type SkinSiteUser as ApiSkinSiteUser, } from '@/helpers/skin-site-api' import { fetchImageAsDataUrl, renderSkinHead } from '@/helpers/skin-site-render' 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 skinState: 'ready' | 'empty' | 'error' headDataUrl?: string skinDataUrl?: string model?: 'default' | 'slim' } export type SkinSitePlayersStatus = 'idle' | 'checking' | 'ready' | 'error' const user = ref(null) const status = ref('checking') const players = ref([]) const playersStatus = ref('idle') const selectedPlayerId = ref(null) let playersRequest: Promise | null = null 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) function toUser(raw: ApiSkinSiteUser): SkinSiteUser { return { uuid: raw.uuid, username: raw.username } } function clearSessionState() { user.value = null players.value = [] playersStatus.value = 'idle' selectedPlayerId.value = null } // --------------------------------------------------------------------------- // Session lifecycle // --------------------------------------------------------------------------- let initialized: Promise | null = null /** * Resolve the current session from the stored JWT. Safe to call repeatedly; * the first call performs the actual work and later ones reuse it. */ export function initSkinSiteSession(): Promise { if (initialized) return initialized initialized = (async () => { const token = getSkinSiteToken() if (!token) { status.value = 'signed-out' return } status.value = 'checking' const result = await skinSiteCurrentUser() if (result.success) { user.value = toUser(result.payload) status.value = 'signed-in' void requestSkinSitePlayers().catch(() => {}) } else if (result.errorCode === 401 || result.errorCode === 403) { clearSkinSiteToken() clearSessionState() status.value = 'signed-out' } else { status.value = 'error' } })() return initialized } /** Force a re-check (e.g. after the user signs in from another view). */ export function reloadSkinSiteSession(): Promise { initialized = null return initSkinSiteSession() } export async function signInSkinSite(payload: { username: string password: string clientId: string }): Promise> { const result = await skinSiteLogin(payload) if (result.success) { setSkinSiteToken(result.payload.jwt) const current = await skinSiteCurrentUser() if (current.success) { user.value = toUser(current.payload) status.value = 'signed-in' void requestSkinSitePlayers().catch(() => {}) } else { user.value = { uuid: '', username: result.payload.username } status.value = 'signed-in' } } return result } export function signOutSkinSite() { clearSkinSiteToken() clearSessionState() status.value = 'signed-out' initialized = null } /** Refresh the JWT; returns true when a new token was stored. */ export async function refreshSkinSiteSession(): Promise { if (status.value !== 'signed-in') return false const result = await skinSiteRefresh() if (result.success) { setSkinSiteToken(result.payload.jwt) return true } if (result.errorCode === 401 || result.errorCode === 403) signOutSkinSite() return false } // --------------------------------------------------------------------------- // Compatibility shims for the old iframe-based API // --------------------------------------------------------------------------- /** @deprecated The session is no longer driven by an iframe. No-op. */ export function setSkinSiteFrame(_frame: Window | null) {} /** @deprecated The session is no longer driven by an iframe. No-op. */ export function receiveSkinSiteMessage(_event: MessageEvent, _frame: Window | null): boolean { return false } /** @deprecated Use the in-app login view instead. No-op. */ export function openSkinSiteLogin() {} export function resetSkinSiteSession() { clearSessionState() status.value = getSkinSiteToken() ? 'checking' : 'signed-out' initialized = null } /** Resolves once the initial session check has settled. */ export async function waitForSkinSiteSession(): Promise { await initSkinSiteSession() } // --------------------------------------------------------------------------- // Download token (the launcher's hosted pack downloader) // --------------------------------------------------------------------------- /** * The hosted pack downloader authenticates with the skin site JWT. We hold it * locally now, so this simply surfaces it (refreshing once if it is missing). */ export async function requestSkinSiteDownloadToken(): Promise { if (status.value !== 'signed-in') { await initSkinSiteSession() } if (status.value !== 'signed-in') { throw new Error('请先在启动器中登录 StarLight 皮肤站,再下载整合包。') } let token = getSkinSiteToken() if (!token) { if (await refreshSkinSiteSession()) token = getSkinSiteToken() } if (!token) throw new Error('StarLight 登录凭据不可用,请重新登录。') return token } // --------------------------------------------------------------------------- // Players // --------------------------------------------------------------------------- async function buildPlayer(entry: { uuid: string name: string isMojang: boolean }): Promise { const base: SkinSitePlayer = { uuid: entry.uuid, name: entry.name, isMojang: entry.isMojang, skinState: 'empty', } const skinResult = await skinSitePlayerSkin(entry.uuid) if (!skinResult.success) return { ...base, skinState: 'error' } const texture: SkinSitePlayerSkin = skinResult.payload ?? {} if (!texture.skin) return base try { const [headDataUrl, skinDataUrl] = await Promise.all([ renderSkinHead(texture.skin), fetchImageAsDataUrl(texture.skin), ]) return { ...base, skinState: 'ready', headDataUrl, skinDataUrl, model: texture.model === 'slim' ? 'slim' : 'default', } } catch { return { ...base, skinState: 'error' } } } export function requestSkinSitePlayers(): Promise { if (playersRequest) return playersRequest if (status.value !== 'signed-in' || !user.value) { return Promise.reject(new Error('请先登录 StarLight 皮肤站。')) } playersStatus.value = 'checking' playersRequest = (async () => { try { const list = await skinSitePlayerList() if (!list.success) throw new Error(list.errorMessage) const built = await Promise.all(list.payload.map(buildPlayer)) players.value = built if (selectedPlayerId.value && !built.some((p) => p.uuid === selectedPlayerId.value)) { selectedPlayerId.value = null } playersStatus.value = 'ready' return players.value } catch (error) { playersStatus.value = 'error' throw error } finally { playersRequest = null } })() return playersRequest } export function selectSkinSitePlayer(playerId: string | null) { if (playerId === null) { selectedPlayerId.value = null return } if (players.value.some((player) => player.uuid === playerId)) selectedPlayerId.value = playerId } // --------------------------------------------------------------------------- // Luck // --------------------------------------------------------------------------- export async function requestSkinSiteLuck(): Promise { if (status.value !== 'signed-in') throw new Error('请先登录 StarLight 皮肤站。') const result = await skinSiteLuck() if (!result.success) throw new Error(result.errorMessage) return result.payload.luck } // --------------------------------------------------------------------------- // Legacy compatibility // --------------------------------------------------------------------------- /** * @deprecated The home view renders the skin site natively now. Kept only so * the transitional `SkinSiteFrame.vue` still compiles until it is replaced. */ export const skinSiteFrameUrl = readonly(ref(`${SKIN_SITE_ORIGIN}/`)) /** * Replace a player's skin texture. * * The caller hands us a base64 PNG (what the in-app editor produces); we turn * it back into a File and upload it through the skin site's texture endpoint. */ export async function requestSkinSiteSkinUpdate( playerId: string, textureDataUrl: string, model: 'default' | 'slim', ): Promise { if (status.value !== 'signed-in' || !user.value) { throw new Error('请先登录 StarLight 皮肤站。') } const player = players.value.find((candidate) => candidate.uuid === playerId) if (!player || player.isMojang) { throw new Error('该玩家无法修改皮肤。') } if (!/^data:image\/png;base64,[a-z0-9+/]+={0,2}$/i.test(textureDataUrl)) { throw new Error('皮肤图片无效或过大。') } const file = await dataUrlToFile(textureDataUrl, `${player.name}.png`) const result = await skinSiteUploadTexture(playerId, file, 'SKIN', { model }) if (!result.success) throw new Error(result.errorMessage) const refreshed = await skinSitePlayerSkin(playerId) if (refreshed.success && refreshed.payload?.skin) { const updated = await buildPlayer({ uuid: player.uuid, name: player.name, isMojang: player.isMojang, }) players.value = players.value.map((entry) => (entry.uuid === playerId ? updated : entry)) } } async function dataUrlToFile(dataUrl: string, filename: string): Promise { const response = await fetch(dataUrl) const blob = await response.blob() return new File([blob], filename, { type: 'image/png' }) }