主页原先通过隐藏 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:
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' })
|
||||
}
|
||||
Reference in new issue
Block a user