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,86 @@
/**
* Renders a player's skin into a base64 head image, mirroring the skin site's
* own head cropper (front face at 8,8 plus the hat overlay at 40,8).
*
* The skin image is loaded through the Tauri HTTP client first so cross-origin
* textures do not taint the canvas, then drawn once as a data URL.
*/
const HEAD_BUFFER_SIZE = 18
const HEAD_FACE = { sx: 8, sy: 8, sw: 8, sh: 8, dx: 1, dy: 1, dw: 16, dh: 16 }
const HEAD_HAT = { sx: 40, sy: 8, sw: 8, sh: 8, dx: 0, dy: 0, dw: 18, dh: 18 }
let buffer: HTMLCanvasElement | null = null
function getBuffer(): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } {
if (!buffer) buffer = document.createElement('canvas')
const ctx = buffer.getContext('2d')
if (!ctx) throw new Error('无法创建皮肤渲染画布')
return { canvas: buffer, ctx }
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('无法加载皮肤图片'))
img.src = src
})
}
/** Crop the head (and hat overlay) out of a skin image URL. Returns a data URL. */
export async function renderSkinHead(skinUrl: string, scale = 2): Promise<string> {
const img = await loadImage(skinUrl)
const { canvas, ctx } = getBuffer()
canvas.width = HEAD_BUFFER_SIZE
canvas.height = HEAD_BUFFER_SIZE
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.imageSmoothingEnabled = false
ctx.drawImage(
img,
HEAD_FACE.sx,
HEAD_FACE.sy,
HEAD_FACE.sw,
HEAD_FACE.sh,
HEAD_FACE.dx,
HEAD_FACE.dy,
HEAD_FACE.dw,
HEAD_FACE.dh,
)
ctx.globalCompositeOperation = 'source-over'
ctx.drawImage(
img,
HEAD_HAT.sx,
HEAD_HAT.sy,
HEAD_HAT.sw,
HEAD_HAT.sh,
HEAD_HAT.dx,
HEAD_HAT.dy,
HEAD_HAT.dw,
HEAD_HAT.dh,
)
if (scale <= 1) return canvas.toDataURL('image/png')
const small = await createImageBitmap(canvas)
canvas.width = Math.floor(HEAD_BUFFER_SIZE * scale)
canvas.height = Math.floor(HEAD_BUFFER_SIZE * scale)
ctx.imageSmoothingEnabled = false
ctx.drawImage(small, 0, 0, HEAD_BUFFER_SIZE, HEAD_BUFFER_SIZE, 0, 0, canvas.width, canvas.height)
return canvas.toDataURL('image/png')
}
/** Fetch an image URL as a data URL (used to keep full skin textures local). */
export async function fetchImageAsDataUrl(url: string): Promise<string> {
const response = await fetch(url)
if (!response.ok) throw new Error(`无法加载图片(HTTP ${response.status})`)
const blob = await response.blob()
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result ?? ''))
reader.onerror = () => reject(new Error('无法读取图片数据'))
reader.readAsDataURL(blob)
})
}