diff --git a/apps/app-frontend/src/composables/skin-site-session.test.ts b/apps/app-frontend/src/composables/skin-site-session.test.ts deleted file mode 100644 index ea341cd..0000000 --- a/apps/app-frontend/src/composables/skin-site-session.test.ts +++ /dev/null @@ -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 }> = [] - let syncResponse: (() => Promise) | 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) { - 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 }> = [] - 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) { - 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 - } -}) diff --git a/apps/app-frontend/src/composables/skin-site-session.ts b/apps/app-frontend/src/composables/skin-site-session.ts index 3bea128..2ebcfb3 100644 --- a/apps/app-frontend/src/composables/skin-site-session.ts +++ b/apps/app-frontend/src/composables/skin-site-session.ts @@ -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(null) -const status = ref('signed-out') +const status = ref('checking') const players = ref([]) const playersStatus = ref('idle') const selectedPlayerId = ref(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 - } ->() - -function rejectPendingPackTokens() { - for (const request of pendingPackTokens.values()) { - clearTimeout(request.timeout) - request.reject(new Error('StarLight 登录状态已变化,请重试。')) - } - pendingPackTokens.clear() -} - -export function requestSkinSiteDownloadToken(): Promise { - 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 -} - -const pendingLuckRequests = new Map() -type PendingPlayersRequest = { - resolve: (players: SkinSitePlayer[]) => void - reject: (error: Error) => void - timeout: ReturnType -} -const pendingPlayersRequests = new Map() -type PendingSkinUpdateRequest = { - resolve: () => void - reject: (error: Error) => void - timeout: ReturnType -} -const pendingSkinUpdateRequests = new Map() - -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 | 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((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 | null = null + +/** + * Resolve the current session from the stored JWT. Safe to call repeatedly; + * the first call performs the actual work and later ones reuse it. + */ +export function initSkinSiteSession(): Promise { + if (initialized) return initialized + initialized = (async () => { + const token = getSkinSiteToken() + if (!token) { + status.value = 'signed-out' + return + } + status.value = 'checking' + const result = await skinSiteCurrentUser() + if (result.success) { + user.value = toUser(result.payload) + status.value = 'signed-in' + void requestSkinSitePlayers().catch(() => {}) + } else if (result.errorCode === 401 || result.errorCode === 403) { + clearSkinSiteToken() + clearSessionState() + status.value = 'signed-out' + } else { + status.value = 'error' + } + })() + return initialized +} + +/** Force a re-check (e.g. after the user signs in from another view). */ +export function reloadSkinSiteSession(): Promise { + initialized = null + return initSkinSiteSession() +} + +export async function signInSkinSite(payload: { + username: string + password: string + clientId: string +}): Promise> { + const result = await skinSiteLogin(payload) + if (result.success) { + setSkinSiteToken(result.payload.jwt) + const current = await skinSiteCurrentUser() + if (current.success) { + user.value = toUser(current.payload) + status.value = 'signed-in' + void requestSkinSitePlayers().catch(() => {}) + } else { + user.value = { uuid: '', username: result.payload.username } + status.value = 'signed-in' + } } - 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 { + 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 { + await initSkinSiteSession() +} + +// --------------------------------------------------------------------------- +// Download token (the launcher's hosted pack downloader) +// --------------------------------------------------------------------------- + +/** + * The hosted pack downloader authenticates with the skin site JWT. We hold it + * locally now, so this simply surfaces it (refreshing once if it is missing). + */ +export async function requestSkinSiteDownloadToken(): Promise { + if (status.value !== 'signed-in') { + await initSkinSiteSession() } - 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 { + const base: SkinSitePlayer = { + uuid: entry.uuid, + name: entry.name, + isMojang: entry.isMojang, + skinState: 'empty', + } + const skinResult = await skinSitePlayerSkin(entry.uuid) + if (!skinResult.success) return { ...base, skinState: 'error' } + + const texture: SkinSitePlayerSkin = skinResult.payload ?? {} + if (!texture.skin) return base + + try { + const [headDataUrl, skinDataUrl] = await Promise.all([ + renderSkinHead(texture.skin), + fetchImageAsDataUrl(texture.skin), + ]) + return { + ...base, + skinState: 'ready', + headDataUrl, + skinDataUrl, + model: texture.model === 'slim' ? 'slim' : 'default', + } + } catch { + return { ...base, skinState: 'error' } + } +} + +export function requestSkinSitePlayers(): Promise { + if (playersRequest) return playersRequest + if (status.value !== 'signed-in' || !user.value) { + return Promise.reject(new Error('请先登录 StarLight 皮肤站。')) + } + + playersStatus.value = 'checking' + playersRequest = (async () => { + try { + const list = await skinSitePlayerList() + if (!list.success) throw new Error(list.errorMessage) + const built = await Promise.all(list.payload.map(buildPlayer)) + players.value = built + if (selectedPlayerId.value && !built.some((p) => p.uuid === selectedPlayerId.value)) { + selectedPlayerId.value = null + } + playersStatus.value = 'ready' + return players.value + } catch (error) { + playersStatus.value = 'error' + throw error + } finally { + playersRequest = null + } + })() + return playersRequest } export function selectSkinSitePlayer(playerId: string | null) { @@ -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 { + 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 { + 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((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((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((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 { + const response = await fetch(dataUrl) + const blob = await response.blob() + return new File([blob], filename, { type: 'image/png' }) } diff --git a/apps/app-frontend/src/helpers/skin-site-api.ts b/apps/app-frontend/src/helpers/skin-site-api.ts new file mode 100644 index 0000000..1d0a723 --- /dev/null +++ b/apps/app-frontend/src/helpers/skin-site-api.ts @@ -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 = + | { 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 = { + 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 { + return { + success: false, + timestamp: timestamp ?? Date.now(), + errorMessage, + errorType, + errorCode, + } +} + +function handleException(status: number): SkinSiteResponse { + 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(response: Response): Promise> { + const text = await response.text() + + if (!response.ok && text.length === 0) { + return handleException(response.status) + } + + let data: Record | 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( + path: string, + options: SendOptions = {}, +): Promise> { + const { method = 'GET', type = 'starlight', body, auth = true } = options + const url = `${SKIN_SITE_ORIGIN}${REQUEST_TYPE_PATH[type]}/${path.replace(/^\/+/, '')}` + + const headers: Record = { + 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(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> { + return skinSiteRequest('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> { + return skinSiteRequest('verifyCaptcha', { method: 'POST', body: payload, auth: false }) +} + +export function skinSiteLogin(payload: { + username: string + password: string + clientId: string +}): Promise> { + return skinSiteRequest('login', { + method: 'POST', + body: payload, + auth: false, + }) +} + +export function skinSiteRefresh(): Promise> { + return skinSiteRequest('user/refresh') +} + +export function skinSiteCurrentUser(): Promise> { + return skinSiteRequest('user') +} + +export function skinSiteLuck(): Promise> { + 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> { + return skinSiteRequest('player', { type: 'skin' }) +} + +/** Fetch the skin/cape/elytra textures for a single player. */ +export function skinSitePlayerSkin(uuid: string): Promise> { + return skinSiteRequest(`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 = {}, +): Promise> { + 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 = {} + 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(response) +} + +/** Remove a texture from a player. */ +export function skinSiteDeleteTexture( + uuid: string, + type: SkinTextureType, +): Promise> { + return skinSiteRequest(`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> { + return skinSiteRequest(`player/add/${encodeURIComponent(playerName)}`, { + method: 'POST', + type: 'skin', + }) +} + +export function skinSiteDeletePlayer(uuid: string): Promise> { + return skinSiteRequest(`player/${encodeURIComponent(uuid)}`, { + method: 'DELETE', + type: 'skin', + }) +} + +export function skinSiteRenamePlayer( + uuid: string, + newName: string, +): Promise> { + return skinSiteRequest( + `player/${encodeURIComponent(uuid)}/rename/${encodeURIComponent(newName)}`, + { method: 'POST', type: 'skin' }, + ) +} diff --git a/apps/app-frontend/src/helpers/skin-site-render.ts b/apps/app-frontend/src/helpers/skin-site-render.ts new file mode 100644 index 0000000..c1d8c91 --- /dev/null +++ b/apps/app-frontend/src/helpers/skin-site-render.ts @@ -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 { + 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 { + 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 { + const response = await fetch(url) + if (!response.ok) throw new Error(`无法加载图片(HTTP ${response.status})`) + const blob = await response.blob() + return await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result ?? '')) + reader.onerror = () => reject(new Error('无法读取图片数据')) + reader.readAsDataURL(blob) + }) +}