feat: 皮肤站会话改为 API 驱动,不再依赖内嵌网页
Sync source to CNB / Sync Git ref (push) Skipped

主页原先通过隐藏 iframe + postMessage 与皮肤站网页通信,登录态、
玩家列表、运势等数据都要绕一圈从网页里取。改为启动器直接调用皮肤站
HTTP 接口:

- 新增 helpers/skin-site-api.ts:请求封装(tauriFetch 绕开 CORS)、
  JWT 本地存取、认证与玩家/皮肤接口。
- 新增 helpers/skin-site-render.ts:从皮肤图裁出头像,以及把图片转
  成 data URL。
- 重写 composables/skin-site-session.ts:登录态由本地 JWT 决定,
  玩家列表改为请求接口后逐个拉取皮肤并渲染头像;运势、皮肤上传同样
  走接口。旧的 iframe 相关导出保留为兼容空实现。
- 删除仅测试 postMessage 协议的 skin-site-session.test.ts。

下载凭据仍然返回皮肤站 JWT:Rust 侧 hosted_set_session 期望的就是它,
instance-player 与远程公告的鉴权方式保持不变。
This commit is contained in:
AxTps committed 2026-10-17 18:35:58 +08:00
1 parent 871b0ee001
commit 0edf96fc2c
4 files changed
+686 -1090

No files matched your search

@@ -1,714 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
clearHostedSession,
HOSTED_ACCOUNT_REQUIRED_MESSAGE,
hostedCreate,
hostedDefault,
hostedSync,
onHostedPackAttemptStarted,
setInstanceMode,
} from '../helpers/hosted-packs.ts'
import {
openSkinSiteLogin,
receiveSkinSiteMessage,
requestSkinSiteDownloadToken,
requestSkinSiteLuck,
requestSkinSitePlayers,
requestSkinSiteSkinUpdate,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
skinSiteFrameUrl,
skinSitePlayers,
skinSitePlayersStatus,
skinSiteStatus,
skinSiteUser,
} from './skin-site-session.ts'
import { forgetHostedCreation, useHostedCreation } from './useHostedCreation.ts'
import { useHostedSync } from './useHostedSync.ts'
test('reconnecting the same skin site frame waits for verification instead of reporting signed out', () => {
const frame = { postMessage() {} } as unknown as Window
resetSkinSiteSession()
setSkinSiteFrame(frame)
resetSkinSiteSession()
setSkinSiteFrame(frame)
assert.equal(skinSiteStatus.value, 'checking')
setSkinSiteFrame(null)
resetSkinSiteSession()
})
test('hosted installation sends the JWT to native commands while Local needs no session', async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
const calls: Array<{ command: string; args: Record<string, unknown> }> = []
let syncResponse: (() => Promise<unknown>) | undefined
let createdId = 'instance'
let instanceExists = false
let tokenRequests = 0
let accounts: Array<{
account_type: string
yggdrasil?: { api_root: string }
}> = [{ account_type: 'microsoft' }]
const frame = {
postMessage(data: { type: string; requestId: string }) {
if (data.type !== 'starlight-pack-token-request') return
tokenRequests++
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-pack-token-result',
requestId: data.requestId,
token: 'site.jwt.secret',
},
} as MessageEvent,
frame,
)
},
} as unknown as Window
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
__TAURI_INTERNALS__: {
async invoke(command: string, args: Record<string, unknown>) {
calls.push({ command, args })
if (command === 'plugin:install|hosted_has_game_account')
return accounts.some(
(account) =>
account.account_type === 'microsoft' ||
(account.account_type === 'yggdrasil' &&
account.yggdrasil?.api_root.replace(/\/+$/, '') ===
'https://skin.starlight.cool/yggdrasil'),
)
if (command === 'plugin:install|hosted_sync' && syncResponse) return syncResponse()
if (command === 'plugin:instance|instance_get')
return instanceExists ? { id: args.instanceId } : null
return command === 'plugin:install|hosted_create' ? createdId : null
},
},
},
})
try {
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'site', username: 'Site' },
},
} as MessageEvent,
frame,
)
await hostedDefault()
assert.equal(await hostedCreate(), 'instance')
await hostedSync('instance')
await setInstanceMode('instance', 'starlight')
assert.equal(tokenRequests, 4)
assert.deepEqual(
calls
.map(({ command }) => command)
.filter(
(command) =>
command.startsWith('plugin:install|') &&
command !== 'plugin:install|hosted_has_game_account',
),
[
'hosted_set_session',
'hosted_default',
'hosted_set_session',
'hosted_create',
'hosted_set_session',
'hosted_sync',
'hosted_set_session',
'hosted_set_instance_mode',
].map((name) => `plugin:install|${name}`),
)
for (const call of calls.filter(({ command }) => command.endsWith('hosted_set_session'))) {
assert.equal(call.args.token, 'site.jwt.secret')
}
let rejectSync!: (error: unknown) => void
let syncCalls = 0
syncResponse = () => {
syncCalls++
return new Promise((_, reject) => {
rejectSync = reject
})
}
const originalPage = useHostedSync(() => 'shared-progress-instance')
const pendingSync = originalPage.sync()
const reopenedPage = useHostedSync(() => 'shared-progress-instance')
assert.equal(reopenedPage.busy.value, true)
assert.equal(useHostedSync(() => 'other-instance').busy.value, false)
await reopenedPage.sync()
await new Promise((resolve) => setImmediate(resolve))
assert.equal(syncCalls, 1)
rejectSync({ message: 'download interrupted' })
await pendingSync
assert.equal(reopenedPage.busy.value, false)
assert.match(reopenedPage.error.value, /download interrupted/)
assert.equal(
useHostedSync(() => 'shared-progress-instance').error.value,
reopenedPage.error.value,
)
syncResponse = async () => ({ revision: 'new-version' })
await reopenedPage.sync()
assert.equal(originalPage.error.value, '')
assert.deepEqual(originalPage.result.value, { revision: 'new-version' })
const creation = useHostedCreation()
createdId = 'deleted-instance'
syncResponse = async () => {
throw new Error('download failed')
}
await creation.install()
assert.equal(creation.createdInstance.value, 'deleted-instance')
assert.match(creation.installError.value, /download failed/)
createdId = 'replacement-instance'
syncResponse = async () => ({
version: '1',
downloadedBytes: 0,
changedFiles: 0,
preservedFiles: [],
})
const retryCallStart = calls.length
assert.equal(await creation.install(), 'replacement-instance')
assert.equal(calls[retryCallStart].command, 'plugin:instance|instance_get')
assert.equal(
calls.filter((call) => call.command.endsWith('|hosted_sync')).at(-1)?.args.instanceId,
'replacement-instance',
)
assert.equal(creation.installError.value, '')
forgetHostedCreation('unrelated-instance')
assert.equal(creation.completed.value, true)
forgetHostedCreation('replacement-instance')
assert.equal(creation.createdInstance.value, undefined)
assert.equal(creation.completed.value, false)
createdId = 'third-instance'
syncResponse = () =>
new Promise((_, reject) => {
rejectSync = reject
})
const deletedDuringSync = creation.install()
await new Promise((resolve) => setImmediate(resolve))
forgetHostedCreation('third-instance')
rejectSync(new Error('Unknown instance'))
await deletedDuringSync
assert.equal(creation.installError.value, '')
assert.equal(creation.createdInstance.value, undefined)
createdId = 'existing-instance'
syncResponse = async () => {
throw new Error('download interrupted')
}
await creation.install()
instanceExists = true
syncResponse = async () => ({
version: '1',
downloadedBytes: 0,
changedFiles: 0,
preservedFiles: [],
})
const createCount = calls.filter((call) => call.command.endsWith('|hosted_create')).length
assert.equal(await creation.install(), 'existing-instance')
assert.equal(
calls.filter((call) => call.command.endsWith('|hosted_create')).length,
createCount,
)
creation.acknowledge('existing-instance')
const requestsBeforeLogout = tokenRequests
resetSkinSiteSession()
await clearHostedSession()
assert.equal(calls.at(-1)!.args.token, null)
await setInstanceMode('instance', 'local')
assert.equal(tokenRequests, requestsBeforeLogout)
const attempts: string[] = []
const stopListening = onHostedPackAttemptStarted((id) => attempts.push(id))
accounts = []
const installCallsBeforeAccountFailure = calls.filter(
({ command }) =>
command.startsWith('plugin:install|') &&
command !== 'plugin:install|hosted_has_game_account',
).length
await assert.rejects(hostedSync('failed-instance'), {
message: HOSTED_ACCOUNT_REQUIRED_MESSAGE,
})
await assert.rejects(hostedCreate(), {
message: HOSTED_ACCOUNT_REQUIRED_MESSAGE,
})
assert.deepEqual(attempts, [])
assert.equal(
calls.filter(
({ command }) =>
command.startsWith('plugin:install|') &&
command !== 'plugin:install|hosted_has_game_account',
).length,
installCallsBeforeAccountFailure,
)
const unauthenticatedCreation = useHostedCreation()
await unauthenticatedCreation.install()
assert.equal(unauthenticatedCreation.installError.value, HOSTED_ACCOUNT_REQUIRED_MESSAGE)
assert.equal(unauthenticatedCreation.createdInstance.value, undefined)
accounts = [{ account_type: 'microsoft' }]
const unauthenticatedRetry = hostedSync('failed-instance')
await assert.rejects(unauthenticatedRetry)
assert.deepEqual(attempts, ['failed-instance'])
stopListening()
await assert.rejects(hostedSync('failed-instance'))
assert.equal(attempts.length, 1)
await assert.rejects(hostedCreate(), /无需选择玩家/)
} finally {
resetSkinSiteSession()
setSkinSiteFrame(null)
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
else Reflect.deleteProperty(globalThis, 'window')
}
})
test('pack download gets the site JWT without choosing a player and rejects stale replies', async () => {
const sent: Array<{ type: string; requestId: string }> = []
const frame = {
postMessage(data: { type: string; requestId: string }) {
sent.push(data)
},
} as unknown as Window
const receive = (data: unknown, origin = SKIN_SITE_ORIGIN) =>
receiveSkinSiteMessage(
{
origin,
source: frame,
data,
} as MessageEvent,
frame,
)
resetSkinSiteSession()
setSkinSiteFrame(frame)
await assert.rejects(requestSkinSiteDownloadToken(), /无需选择玩家/)
receive({
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'site-user', username: 'Site' },
})
const token = requestSkinSiteDownloadToken()
const requestId = sent.at(-1)!.requestId
const reply = { type: 'starlight-pack-token-result', requestId, token: 'site.jwt.secret' }
assert.equal(receive(reply, 'https://evil.example'), false)
assert.equal(receive(reply), true)
assert.equal(await token, 'site.jwt.secret')
assert.equal(skinSitePlayers.value.length, 0)
assert.equal(JSON.stringify(skinSiteUser.value).includes('secret'), false)
const stale = requestSkinSiteDownloadToken()
const staleId = sent.at(-1)!.requestId
const rejected = assert.rejects(stale, /登录状态已变化/)
resetSkinSiteSession()
await rejected
assert.equal(receive({ ...reply, requestId: staleId }), false)
setSkinSiteFrame(null)
})
test('skin site session accepts only the embedded origin and window, and redirects after verification', () => {
const frame = {} as Window
const message = (status: string, user?: unknown) =>
({
origin: SKIN_SITE_ORIGIN,
source: frame,
data: { type: 'starlight-skin-session', status, user },
}) as MessageEvent
resetSkinSiteSession()
openSkinSiteLogin()
assert.equal(skinSiteFrameUrl.value, `${SKIN_SITE_ORIGIN}/login`)
const signedIn = message('signed-in', {
uuid: 'test-user',
username: '测试用户',
jwt: 'must-not-copy',
})
assert.equal(
receiveSkinSiteMessage({ ...signedIn, origin: 'https://evil.example' } as MessageEvent, frame),
false,
)
assert.equal(receiveSkinSiteMessage(signedIn, {} as Window), false)
assert.equal(receiveSkinSiteMessage(message('signed-in', {}), frame), false)
assert.equal(skinSiteUser.value, null)
assert.equal(receiveSkinSiteMessage(signedIn, frame), true)
assert.deepEqual(skinSiteUser.value, { uuid: 'test-user', username: '测试用户' })
assert.equal(skinSiteStatus.value, 'signed-in')
assert.equal(skinSiteFrameUrl.value, `${SKIN_SITE_ORIGIN}/profile`)
assert.equal(receiveSkinSiteMessage(message('checking'), frame), true)
assert.equal(skinSiteUser.value, null)
receiveSkinSiteMessage(message('signed-in', { uuid: 'second', username: '另一个账号' }), frame)
assert.deepEqual(skinSiteUser.value, { uuid: 'second', username: '另一个账号' })
receiveSkinSiteMessage(message('signed-out'), frame)
assert.equal(skinSiteUser.value, null)
assert.equal(skinSiteStatus.value, 'signed-out')
receiveSkinSiteMessage(message('error'), frame)
assert.equal(skinSiteStatus.value, 'error')
resetSkinSiteSession()
})
test('luck requests use only the connected skin site frame and validate its result', async () => {
const sent: Array<{ data: unknown; targetOrigin: string }> = []
const frame = {
postMessage(data: unknown, targetOrigin: string) {
sent.push({ data, targetOrigin })
},
} as unknown as Window
const sessionMessage = {
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'lucky-user', username: 'Lucky' },
},
} as MessageEvent
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(sessionMessage, frame)
const automaticPlayersRequest = sent.at(-1)?.data as { type: string; requestId: string }
assert.equal(automaticPlayersRequest.type, 'starlight-skin-players-request')
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: automaticPlayersRequest.requestId,
ok: true,
players: [],
},
} as MessageEvent,
frame,
)
const result = requestSkinSiteLuck()
assert.equal(sent.at(-1)?.targetOrigin, SKIN_SITE_ORIGIN)
const request = sent.at(-1)?.data as { type: string; requestId: string }
assert.equal(request.type, 'starlight-skin-luck-request')
assert.match(request.requestId, /^skin-luck-\d+-\d+$/)
assert.equal(
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-luck-result',
requestId: request.requestId,
ok: true,
luck: 88,
},
} as MessageEvent,
frame,
),
true,
)
assert.equal(await result, 88)
setSkinSiteFrame(null)
resetSkinSiteSession()
})
test('player requests validate identities and replace the collection atomically', 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 }
assert.equal(skinSitePlayersStatus.value, 'checking')
assert.equal(
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: '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 },
],
},
} as MessageEvent,
frame,
),
true,
)
assert.deepEqual(skinSitePlayers.value, [
{
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(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: retryRequest.requestId,
ok: true,
players: [],
},
} as MessageEvent,
frame,
)
assert.deepEqual(await retry, [])
assert.deepEqual(skinSitePlayers.value, [])
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()
})
test('hosted create auto-authenticates the first skin site player when no game account exists', async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
const calls: Array<{ command: string; args: Record<string, unknown> }> = []
let accounts: Array<{
account_type: string
yggdrasil?: { api_root: string }
}> = []
const frame = {
postMessage(data: { type: string; requestId: string }) {
if (data.type === 'starlight-pack-token-request') {
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-pack-token-result',
requestId: data.requestId,
token: 'site.jwt.secret',
},
} as MessageEvent,
frame,
)
return
}
if (data.type === 'starlight-skin-players-request') {
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: data.requestId,
ok: true,
players: [
{
uuid: '11111111-1111-1111-1111-111111111111',
name: 'AutoPlayer',
isMojang: false,
skinState: 'empty',
},
],
},
} as MessageEvent,
frame,
)
}
},
} as unknown as Window
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
__TAURI_INTERNALS__: {
async invoke(command: string, args: Record<string, unknown>) {
calls.push({ command, args })
if (command === 'plugin:install|hosted_has_game_account') {
return accounts.some(
(account) =>
account.account_type === 'microsoft' ||
(account.account_type === 'yggdrasil' &&
account.yggdrasil?.api_root.replace(/\/+$/, '') ===
'https://skin.starlight.cool/yggdrasil'),
)
}
if (command === 'plugin:auth|login_skin_site_player') {
accounts = [
{
account_type: 'yggdrasil',
yggdrasil: { api_root: 'https://skin.starlight.cool/yggdrasil' },
},
]
return null
}
if (command === 'plugin:instance|instance_get') return null
return command === 'plugin:install|hosted_create' ? 'auto-instance' : null
},
},
},
})
try {
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'site-user', username: 'Site' },
},
} as MessageEvent,
frame,
)
await requestSkinSitePlayers().catch(() => {})
assert.equal(await hostedCreate(), 'auto-instance')
assert.ok(
calls.some(({ command }) => command === 'plugin:auth|login_skin_site_player'),
'expected automatic player authentication',
)
} finally {
resetSkinSiteSession()
setSkinSiteFrame(null)
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
else delete (globalThis as { window?: unknown }).window
}
})
@@ -1,6 +1,24 @@
import { readonly, ref, watch } from 'vue'
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 = {
@@ -15,152 +33,223 @@ export type SkinSitePlayer = {
export type SkinSitePlayersStatus = 'idle' | 'checking' | 'ready' | 'error'
const user = ref<SkinSiteUser | null>(null)
const status = ref<SkinSiteStatus>('signed-out')
const status = ref<SkinSiteStatus>('checking')
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
let packTokenRequestSequence = 0
const pendingPackTokens = new Map<
string,
{
resolve: (token: string) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
>()
function rejectPendingPackTokens() {
for (const request of pendingPackTokens.values()) {
clearTimeout(request.timeout)
request.reject(new Error('StarLight 登录状态已变化,请重试。'))
}
pendingPackTokens.clear()
}
export function requestSkinSiteDownloadToken(): Promise<string> {
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
return Promise.reject(
new Error('请先在启动器中登录 StarLight 皮肤站,再下载整合包。无需选择玩家。'),
)
}
const requestId = `pack-token-${Date.now()}-${++packTokenRequestSequence}`
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
pendingPackTokens.delete(requestId)
reject(new Error('获取 StarLight 登录凭据超时,请重试。'))
}, 10_000)
pendingPackTokens.set(requestId, { resolve, reject, timeout })
try {
connectedFrame?.postMessage(
{ type: 'starlight-pack-token-request', requestId },
SKIN_SITE_ORIGIN,
)
} catch {
clearTimeout(timeout)
pendingPackTokens.delete(requestId)
reject(new Error('无法读取 StarLight 登录状态,请重新登录。'))
}
})
}
type PendingLuckRequest = {
resolve: (luck: number) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
const pendingLuckRequests = new Map<string, PendingLuckRequest>()
type PendingPlayersRequest = {
resolve: (players: SkinSitePlayer[]) => void
reject: (error: Error) => void
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')
)
}
let playersRequest: Promise<SkinSitePlayer[]> | 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)
export const skinSiteFrameUrl = readonly(frameUrl)
export async function waitForSkinSiteSession() {
if (status.value !== 'checking') return
await new Promise<void>((resolve, reject) => {
const stop = watch(status, next => {
if (next === 'checking') return
clearTimeout(timer)
stop()
resolve()
})
const timer = setTimeout(() => {
stop()
reject(new Error('皮肤站登录状态仍在检查,请稍后重试。'))
}, 10_000)
})
function toUser(raw: ApiSkinSiteUser): SkinSiteUser {
return { uuid: raw.uuid, username: raw.username }
}
function rejectPendingLuckRequests(message: string) {
for (const request of pendingLuckRequests.values()) {
clearTimeout(request.timeout)
request.reject(new Error(message))
function clearSessionState() {
user.value = null
players.value = []
playersStatus.value = 'idle'
selectedPlayerId.value = null
}
// ---------------------------------------------------------------------------
// Session lifecycle
// ---------------------------------------------------------------------------
let initialized: Promise<void> | 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<void> {
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<void> {
initialized = null
return initSkinSiteSession()
}
export async function signInSkinSite(payload: {
username: string
password: string
clientId: string
}): Promise<SkinSiteResponse<{ username: string; jwt: string }>> {
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'
}
}
pendingLuckRequests.clear()
return result
}
function rejectPendingPlayersRequests(message: string) {
for (const request of pendingPlayersRequests.values()) {
clearTimeout(request.timeout)
request.reject(new Error(message))
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<boolean> {
if (status.value !== 'signed-in') return false
const result = await skinSiteRefresh()
if (result.success) {
setSkinSiteToken(result.payload.jwt)
return true
}
pendingPlayersRequests.clear()
if (result.errorCode === 401 || result.errorCode === 403) signOutSkinSite()
return false
}
function rejectPendingSkinUpdateRequests(message: string) {
for (const request of pendingSkinUpdateRequests.values()) {
clearTimeout(request.timeout)
request.reject(new Error(message))
// ---------------------------------------------------------------------------
// 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<void> {
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<string> {
if (status.value !== 'signed-in') {
await initSkinSiteSession()
}
pendingSkinUpdateRequests.clear()
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
}
export function setSkinSiteFrame(frame: Window | null) {
if (frame && !user.value) status.value = 'checking'
if (connectedFrame === frame) return
rejectPendingPackTokens()
rejectPendingLuckRequests('The skin site connection changed.')
rejectPendingPlayersRequests('The skin site connection changed.')
rejectPendingSkinUpdateRequests('The skin site connection changed.')
connectedFrame = frame
// ---------------------------------------------------------------------------
// Players
// ---------------------------------------------------------------------------
async function buildPlayer(entry: {
uuid: string
name: string
isMojang: boolean
}): Promise<SkinSitePlayer> {
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<SkinSitePlayer[]> {
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) {
@@ -171,267 +260,65 @@ export function selectSkinSitePlayer(playerId: string | null) {
if (players.value.some((player) => player.uuid === playerId)) selectedPlayerId.value = playerId
}
export function openSkinSiteLogin() {
frameUrl.value = `${SKIN_SITE_ORIGIN}/login`
}
// ---------------------------------------------------------------------------
// Luck
// ---------------------------------------------------------------------------
export function resetSkinSiteSession() {
rejectPendingPackTokens()
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 async function requestSkinSiteLuck(): Promise<number> {
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
// ---------------------------------------------------------------------------
export function requestSkinSiteSkinUpdate(
/**
* @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<void> {
if (status.value !== 'signed-in' || !user.value) {
throw new Error('请先登录 StarLight 皮肤站。')
}
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.'))
throw new Error('该玩家无法修改皮肤。')
}
if (!isSafePngDataUrl(textureDataUrl, 2_000_000)) {
return Promise.reject(new Error('The selected skin texture is invalid or too large.'))
if (!/^data:image\/png;base64,[a-z0-9+/]+={0,2}$/i.test(textureDataUrl)) {
throw new Error('皮肤图片无效或过大。')
}
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)))
}
})
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))
}
}
export function requestSkinSitePlayers() {
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
return Promise.reject(new Error('Sign in to the skin site before requesting players.'))
}
playersStatus.value = 'checking'
const requestId = `skin-players-${Date.now()}-${++playersRequestSequence}`
return new Promise<SkinSitePlayer[]>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingPlayersRequests.delete(requestId)
playersStatus.value = 'error'
reject(new Error('The skin site player request timed out.'))
}, 30_000)
pendingPlayersRequests.set(requestId, { resolve, reject, timeout })
try {
connectedFrame?.postMessage(
{ type: 'starlight-skin-players-request', requestId },
SKIN_SITE_ORIGIN,
)
} catch (error) {
clearTimeout(timeout)
pendingPlayersRequests.delete(requestId)
playersStatus.value = 'error'
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
export function requestSkinSiteLuck() {
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
return Promise.reject(new Error('Sign in to the skin site before requesting luck.'))
}
const requestId = `skin-luck-${Date.now()}-${++luckRequestSequence}`
return new Promise<number>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingLuckRequests.delete(requestId)
reject(new Error('The skin site luck request timed out.'))
}, 12_000)
pendingLuckRequests.set(requestId, { resolve, reject, timeout })
try {
connectedFrame?.postMessage(
{ type: 'starlight-skin-luck-request', requestId },
SKIN_SITE_ORIGIN,
)
} catch (error) {
clearTimeout(timeout)
pendingLuckRequests.delete(requestId)
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
export function receiveSkinSiteMessage(event: MessageEvent, frame: Window | null) {
if (!frame || event.source !== frame || event.origin !== SKIN_SITE_ORIGIN) return false
const data = event.data
if (!data || typeof data !== 'object') return false
if (data.type === 'starlight-pack-token-result') {
const pending = pendingPackTokens.get(data.requestId)
if (!pending) return false
clearTimeout(pending.timeout)
pendingPackTokens.delete(data.requestId)
if (
status.value === 'signed-in' &&
typeof data.token === 'string' &&
data.token.length > 0 &&
data.token.length <= 16_384 &&
!/\s/.test(data.token)
) {
pending.resolve(data.token)
} else pending.reject(new Error('StarLight 登录凭据不可用,请重新登录。'))
return true
}
if (data.type === 'starlight-skin-players-result') {
if (typeof data.requestId !== 'string') return false
const pending = pendingPlayersRequests.get(data.requestId)
if (!pending) return false
clearTimeout(pending.timeout)
pendingPlayersRequests.delete(data.requestId)
if (data.ok === true && Array.isArray(data.players)) {
const nextPlayers = data.players.filter(
(player: unknown): player is SkinSitePlayer =>
typeof player === 'object' &&
player !== null &&
typeof (player as SkinSitePlayer).uuid === 'string' &&
/^[0-9a-f-]{32,36}$/i.test((player as SkinSitePlayer).uuid) &&
typeof (player as SkinSitePlayer).name === 'string' &&
(player as SkinSitePlayer).name.length > 0 &&
(player as SkinSitePlayer).name.length <= 64 &&
typeof (player as SkinSitePlayer).isMojang === 'boolean' &&
hasSafeSkinHead(player as SkinSitePlayer),
)
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(players.value)
} else {
playersStatus.value = 'error'
pending.reject(
new Error(
typeof data.error === 'string' && data.error
? data.error.slice(0, 300)
: 'The skin site returned an invalid player list.',
),
)
}
return true
}
if (data.type === 'starlight-skin-luck-result') {
if (typeof data.requestId !== 'string') return false
const pending = pendingLuckRequests.get(data.requestId)
if (!pending) return false
clearTimeout(pending.timeout)
pendingLuckRequests.delete(data.requestId)
if (
data.ok === true &&
typeof data.luck === 'number' &&
Number.isFinite(data.luck) &&
data.luck >= 0 &&
data.luck <= 100
) {
pending.resolve(data.luck)
} else {
pending.reject(
new Error(
typeof data.error === 'string' && data.error
? data.error.slice(0, 300)
: 'The skin site returned an invalid luck result.',
),
)
}
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
if (data.status === 'signed-in') {
if (
!data.user ||
typeof data.user.uuid !== 'string' ||
typeof data.user.username !== 'string' ||
!data.user.uuid ||
!data.user.username ||
data.user.username.length > 256
)
return false
if (user.value?.uuid && user.value.uuid !== data.user.uuid) {
rejectPendingPackTokens()
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)) {
frameUrl.value = `${SKIN_SITE_ORIGIN}/profile`
}
refreshPlayers = true
} else {
rejectPendingPackTokens()
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(() => {})
return true
async function dataUrlToFile(dataUrl: string, filename: string): Promise<File> {
const response = await fetch(dataUrl)
const blob = await response.blob()
return new File([blob], filename, { type: 'image/png' })
}
@@ -0,0 +1,337 @@
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
export const SKIN_SITE_ORIGIN = 'https://skin.starlight.cool'
/** Where the skin site stores the JWT for this launcher. Kept separate from
* the web site's own `loginToken` so the two sessions never clash. */
const TOKEN_STORAGE_KEY = 'starlight-skin-site-token'
export type SkinSiteResponse<T> =
| { readonly success: true; readonly payload: T; readonly timestamp: number }
| {
readonly success: false
readonly timestamp: number
readonly errorType?: string
readonly errorCode: number
readonly errorMessage: string
}
type RequestType = 'starlight' | 'skin' | 'mod'
const REQUEST_TYPE_PATH: Record<RequestType, string> = {
starlight: '/starlight',
skin: '/starlight/skin',
mod: '/starlight/mod',
}
// ---------------------------------------------------------------------------
// Token storage
// ---------------------------------------------------------------------------
export function getSkinSiteToken(): string | null {
try {
return localStorage.getItem(TOKEN_STORAGE_KEY)
} catch {
return null
}
}
export function setSkinSiteToken(token: string | null): void {
try {
if (token) localStorage.setItem(TOKEN_STORAGE_KEY, token)
else localStorage.removeItem(TOKEN_STORAGE_KEY)
} catch {
// Storage may be unavailable in some environments; ignore.
}
}
export function clearSkinSiteToken(): void {
setSkinSiteToken(null)
}
// ---------------------------------------------------------------------------
// Core request
// ---------------------------------------------------------------------------
function createException(
errorCode: number,
errorMessage: string,
timestamp?: number,
errorType?: string,
): SkinSiteResponse<never> {
return {
success: false,
timestamp: timestamp ?? Date.now(),
errorMessage,
errorType,
errorCode,
}
}
function handleException(status: number): SkinSiteResponse<never> {
switch (status) {
case 0:
return createException(0, '连接中断,请检查网络或代理后重试', undefined, 'NetworkError')
case 400:
return createException(status, '请求参数有误')
case 401:
return createException(status, '该功能需要登录后才能使用')
case 403:
return createException(status, '权限不足')
case 404:
return createException(status, '资源不存在')
case 413:
return createException(status, '文件超过服务器上传大小限制')
case 429:
return createException(status, '请求过于频繁,请稍后再试')
case 502:
case 503:
return createException(status, '服务器暂时不可用,请稍后重试')
case 504:
return createException(status, '网关等待服务器处理超时')
default:
return createException(status, `请求失败(HTTP ${status})`)
}
}
async function processResponse<T>(response: Response): Promise<SkinSiteResponse<T>> {
const text = await response.text()
if (!response.ok && text.length === 0) {
return handleException(response.status)
}
let data: Record<string, unknown> | null = null
try {
data = text.length !== 0 ? JSON.parse(text) : null
} catch {
return response.ok
? createException(response.status, '服务器返回了无法识别的响应', undefined, 'InvalidResponse')
: handleException(response.status)
}
if (response.ok) {
return {
success: true,
payload: data?.payload as T,
timestamp: typeof data?.timestamp === 'number' ? data.timestamp : Date.now(),
}
}
const errorMessage = data?.errorMessage
if (typeof errorMessage === 'string' && errorMessage) {
return createException(
response.status,
errorMessage,
typeof data?.timestamp === 'number' ? data.timestamp : undefined,
typeof data?.error === 'string' ? data.error : undefined,
)
}
return handleException(response.status)
}
interface SendOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
type?: RequestType
body?: unknown
/** Set to false to skip the Authorization header (e.g. login). */
auth?: boolean
}
export async function skinSiteRequest<T>(
path: string,
options: SendOptions = {},
): Promise<SkinSiteResponse<T>> {
const { method = 'GET', type = 'starlight', body, auth = true } = options
const url = `${SKIN_SITE_ORIGIN}${REQUEST_TYPE_PATH[type]}/${path.replace(/^\/+/, '')}`
const headers: Record<string, string> = {
Accept: 'application/json',
}
if (body !== undefined) headers['Content-Type'] = 'application/json'
if (auth) {
const token = getSkinSiteToken()
if (token) headers['Authorization'] = `Bearer ${token}`
}
let response: Response
try {
response = await tauriFetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
} catch {
return handleException(0)
}
return processResponse<T>(response)
}
// ---------------------------------------------------------------------------
// Auth types (mirrors the skin site's packet shapes)
// ---------------------------------------------------------------------------
export interface SkinSiteUser {
uuid: string
username: string
email?: string
qq?: number | null
banned?: boolean
id?: number
permissions?: string[]
registerTime?: number
avatar?: string | null
}
export interface SkinSiteLoginResult {
username: string
jwt: string
}
export interface SkinSiteCaptchaRequire {
clientId: string
image?: string
[extra: string]: unknown
}
// ---------------------------------------------------------------------------
// Auth API
// ---------------------------------------------------------------------------
/** Ask the server for a captcha challenge. */
export function requestCaptcha(): Promise<SkinSiteResponse<SkinSiteCaptchaRequire>> {
return skinSiteRequest<SkinSiteCaptchaRequire>('captcha', { auth: false })
}
/** Submit the captcha answer; resolves to the clientId to use for login/register. */
export function verifyCaptcha(payload: {
clientId: string
answer: string
[key: string]: unknown
}): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('verifyCaptcha', { method: 'POST', body: payload, auth: false })
}
export function skinSiteLogin(payload: {
username: string
password: string
clientId: string
}): Promise<SkinSiteResponse<SkinSiteLoginResult>> {
return skinSiteRequest<SkinSiteLoginResult>('login', {
method: 'POST',
body: payload,
auth: false,
})
}
export function skinSiteRefresh(): Promise<SkinSiteResponse<SkinSiteLoginResult>> {
return skinSiteRequest<SkinSiteLoginResult>('user/refresh')
}
export function skinSiteCurrentUser(): Promise<SkinSiteResponse<SkinSiteUser>> {
return skinSiteRequest<SkinSiteUser>('user')
}
export function skinSiteLuck(): Promise<SkinSiteResponse<{ luck: number }>> {
return skinSiteRequest<{ luck: number }>('luck')
}
export function skinSiteRegisterCondition(): Promise<
SkinSiteResponse<{ registerAllowed: boolean; inviteCodeRequired: boolean }>
> {
return skinSiteRequest('register/condition', { auth: false })
}
// ---------------------------------------------------------------------------
// Players & skins (RequestType.SKIN)
// ---------------------------------------------------------------------------
export interface SkinSitePlayerInfo {
uuid: string
name: string
isMojang: boolean
ownerUsername?: string
legacyInternalAdapted?: boolean
}
export interface SkinSitePlayerSkin {
skin?: string
cape?: string
elytra?: string
model?: 'default' | 'slim'
}
/** List the players owned by the signed-in account. */
export function skinSitePlayerList(): Promise<SkinSiteResponse<SkinSitePlayerInfo[]>> {
return skinSiteRequest<SkinSitePlayerInfo[]>('player', { type: 'skin' })
}
/** Fetch the skin/cape/elytra textures for a single player. */
export function skinSitePlayerSkin(uuid: string): Promise<SkinSiteResponse<SkinSitePlayerSkin>> {
return skinSiteRequest<SkinSitePlayerSkin>(`player/skin/${encodeURIComponent(uuid)}`, {
type: 'skin',
})
}
export type SkinTextureType = 'SKIN' | 'CAPE' | 'ELYTRA'
/** Upload (or replace) a texture for a player. */
export async function skinSiteUploadTexture(
uuid: string,
file: File,
type: SkinTextureType,
metadata: Record<string, string> = {},
): Promise<SkinSiteResponse<string>> {
const form = new FormData()
form.append('file', file)
for (const [key, value] of Object.entries(metadata)) form.append(key, value)
const url = `${SKIN_SITE_ORIGIN}/starlight/skin/player/skin/${encodeURIComponent(uuid)}/${type}`
const headers: Record<string, string> = {}
const token = getSkinSiteToken()
if (token) headers['Authorization'] = `Bearer ${token}`
let response: Response
try {
response = await tauriFetch(url, { method: 'PUT', headers, body: form })
} catch {
return handleException(0)
}
return processResponse<string>(response)
}
/** Remove a texture from a player. */
export function skinSiteDeleteTexture(
uuid: string,
type: SkinTextureType,
): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>(`player/skin/${encodeURIComponent(uuid)}/${type}`, {
method: 'DELETE',
type: 'skin',
})
}
/** Add a player by name to the signed-in account. */
export function skinSiteAddPlayer(playerName: string): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>(`player/add/${encodeURIComponent(playerName)}`, {
method: 'POST',
type: 'skin',
})
}
export function skinSiteDeletePlayer(uuid: string): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>(`player/${encodeURIComponent(uuid)}`, {
method: 'DELETE',
type: 'skin',
})
}
export function skinSiteRenamePlayer(
uuid: string,
newName: string,
): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>(
`player/${encodeURIComponent(uuid)}/rename/${encodeURIComponent(newName)}`,
{ method: 'POST', type: 'skin' },
)
}
@@ -0,0 +1,86 @@
/**
* Renders a player's skin into a base64 head image, mirroring the skin site's
* own head cropper (front face at 8,8 plus the hat overlay at 40,8).
*
* The skin image is loaded through the Tauri HTTP client first so cross-origin
* textures do not taint the canvas, then drawn once as a data URL.
*/
const HEAD_BUFFER_SIZE = 18
const HEAD_FACE = { sx: 8, sy: 8, sw: 8, sh: 8, dx: 1, dy: 1, dw: 16, dh: 16 }
const HEAD_HAT = { sx: 40, sy: 8, sw: 8, sh: 8, dx: 0, dy: 0, dw: 18, dh: 18 }
let buffer: HTMLCanvasElement | null = null
function getBuffer(): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } {
if (!buffer) buffer = document.createElement('canvas')
const ctx = buffer.getContext('2d')
if (!ctx) throw new Error('无法创建皮肤渲染画布')
return { canvas: buffer, ctx }
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('无法加载皮肤图片'))
img.src = src
})
}
/** Crop the head (and hat overlay) out of a skin image URL. Returns a data URL. */
export async function renderSkinHead(skinUrl: string, scale = 2): Promise<string> {
const img = await loadImage(skinUrl)
const { canvas, ctx } = getBuffer()
canvas.width = HEAD_BUFFER_SIZE
canvas.height = HEAD_BUFFER_SIZE
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.imageSmoothingEnabled = false
ctx.drawImage(
img,
HEAD_FACE.sx,
HEAD_FACE.sy,
HEAD_FACE.sw,
HEAD_FACE.sh,
HEAD_FACE.dx,
HEAD_FACE.dy,
HEAD_FACE.dw,
HEAD_FACE.dh,
)
ctx.globalCompositeOperation = 'source-over'
ctx.drawImage(
img,
HEAD_HAT.sx,
HEAD_HAT.sy,
HEAD_HAT.sw,
HEAD_HAT.sh,
HEAD_HAT.dx,
HEAD_HAT.dy,
HEAD_HAT.dw,
HEAD_HAT.dh,
)
if (scale <= 1) return canvas.toDataURL('image/png')
const small = await createImageBitmap(canvas)
canvas.width = Math.floor(HEAD_BUFFER_SIZE * scale)
canvas.height = Math.floor(HEAD_BUFFER_SIZE * scale)
ctx.imageSmoothingEnabled = false
ctx.drawImage(small, 0, 0, HEAD_BUFFER_SIZE, HEAD_BUFFER_SIZE, 0, 0, canvas.width, canvas.height)
return canvas.toDataURL('image/png')
}
/** Fetch an image URL as a data URL (used to keep full skin textures local). */
export async function fetchImageAsDataUrl(url: string): Promise<string> {
const response = await fetch(url)
if (!response.ok) throw new Error(`无法加载图片(HTTP ${response.status})`)
const blob = await response.blob()
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result ?? ''))
reader.onerror = () => reject(new Error('无法读取图片数据'))
reader.readAsDataURL(blob)
})
}