Files
Starlight_Launcher/apps/app-frontend/src/helpers/skin-site-api.ts
T
AxTps 0edf96fc2c
Sync source to CNB / Sync Git ref (push) Skipped
feat: 皮肤站会话改为 API 驱动,不再依赖内嵌网页
主页原先通过隐藏 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 与远程公告的鉴权方式保持不变。
2026-10-17 18:35:58 +08:00

338 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
export const SKIN_SITE_ORIGIN = 'https://skin.starlight.cool'
/** Where the skin site stores the JWT for this launcher. Kept separate from
* the web site's own `loginToken` so the two sessions never clash. */
const TOKEN_STORAGE_KEY = 'starlight-skin-site-token'
export type SkinSiteResponse<T> =
| { readonly success: true; readonly payload: T; readonly timestamp: number }
| {
readonly success: false
readonly timestamp: number
readonly errorType?: string
readonly errorCode: number
readonly errorMessage: string
}
type RequestType = 'starlight' | 'skin' | 'mod'
const REQUEST_TYPE_PATH: Record<RequestType, string> = {
starlight: '/starlight',
skin: '/starlight/skin',
mod: '/starlight/mod',
}
// ---------------------------------------------------------------------------
// Token storage
// ---------------------------------------------------------------------------
export function getSkinSiteToken(): string | null {
try {
return localStorage.getItem(TOKEN_STORAGE_KEY)
} catch {
return null
}
}
export function setSkinSiteToken(token: string | null): void {
try {
if (token) localStorage.setItem(TOKEN_STORAGE_KEY, token)
else localStorage.removeItem(TOKEN_STORAGE_KEY)
} catch {
// Storage may be unavailable in some environments; ignore.
}
}
export function clearSkinSiteToken(): void {
setSkinSiteToken(null)
}
// ---------------------------------------------------------------------------
// Core request
// ---------------------------------------------------------------------------
function createException(
errorCode: number,
errorMessage: string,
timestamp?: number,
errorType?: string,
): SkinSiteResponse<never> {
return {
success: false,
timestamp: timestamp ?? Date.now(),
errorMessage,
errorType,
errorCode,
}
}
function handleException(status: number): SkinSiteResponse<never> {
switch (status) {
case 0:
return createException(0, '连接中断,请检查网络或代理后重试', undefined, 'NetworkError')
case 400:
return createException(status, '请求参数有误')
case 401:
return createException(status, '该功能需要登录后才能使用')
case 403:
return createException(status, '权限不足')
case 404:
return createException(status, '资源不存在')
case 413:
return createException(status, '文件超过服务器上传大小限制')
case 429:
return createException(status, '请求过于频繁,请稍后再试')
case 502:
case 503:
return createException(status, '服务器暂时不可用,请稍后重试')
case 504:
return createException(status, '网关等待服务器处理超时')
default:
return createException(status, `请求失败(HTTP ${status})`)
}
}
async function processResponse<T>(response: Response): Promise<SkinSiteResponse<T>> {
const text = await response.text()
if (!response.ok && text.length === 0) {
return handleException(response.status)
}
let data: Record<string, unknown> | null = null
try {
data = text.length !== 0 ? JSON.parse(text) : null
} catch {
return response.ok
? createException(response.status, '服务器返回了无法识别的响应', undefined, 'InvalidResponse')
: handleException(response.status)
}
if (response.ok) {
return {
success: true,
payload: data?.payload as T,
timestamp: typeof data?.timestamp === 'number' ? data.timestamp : Date.now(),
}
}
const errorMessage = data?.errorMessage
if (typeof errorMessage === 'string' && errorMessage) {
return createException(
response.status,
errorMessage,
typeof data?.timestamp === 'number' ? data.timestamp : undefined,
typeof data?.error === 'string' ? data.error : undefined,
)
}
return handleException(response.status)
}
interface SendOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
type?: RequestType
body?: unknown
/** Set to false to skip the Authorization header (e.g. login). */
auth?: boolean
}
export async function skinSiteRequest<T>(
path: string,
options: SendOptions = {},
): Promise<SkinSiteResponse<T>> {
const { method = 'GET', type = 'starlight', body, auth = true } = options
const url = `${SKIN_SITE_ORIGIN}${REQUEST_TYPE_PATH[type]}/${path.replace(/^\/+/, '')}`
const headers: Record<string, string> = {
Accept: 'application/json',
}
if (body !== undefined) headers['Content-Type'] = 'application/json'
if (auth) {
const token = getSkinSiteToken()
if (token) headers['Authorization'] = `Bearer ${token}`
}
let response: Response
try {
response = await tauriFetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
} catch {
return handleException(0)
}
return processResponse<T>(response)
}
// ---------------------------------------------------------------------------
// Auth types (mirrors the skin site's packet shapes)
// ---------------------------------------------------------------------------
export interface SkinSiteUser {
uuid: string
username: string
email?: string
qq?: number | null
banned?: boolean
id?: number
permissions?: string[]
registerTime?: number
avatar?: string | null
}
export interface SkinSiteLoginResult {
username: string
jwt: string
}
export interface SkinSiteCaptchaRequire {
clientId: string
image?: string
[extra: string]: unknown
}
// ---------------------------------------------------------------------------
// Auth API
// ---------------------------------------------------------------------------
/** Ask the server for a captcha challenge. */
export function requestCaptcha(): Promise<SkinSiteResponse<SkinSiteCaptchaRequire>> {
return skinSiteRequest<SkinSiteCaptchaRequire>('captcha', { auth: false })
}
/** Submit the captcha answer; resolves to the clientId to use for login/register. */
export function verifyCaptcha(payload: {
clientId: string
answer: string
[key: string]: unknown
}): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('verifyCaptcha', { method: 'POST', body: payload, auth: false })
}
export function skinSiteLogin(payload: {
username: string
password: string
clientId: string
}): Promise<SkinSiteResponse<SkinSiteLoginResult>> {
return skinSiteRequest<SkinSiteLoginResult>('login', {
method: 'POST',
body: payload,
auth: false,
})
}
export function skinSiteRefresh(): Promise<SkinSiteResponse<SkinSiteLoginResult>> {
return skinSiteRequest<SkinSiteLoginResult>('user/refresh')
}
export function skinSiteCurrentUser(): Promise<SkinSiteResponse<SkinSiteUser>> {
return skinSiteRequest<SkinSiteUser>('user')
}
export function skinSiteLuck(): Promise<SkinSiteResponse<{ luck: number }>> {
return skinSiteRequest<{ luck: number }>('luck')
}
export function skinSiteRegisterCondition(): Promise<
SkinSiteResponse<{ registerAllowed: boolean; inviteCodeRequired: boolean }>
> {
return skinSiteRequest('register/condition', { auth: false })
}
// ---------------------------------------------------------------------------
// Players & skins (RequestType.SKIN)
// ---------------------------------------------------------------------------
export interface SkinSitePlayerInfo {
uuid: string
name: string
isMojang: boolean
ownerUsername?: string
legacyInternalAdapted?: boolean
}
export interface SkinSitePlayerSkin {
skin?: string
cape?: string
elytra?: string
model?: 'default' | 'slim'
}
/** List the players owned by the signed-in account. */
export function skinSitePlayerList(): Promise<SkinSiteResponse<SkinSitePlayerInfo[]>> {
return skinSiteRequest<SkinSitePlayerInfo[]>('player', { type: 'skin' })
}
/** Fetch the skin/cape/elytra textures for a single player. */
export function skinSitePlayerSkin(uuid: string): Promise<SkinSiteResponse<SkinSitePlayerSkin>> {
return skinSiteRequest<SkinSitePlayerSkin>(`player/skin/${encodeURIComponent(uuid)}`, {
type: 'skin',
})
}
export type SkinTextureType = 'SKIN' | 'CAPE' | 'ELYTRA'
/** Upload (or replace) a texture for a player. */
export async function skinSiteUploadTexture(
uuid: string,
file: File,
type: SkinTextureType,
metadata: Record<string, string> = {},
): Promise<SkinSiteResponse<string>> {
const form = new FormData()
form.append('file', file)
for (const [key, value] of Object.entries(metadata)) form.append(key, value)
const url = `${SKIN_SITE_ORIGIN}/starlight/skin/player/skin/${encodeURIComponent(uuid)}/${type}`
const headers: Record<string, string> = {}
const token = getSkinSiteToken()
if (token) headers['Authorization'] = `Bearer ${token}`
let response: Response
try {
response = await tauriFetch(url, { method: 'PUT', headers, body: form })
} catch {
return handleException(0)
}
return processResponse<string>(response)
}
/** Remove a texture from a player. */
export function skinSiteDeleteTexture(
uuid: string,
type: SkinTextureType,
): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>(`player/skin/${encodeURIComponent(uuid)}/${type}`, {
method: 'DELETE',
type: 'skin',
})
}
/** Add a player by name to the signed-in account. */
export function skinSiteAddPlayer(playerName: string): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>(`player/add/${encodeURIComponent(playerName)}`, {
method: 'POST',
type: 'skin',
})
}
export function skinSiteDeletePlayer(uuid: string): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>(`player/${encodeURIComponent(uuid)}`, {
method: 'DELETE',
type: 'skin',
})
}
export function skinSiteRenamePlayer(
uuid: string,
newName: string,
): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>(
`player/${encodeURIComponent(uuid)}/rename/${encodeURIComponent(newName)}`,
{ method: 'POST', type: 'skin' },
)
}