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

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

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

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

No files matched your search

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