update: bili-toy

This commit is contained in:
Vick Scarlet
2026-08-16 20:06:16 +08:00
committed by 神戸小鳥
parent 5c951f4940
commit d3f47dd7e9
17 changed files with 1212 additions and 201 deletions
+2
View File
@@ -7,6 +7,7 @@
"dev": "bunx --bun vite",
"dev:prod": "bunx --bun vite --mode production",
"build": "bunx --bun vite build",
"build:bili": "bunx --bun vite build --config vite.config.bili.ts",
"preview": "bunx --bun vite preview",
"lint": "bunx --bun eslint",
"test": "bunx --bun vitest"
@@ -15,6 +16,7 @@
"@remake/data": "workspace:*",
"@remake/hooks": "workspace:*",
"@remake/vitex": "workspace:*",
"@remake/thirdparty-bili-toy": "workspace:*",
"jotai": "^2.20.2",
"react": "^19.2.8",
"react-dom": "^19.2.8",
+6 -4
View File
@@ -2,7 +2,7 @@ import { useEffect, useCallback, useTransition } from 'react'
import { atom, useAtom } from 'jotai'
import { useConfigInject, useProfileInject, useRawProfile } from '@remake/hooks'
import { useUniqueInject, useUnique } from '@remake/hooks'
import { get, set } from '@/storage'
import { init, get, set } from '@/storage'
import { config } from '@/config'
const initedAtom = atom(false)
@@ -15,7 +15,8 @@ export const useInit = () => {
const loader = useCallback(async () => {
if (inited) return
configInject(config)
const { profile, unique } = await get(['profile', 'unique'])
await init()
const profile = await get('profile')
const parsed = profile ? JSON.parse(profile) || {} : {}
profileInject({
...parsed,
@@ -24,6 +25,7 @@ export const useInit = () => {
events: new Set(parsed.events || []),
talents: new Set(parsed.talents || []),
})
const unique = await get('unique')
if (unique) uniqueInject(JSON.parse(unique))
setInited(true)
}, [inited, configInject, profileInject, uniqueInject, setInited])
@@ -46,14 +48,14 @@ export const useWatcher = () => {
talents: Array.from(profile.talents),
})
saveProfile(async () => {
await set({ profile: str })
await set('profile', str)
})
}, [inited, profile, saveProfile])
useEffect(() => {
if (!inited || !unique) return
const str = JSON.stringify(unique)
saveUnique(async () => {
await set({ unique: str })
await set('unique', str)
})
}, [inited, unique, saveUnique])
+15
View File
@@ -0,0 +1,15 @@
import { test, expect, describe } from 'vitest'
import { talents, achievements, events } from '@remake/data'
describe('Bili Toy Cloud Save Core', () => {
test('maxSizeProfile should pass ToyCloudSaveCore constraints', () => {
const json = JSON.stringify({
times: 999999,
locked: Array.from(talents.keys()),
talents: Array.from(talents.keys()),
achievements: Array.from(achievements.keys()),
events: Array.from(events.keys()),
})
expect(json.length).toBeLessThan((48 * 960 * 3) / 4)
})
})
+23
View File
@@ -0,0 +1,23 @@
import * as biliToy from '@remake/thirdparty-bili-toy'
import * as local from './local'
interface Storage {
init(): Promise<void>
get(key: string): Promise<string | null>
set(key: string, value: string): Promise<boolean>
}
const storage = {} as Storage
if (import.meta.env.VITE_CHANNEL === 'bili') {
storage.init = biliToy.init
storage.get = biliToy.get
storage.set = biliToy.set
} else {
storage.init = async () => {}
storage.get = local.get
storage.set = local.set
}
export const init = storage.init
export const get = storage.get
export const set = storage.set
@@ -28,15 +28,12 @@ if (localStorage.getItem('version') !== '3.0.0') {
}
localStorage.setItem('version', '3.0.0')
}
export async function get<Key extends string>(keys: Key[]) {
return Object.fromEntries(keys.map(k => [k, localStorage.getItem(k)])) as {
[K in Key]: string | null
}
export async function get(key: string) {
return localStorage.getItem(key)
}
export async function set<Key extends string>(data: Record<Key, string>) {
for (const key in data) {
localStorage.setItem(key, data[key])
}
export async function set(key: string, value: string) {
localStorage.setItem(key, value)
return true
}
+9
View File
@@ -115,6 +115,15 @@ input {
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
}
input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0; /* 移除某些浏览器下的默认留白 */
}
}
@media (max-width: 32rem) { :root { font-size: 2.88vw; } }
@media screen and (orientation: portrait) and (pointer: coarse) {
+33
View File
@@ -0,0 +1,33 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
const dataSplitRule = {
test: /[\\/]packages[\\/]data[\\/]|@remake\/data/,
priority: 40,
name(id: string) {
const name = id.split(/[\/\\]/).pop()!
const baseName = name.substring(0, name.lastIndexOf('.'))
if (baseName && baseName !== 'index') return `data-${baseName}`
return null
},
}
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: { tsconfigPaths: true },
define: { 'import.meta.env.VITE_CHANNEL': '"bili"' },
base: './',
build: {
outDir: 'dist/bili/remake',
chunkSizeWarningLimit: 1500,
rolldownOptions: {
output: {
codeSplitting: {
minSize: 1024,
groups: [dataSplitRule],
},
},
},
},
})
+1
View File
@@ -16,6 +16,7 @@ const dataSplitRule = {
export default defineConfig({
plugins: [react()],
resolve: { tsconfigPaths: true },
base: './',
build: {
chunkSizeWarningLimit: 1500,
rolldownOptions: {
+3
View File
@@ -44,6 +44,9 @@ importers:
'@remake/hooks':
specifier: workspace:*
version: link:../../packages/hooks
'@remake/thirdparty-bili-toy':
specifier: workspace:*
version: link:../../thirdparty/bili-toy
'@remake/vitex':
specifier: workspace:*
version: link:../../packages/vitex
+1 -166
View File
@@ -1,166 +1 @@
const SDK = '//s1.hdslb.com/bfs/seed/toy/app/sdk/toy-sdk.js'
const CHUNK_SIZE = 1024
const PRELOAD_CHUNKS_LIMIT = 20
let inited = false
export async function loadSDK() {
const script = document.createElement('script')
script.src = SDK
script.async = true
document.body.appendChild(script)
return new Promise((resolve, reject) => {
script.onload = () => {
resolve(window.toy)
}
script.onerror = () => {
reject(new Error('Failed to load SDK'))
}
})
}
export async function init() {
try {
await loadSDK()
inited = true
} catch (error) {
console.error('Failed to load Bili Toy SDK:', error)
return
}
await Promise.all([
checkAbility('getCloudStorage'),
checkAbility('setCloudStorage'),
checkAbility('removeCloudStorage'),
])
}
async function checkAbility(ability: toy.Ability) {
if (!(await toy.isSupport(ability)))
throw new Error(
`Bili Toy SDK does not support ${ability}. Please check the SDK version.`,
)
}
interface ChunkMeta {
total: number
size: number
}
function splitIntoChunks(
str: string,
chunkSize: number = CHUNK_SIZE,
): string[] {
const base64Str = new TextEncoder().encode(str).toBase64()
const chunks: string[] = []
let offset = 0
while (offset < base64Str.length) {
chunks.push(base64Str.substring(offset, offset + chunkSize))
offset += chunkSize
}
return chunks
}
function mergeChunks(chunks: string[]): string {
const combinedBase64 = chunks.join('')
return new TextDecoder().decode(Uint8Array.from(combinedBase64))
}
export async function getCloudStore<K extends string[]>(
keys: [...K],
): Promise<Record<K[number], string | undefined>> {
if (!inited)
throw new Error('Bili Toy SDK not initialized. Call init() first.')
if (!keys.length) return {} as any
const firstBatchKeys: string[] = []
keys.forEach(key => {
firstBatchKeys.push(`${key}.meta`)
for (let i = 0; i < PRELOAD_CHUNKS_LIMIT; i++) {
firstBatchKeys.push(`${key}.chunk.${i}`)
}
})
const rawKV = await toy.getCloudStorage(firstBatchKeys)
const result = {} as Record<K[number], string | undefined>
await Promise.all(
keys.map(async key => {
const metaStr = rawKV[`${key}.meta`]
if (!metaStr) {
result[key as K[number]] = undefined
return
}
const meta: ChunkMeta = JSON.parse(metaStr)
const collectedChunks: string[] = []
for (let i = 0; i < meta.total; i++) {
const chunkVal = rawKV[`${key}.chunk.${i}`]
if (chunkVal !== undefined && chunkVal !== null) {
collectedChunks[i] = chunkVal
}
}
if (meta.total > PRELOAD_CHUNKS_LIMIT) {
const missingChunkKeys: string[] = []
for (let i = PRELOAD_CHUNKS_LIMIT; i < meta.total; i++) {
missingChunkKeys.push(`${key}.chunk.${i}`)
}
const secondaryKV = await toy.getCloudStorage(missingChunkKeys)
for (let i = PRELOAD_CHUNKS_LIMIT; i < meta.total; i++) {
collectedChunks[i] = secondaryKV[`${key}.chunk.${i}`]!
}
}
if (collectedChunks.filter(Boolean).length !== meta.total) {
console.warn(
`⚠️ [vTransform Storage] 检测到数据键 [${key}] 的云端切片发生残缺丢失,已自动放弃合并。`,
)
result[key as K[number]] = undefined
return
}
result[key as K[number]] = mergeChunks(collectedChunks)
}),
)
return result
}
export async function setCloudStore(
items: Record<string, string>,
): Promise<void> {
if (!inited)
throw new Error('Bili Toy SDK not initialized. Call init() first.')
const payloadKV: Record<string, string> = {}
for (const key in items) {
const originalValue = items[key]
if (originalValue === null || originalValue === undefined) continue
const chunkList = splitIntoChunks(originalValue, CHUNK_SIZE)
const meta: ChunkMeta = {
total: chunkList.length,
size: originalValue.length,
}
payloadKV[`${key}.meta`] = JSON.stringify(meta)
chunkList.forEach((chunkContent, index) => {
payloadKV[`${key}.chunk.${index}`] = chunkContent
})
}
return await toy.setCloudStorage(payloadKV)
}
export async function removeCloudStore(keys: string[]): Promise<void> {
if (!inited)
throw new Error('Bili Toy SDK not initialized. Call init() first.')
if (!keys.length) return
const metaKeys = keys.map(k => `${k}.meta`)
const rawMetaKV = await toy.getCloudStorage(metaKeys)
const deleteKeysList: string[] = []
keys.forEach(key => {
deleteKeysList.push(`${key}.meta`)
const metaStr = rawMetaKV[`${key}.meta`]
if (metaStr) {
const meta: ChunkMeta = JSON.parse(metaStr)
for (let i = 0; i < meta.total; i++) {
deleteKeysList.push(`${key}.chunk.${i}`)
}
}
})
return await toy.removeCloudStorage(deleteKeysList)
}
export * from './wrapper'
+172
View File
@@ -0,0 +1,172 @@
/**
* 哔哩哔哩官方 ToyCloudSaveCore 核心分片存储算法组件环境类型声明文件 (Ambient Declaration)
*
* 说明:本文件专为官方无类型的 ToyCloudSaveCore.js 设计。
* 放入项目后,TypeScript 会自动在全局识别该组件,红线报错将彻底消除。
*/
declare namespace ToyCloudSaveCoreSDK {
/** 外部必须提供给该核心组件的底层持久化存储驱动接口契约 */
interface IToyStorageDriver {
/** 异步获取指定键值对记录 */
get(keys: string[]): Promise<Record<string, any> | null | undefined>
/** 异步设置并持久化键值对记录,附带当前的存储上下文 */
set(
record: Record<string, string>,
context: IToyStorageContext,
): Promise<void>
}
/** 存储操作的阶段上下文 */
interface IToyStorageContext {
/** 当前操作的存储槽区 */
bank: 'a' | 'b'
/** 递增的存储世代号 */
generation: number
/** 当前落盘的具体物理阶段 */
phase: 'chunks' | 'meta' | 'head'
}
/** 存档操作成功后返回的物理数据摘要 */
interface IToySaveSummary {
/** 写入的存储槽区 */
bank: 'a' | 'b'
/** 递增的世代代数 */
generation: number
/** 本次存盘的实际 UTF-8 字节数 */
bytes: number
/** 分片总块数 */
chunks: number
/** 数据流的 CRC32 校验和十六进制字符串 */
checksum: string
}
/** 状态机向外部观察者派发的所有生命周期事件 */
interface IToySaveEvent {
type:
| 'save:start'
| 'save:phase'
| 'save:verified'
| 'save:success'
| 'save:error'
| 'save:rejected'
| 'save:queued'
| 'load:start'
| 'load:success'
| 'load:empty'
at: number
phase?: 'chunks' | 'meta' | 'head' | 'verify'
bank?: 'a' | 'b'
generation?: number
bytes?: number
chunks?: number
checksum?: string
fallback?: boolean
recovered?: boolean
source?: 'head' | 'scan' | 'fallback'
pending?: number
code?:
| 'STORAGE_ERROR'
| 'VERIFICATION_FAILED'
| 'PAYLOAD_TOO_LARGE'
| 'INVALID_PAYLOAD'
| string
found?: boolean
}
/** 观察者事件回调函数 */
type IToySaveEventHandler = (
event: Readonly<IToySaveEvent>,
) => void | Promise<void>
/** 创建云存储实例所需的入参配置项 */
interface IToyCloudSaveOptions {
/** 必填:提供底层读写能力的本地适配驱动 */
storage: IToyStorageDriver
/** 必填:用于防冲突的唯一键值前缀 */
prefix: string
/** 选填:核心生命周期事件的全量观察者钩子 */
onEvent?: IToySaveEventHandler
}
/** 分片明细列表项 */
interface IToyChunkInspect {
index: number
chars: number
preview: string
}
/** 全盘负载深度审阅快照返回值 */
interface IToyPayloadInspection {
json: string
bytes: number
base64Chars: number
chunkCount: number
checksum: string
exceedsLimit: boolean
chunks: IToyChunkInspect[]
}
/** 调试状态快照 */
interface IToyDebugState {
prefix: string
pendingSaves: number
activeSave: boolean
head: {
bank?: 'a' | 'b'
generation?: number
valid: boolean
status?: string
} | null
banks: {
a: {
bank?: 'a' | 'b'
generation?: number
valid: boolean
status?: string
} | null
b: {
bank?: 'a' | 'b'
generation?: number
valid: boolean
status?: string
} | null
}
lastSave: IToySaveSummary | null
lastLoad: any
events: IToySaveEvent[]
}
/** 经由 createCloudSave 创建的物理隔离处理服务实例 API */
interface IToyCloudSaveInstance {
/** 写入保存云存档 */
save(json: string): Promise<IToySaveSummary>
/** 读取云存档 */
load(): Promise<string | null>
/** 导出内存调试快照 */
getDebugState(): IToyDebugState
}
/** 对应官方核心 JS 脚本导出的顶级大对象契约 */
interface CoreObject {
/** 单个分卷分片的最大安全 Base64 字符容量边界(960 */
readonly CHUNK_CHARS: 960
/** 双槽双缓冲设计允许承载的单次数据最大分片切片物理上限(48 块) */
readonly MAX_CHUNKS: 48
/** 计算指定数据的标准 CRC32 校验和 */
crc32Hex(input: string | ArrayBuffer | ArrayBufferView): string
/** 前置透视并审阅即将进行云存的文件负载 */
inspectPayload(json: string): IToyPayloadInspection
/** 核心工厂构建器:构建具备高可靠双槽原子性保护的云存档处理服务 */
createCloudSave(options: IToyCloudSaveOptions): IToyCloudSaveInstance
}
}
// ----------------- 全局注入声明 -----------------
interface Window {
/** 哔哩哔哩官方无类型的云存档核心分片存储组件。由本地或网络脚本执行后自动挂载。 */
ToyCloudSaveCore: ToyCloudSaveCoreSDK.CoreObject
}
/** 哔哩哔哩官方无类型的云存档核心分片存储组件顶级全局变量。 */
declare const ToyCloudSaveCore: ToyCloudSaveCoreSDK.CoreObject
+577
View File
@@ -0,0 +1,577 @@
(function attachToyCloudSaveCore(root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
debugger
root.ToyCloudSaveCore = factory();
}
}(typeof globalThis !== 'undefined' ? globalThis : this, function buildToyCloudSaveCore() {
'use strict';
const CHUNK_CHARS = 960;
const MAX_CHUNKS = 48;
const EVENT_LIMIT = 60;
const BANKS = ['a', 'b'];
const CRC32_TABLE = buildCrc32Table();
function buildCrc32Table() {
const table = new Uint32Array(256);
for (let index = 0; index < table.length; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0);
}
table[index] = value >>> 0;
}
return table;
}
function utf8Encode(value) {
if (typeof TextEncoder !== 'undefined') {
return new TextEncoder().encode(value);
}
if (typeof Buffer !== 'undefined') {
return Uint8Array.from(Buffer.from(value, 'utf8'));
}
const binary = unescape(encodeURIComponent(value));
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
function utf8Decode(bytes) {
if (typeof TextDecoder !== 'undefined') {
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
}
if (typeof Buffer !== 'undefined') {
const value = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('utf8');
if (!sameBytes(utf8Encode(value), bytes)) {
throw new Error('Invalid UTF-8');
}
return value;
}
let binary = '';
for (let index = 0; index < bytes.length; index += 1) {
binary += String.fromCharCode(bytes[index]);
}
return decodeURIComponent(escape(binary));
}
function sameBytes(left, right) {
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) return false;
}
return true;
}
function toBytes(input) {
if (typeof input === 'string') return utf8Encode(input);
if (typeof ArrayBuffer !== 'undefined' && input instanceof ArrayBuffer) {
return new Uint8Array(input);
}
if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(input)) {
return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
}
throw new TypeError('crc32Hex expects a string, ArrayBuffer, or typed array');
}
function crc32Hex(input) {
const bytes = toBytes(input);
let crc = 0xffffffff;
for (let index = 0; index < bytes.length; index += 1) {
crc = CRC32_TABLE[(crc ^ bytes[index]) & 0xff] ^ (crc >>> 8);
}
return ((crc ^ 0xffffffff) >>> 0).toString(16).padStart(8, '0');
}
function bytesToBase64(bytes) {
if (typeof Buffer !== 'undefined') {
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64');
}
if (typeof btoa !== 'function') {
throw new Error('Base64 encoding is unavailable');
}
let binary = '';
const batch = 0x8000;
for (let offset = 0; offset < bytes.length; offset += batch) {
const slice = bytes.subarray(offset, offset + batch);
for (let index = 0; index < slice.length; index += 1) {
binary += String.fromCharCode(slice[index]);
}
}
return btoa(binary);
}
function base64ToBytes(value) {
if (!isCanonicalBase64(value)) throw new Error('Invalid Base64');
if (typeof Buffer !== 'undefined') {
return Uint8Array.from(Buffer.from(value, 'base64'));
}
if (typeof atob !== 'function') {
throw new Error('Base64 decoding is unavailable');
}
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
function isCanonicalBase64(value) {
if (value === '') return true;
if (value.length % 4 !== 0) return false;
return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
}
function preparePayload(json) {
if (typeof json !== 'string') {
throw new TypeError('Cloud save payload must be a JSON string');
}
try {
JSON.parse(json);
} catch (parseError) {
const error = new SyntaxError('Cloud save payload must contain valid JSON');
error.code = 'INVALID_JSON';
throw error;
}
const bytes = utf8Encode(json);
const base64 = bytesToBase64(bytes);
const chunks = [];
if (base64.length === 0) {
chunks.push('');
} else {
for (let offset = 0; offset < base64.length; offset += CHUNK_CHARS) {
chunks.push(base64.slice(offset, offset + CHUNK_CHARS));
}
}
return {
json,
bytes: bytes.length,
base64Chars: base64.length,
chunks,
checksum: crc32Hex(bytes),
};
}
function inspectPayload(json) {
const payload = preparePayload(json);
return {
json: payload.json,
bytes: payload.bytes,
base64Chars: payload.base64Chars,
chunkCount: payload.chunks.length,
checksum: payload.checksum,
exceedsLimit: payload.chunks.length > MAX_CHUNKS,
chunks: payload.chunks.map((value, index) => ({
index,
chars: value.length,
preview: value.length > 24 ? `${value.slice(0, 24)}` : value,
})),
};
}
function createCloudSave(options) {
const config = options || {};
const storage = config.storage;
const prefix = config.prefix;
const onEvent = config.onEvent;
if (!storage || typeof storage.get !== 'function' || typeof storage.set !== 'function') {
throw new TypeError('storage must provide async get(keys) and set(record, context) methods');
}
if (typeof prefix !== 'string' || prefix.length === 0) {
throw new TypeError('prefix must be a non-empty string');
}
if (onEvent !== undefined && typeof onEvent !== 'function') {
throw new TypeError('onEvent must be a function');
}
const keys = {
head: `${prefix}_head`,
meta(bank) {
return `${prefix}_${bank}_meta`;
},
chunk(bank, index) {
return `${prefix}_${bank}_${String(index).padStart(3, '0')}`;
},
};
const state = {
pendingSaves: 0,
activeSave: false,
head: null,
banks: { a: null, b: null },
lastSave: null,
lastLoad: null,
events: [],
};
let saveTail = Promise.resolve();
function emit(type, details) {
const allowed = [
'bank', 'generation', 'bytes', 'chunks', 'checksum', 'phase',
'source', 'fallback', 'recovered', 'pending', 'code', 'found',
];
const event = { type, at: Date.now() };
const source = details || {};
for (const key of allowed) {
if (source[key] !== undefined) event[key] = source[key];
}
const frozen = Object.freeze(event);
state.events.push({ ...event });
if (state.events.length > EVENT_LIMIT) state.events.shift();
if (onEvent) {
try {
const result = onEvent(frozen);
if (result && typeof result.catch === 'function') result.catch(function ignoreEventError() {});
} catch (error) {
// Observers cannot affect persistence.
}
}
}
async function getRecord(requestedKeys) {
const record = await storage.get(requestedKeys);
return record && typeof record === 'object' ? record : {};
}
function own(record, key) {
return Object.prototype.hasOwnProperty.call(record, key);
}
function parseObject(value) {
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
if (typeof value !== 'string') return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
} catch (error) {
return null;
}
}
function parseHead(value) {
const head = parseObject(value);
if (!head || head.v !== 1 || !BANKS.includes(head.bank)) return null;
if (!Number.isSafeInteger(head.generation) || head.generation < 1) return null;
return { v: 1, bank: head.bank, generation: head.generation };
}
function parseMeta(value) {
const meta = parseObject(value);
if (!meta || meta.v !== 1) return null;
if (!Number.isSafeInteger(meta.generation) || meta.generation < 1) return null;
if (!Number.isSafeInteger(meta.chunks) || meta.chunks < 1 || meta.chunks > MAX_CHUNKS) return null;
if (!Number.isSafeInteger(meta.bytes) || meta.bytes < 0) return null;
if (typeof meta.checksum !== 'string' || !/^[0-9a-f]{8}$/.test(meta.checksum)) return null;
return {
v: 1,
generation: meta.generation,
chunks: meta.chunks,
bytes: meta.bytes,
checksum: meta.checksum,
};
}
function summary(bank, meta) {
return {
bank,
generation: meta.generation,
bytes: meta.bytes,
chunks: meta.chunks,
checksum: meta.checksum,
};
}
function invalidBank(bank, status) {
state.banks[bank] = { bank, valid: false, status };
return { valid: false, bank, status };
}
function validateBankRecord(bank, record, expectedGeneration) {
const metaKey = keys.meta(bank);
const meta = own(record, metaKey) ? parseMeta(record[metaKey]) : null;
if (!meta) return invalidBank(bank, own(record, metaKey) ? 'invalid' : 'missing');
if (expectedGeneration !== undefined && meta.generation !== expectedGeneration) {
return invalidBank(bank, 'generation-mismatch');
}
const chunks = [];
for (let index = 0; index < meta.chunks; index += 1) {
const key = keys.chunk(bank, index);
const value = record[key];
if (typeof value !== 'string' || value.length > CHUNK_CHARS) {
return invalidBank(bank, 'invalid-chunk');
}
if (index < meta.chunks - 1 && value.length !== CHUNK_CHARS) {
return invalidBank(bank, 'invalid-chunk');
}
chunks.push(value);
}
try {
const bytes = base64ToBytes(chunks.join(''));
if (bytes.length !== meta.bytes || crc32Hex(bytes) !== meta.checksum) {
return invalidBank(bank, 'checksum-mismatch');
}
const json = utf8Decode(bytes);
try {
JSON.parse(json);
} catch (parseError) {
return invalidBank(bank, 'invalid-json');
}
const result = { valid: true, bank, json, meta, summary: summary(bank, meta) };
state.banks[bank] = { ...result.summary, valid: true };
return result;
} catch (error) {
return invalidBank(bank, 'decode-failed');
}
}
async function readHead() {
const record = await getRecord([keys.head]);
const head = own(record, keys.head) ? parseHead(record[keys.head]) : null;
state.head = head
? { bank: head.bank, generation: head.generation, valid: true }
: { valid: false, status: own(record, keys.head) ? 'invalid' : 'missing' };
return head;
}
async function readBank(bank, expectedGeneration) {
const metaKey = keys.meta(bank);
const metaRecord = await getRecord([metaKey]);
const meta = own(metaRecord, metaKey) ? parseMeta(metaRecord[metaKey]) : null;
if (!meta) return invalidBank(bank, own(metaRecord, metaKey) ? 'invalid' : 'missing');
if (expectedGeneration !== undefined && meta.generation !== expectedGeneration) {
return invalidBank(bank, 'generation-mismatch');
}
const chunkKeys = [];
for (let index = 0; index < meta.chunks; index += 1) {
chunkKeys.push(keys.chunk(bank, index));
}
const chunkRecord = await getRecord(chunkKeys);
return validateBankRecord(bank, { ...metaRecord, ...chunkRecord }, expectedGeneration);
}
function otherBank(bank) {
return bank === 'a' ? 'b' : 'a';
}
function newestValid(results) {
return results
.filter((result) => result.valid)
.sort((left, right) => right.meta.generation - left.meta.generation)[0] || null;
}
async function chooseSaveTarget() {
const head = await readHead();
let first;
let second;
let base = null;
if (head) {
first = await readBank(head.bank, head.generation);
second = await readBank(otherBank(head.bank));
base = first.valid ? first : (second.valid ? second : null);
} else {
first = await readBank('a');
second = await readBank('b');
base = newestValid([first, second]);
}
const highestGeneration = Math.max(
head ? head.generation : 0,
first.valid ? first.meta.generation : 0,
second.valid ? second.meta.generation : 0,
);
return {
bank: base ? otherBank(base.bank) : (head ? otherBank(head.bank) : 'a'),
generation: highestGeneration + 1,
};
}
function makeError(message, code, ErrorType) {
const error = new (ErrorType || Error)(message);
error.code = code;
return error;
}
async function savePrepared(payload) {
state.activeSave = true;
let transaction;
try {
transaction = await chooseSaveTarget();
const bank = transaction.bank;
const generation = transaction.generation;
const meta = {
v: 1,
generation,
chunks: payload.chunks.length,
bytes: payload.bytes,
checksum: payload.checksum,
};
const savedSummary = summary(bank, meta);
const context = { bank, generation };
emit('save:start', savedSummary);
const chunkRecord = {};
for (let index = 0; index < payload.chunks.length; index += 1) {
chunkRecord[keys.chunk(bank, index)] = payload.chunks[index];
}
await storage.set(chunkRecord, { ...context, phase: 'chunks' });
emit('save:phase', { ...savedSummary, phase: 'chunks' });
await storage.set({ [keys.meta(bank)]: JSON.stringify(meta) }, { ...context, phase: 'meta' });
emit('save:phase', { ...savedSummary, phase: 'meta' });
const verifyKeys = [keys.meta(bank), ...Object.keys(chunkRecord)];
const readback = await getRecord(verifyKeys);
const verified = validateBankRecord(bank, readback, generation);
if (
!verified.valid
|| verified.json !== payload.json
|| verified.meta.bytes !== meta.bytes
|| verified.meta.chunks !== meta.chunks
|| verified.meta.checksum !== meta.checksum
) {
throw makeError('Cloud save readback verification failed', 'VERIFICATION_FAILED');
}
emit('save:verified', { ...savedSummary, phase: 'verify' });
const head = { v: 1, bank, generation };
await storage.set({ [keys.head]: JSON.stringify(head) }, { ...context, phase: 'head' });
state.head = { bank, generation, valid: true };
state.lastSave = { ...savedSummary };
emit('save:success', savedSummary);
return savedSummary;
} catch (error) {
emit('save:error', {
bank: transaction && transaction.bank,
generation: transaction && transaction.generation,
code: error && error.code ? error.code : 'STORAGE_ERROR',
});
throw error;
} finally {
state.activeSave = false;
}
}
function save(json) {
let payload;
try {
payload = preparePayload(json);
if (payload.chunks.length > MAX_CHUNKS) {
throw makeError(
`Cloud save payload needs ${payload.chunks.length} chunks; maximum is ${MAX_CHUNKS}`,
'PAYLOAD_TOO_LARGE',
RangeError,
);
}
} catch (error) {
emit('save:rejected', {
bytes: payload && payload.bytes,
chunks: payload && payload.chunks.length,
code: error && error.code ? error.code : 'INVALID_PAYLOAD',
});
return Promise.reject(error);
}
state.pendingSaves += 1;
emit('save:queued', {
bytes: payload.bytes,
chunks: payload.chunks.length,
checksum: payload.checksum,
pending: state.pendingSaves,
});
const operation = saveTail.then(function runQueuedSave() {
return savePrepared(payload);
});
const completed = operation.finally(function finishQueuedSave() {
state.pendingSaves -= 1;
});
saveTail = completed.catch(function keepQueueAlive() {});
return completed;
}
function finishLoad(result, flags) {
const loadSummary = {
...result.summary,
fallback: Boolean(flags.fallback),
recovered: Boolean(flags.recovered),
source: flags.recovered ? 'scan' : (flags.fallback ? 'fallback' : 'head'),
};
state.lastLoad = loadSummary;
emit('load:success', loadSummary);
return result.json;
}
async function load() {
const savesQueuedBeforeLoad = saveTail;
emit('load:start');
await savesQueuedBeforeLoad;
const head = await readHead();
if (head) {
const active = await readBank(head.bank, head.generation);
if (active.valid) return finishLoad(active, { fallback: false, recovered: false });
const fallback = await readBank(otherBank(head.bank));
if (fallback.valid && fallback.meta.generation < head.generation) {
return finishLoad(fallback, { fallback: true, recovered: false });
}
if (fallback.valid) {
state.banks[fallback.bank] = {
...fallback.summary,
valid: false,
status: 'uncommitted',
};
}
state.lastLoad = { found: false, fallback: true, recovered: false };
emit('load:empty', state.lastLoad);
return null;
}
const bankA = await readBank('a');
const bankB = await readBank('b');
const recovered = newestValid([bankA, bankB]);
if (recovered) return finishLoad(recovered, { fallback: false, recovered: true });
state.lastLoad = { found: false, fallback: false, recovered: true };
emit('load:empty', state.lastLoad);
return null;
}
function getDebugState() {
return JSON.parse(JSON.stringify({
prefix,
pendingSaves: state.pendingSaves,
activeSave: state.activeSave,
head: state.head,
banks: state.banks,
lastSave: state.lastSave,
lastLoad: state.lastLoad,
events: state.events,
}));
}
return Object.freeze({ save, load, getDebugState });
}
return Object.freeze({
createCloudSave,
inspectPayload,
crc32Hex,
CHUNK_CHARS,
MAX_CHUNKS,
});
}));
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Toy 分片云存档接入示例</title>
<style>
:root { color-scheme: light; font-family: system-ui, sans-serif; }
body { max-width: 760px; margin: 0 auto; padding: 24px; color: #18191c; background: #f6f7f8; }
main { display: grid; gap: 16px; padding: 24px; border: 1px solid #e3e5e7; border-radius: 12px; background: #fff; }
textarea { width: 100%; min-height: 220px; box-sizing: border-box; padding: 12px; font: 14px/1.6 ui-monospace, monospace; }
.actions { display: flex; flex-wrap: wrap; gap: 10px; }
button { min-height: 44px; padding: 0 18px; border: 0; border-radius: 8px; color: #fff; background: #00aeec; cursor: pointer; }
button:disabled { cursor: wait; opacity: .55; }
#status { min-height: 24px; margin: 0; color: #61666d; }
</style>
</head>
<body>
<main>
<h1>Toy 分片云存档接入示例</h1>
<p>示例始终先保存本地档;Toy SDK 可用且玩家已登录时,再提交云端。</p>
<label for="save-json">存档 JSON</label>
<textarea id="save-json" spellcheck="false"></textarea>
<div class="actions">
<button id="make-example" type="button">生成示例</button>
<button id="save" type="button">保存</button>
<button id="load" type="button">读取</button>
</div>
<p id="status" role="status" aria-live="polite">正在初始化…</p>
</main>
<!-- 顺序不可颠倒:Toy SDK → 通用核心 → 业务脚本。 -->
<script src="https://s1.hdslb.com/bfs/seed/toy/app/sdk/toy-sdk.js"></script>
<script src="./cloud-save-core.js"></script>
<script src="./example.js"></script>
</body>
</html>
+168
View File
@@ -0,0 +1,168 @@
(function () {
'use strict';
var PREFIX = 'your_game_save_v1';
var LOCAL_KEY = PREFIX + '_local';
function ToyStorageAdapter(toySdk) {
if (
!toySdk ||
typeof toySdk.getCloudStorage !== 'function' ||
typeof toySdk.setCloudStorage !== 'function'
) {
throw new Error('Toy 云存储 SDK 不可用');
}
this.toy = toySdk;
}
ToyStorageAdapter.prototype.get = async function (keys) {
var result = await this.toy.getCloudStorage(keys);
if (result && result.data && typeof result.data === 'object') {
return result.data;
}
return result || {};
};
ToyStorageAdapter.prototype.set = async function (record) {
await this.toy.setCloudStorage(record);
};
function createExampleState() {
return {
schemaVersion: 1,
updatedAt: Date.now(),
progress: { stage: 1, score: 120 },
settings: { music: true, language: 'zh-CN' },
};
}
function summarizeError(error) {
var message = error && error.message ? error.message : String(error || '未知错误');
return message.slice(0, 160);
}
document.addEventListener('DOMContentLoaded', function () {
var editor = document.getElementById('save-json');
var status = document.getElementById('status');
var saveButton = document.getElementById('save');
var loadButton = document.getElementById('load');
var exampleButton = document.getElementById('make-example');
var cloudSave = null;
function setStatus(message) {
status.textContent = message;
}
function applyLoadedState(json) {
// 替换点:把 JSON 交给项目原有的存档应用逻辑。
JSON.parse(json);
editor.value = json;
}
function loadLocalFirst() {
var localJson = localStorage.getItem(LOCAL_KEY);
if (!localJson) {
editor.value = JSON.stringify(createExampleState(), null, 2);
return false;
}
try {
applyLoadedState(localJson);
return true;
} catch (error) {
localStorage.removeItem(LOCAL_KEY);
editor.value = JSON.stringify(createExampleState(), null, 2);
return false;
}
}
function parseEditor() {
var value = editor.value.trim();
JSON.parse(value);
return value;
}
function setBusy(isBusy) {
saveButton.disabled = isBusy;
loadButton.disabled = isBusy;
}
var hadLocalSave = loadLocalFirst();
try {
if (!window.ToyCloudSaveCore) {
throw new Error('cloud-save-core.js 未加载');
}
var storage = new ToyStorageAdapter(window.toy || window.Toy);
cloudSave = window.ToyCloudSaveCore.createCloudSave({
storage: storage,
prefix: 'your_game_save_v1',
onEvent: function (event) {
// 只记录阶段摘要,禁止在这里打印完整 JSON。
if (event && event.phase) {
status.dataset.phase = event.phase;
}
},
});
setStatus(hadLocalSave ? '已加载本地档;可读取云端进行校验。' : '云存档已就绪。');
} catch (error) {
setStatus('当前仅使用本地存档:' + summarizeError(error));
}
exampleButton.addEventListener('click', function () {
editor.value = JSON.stringify(createExampleState(), null, 2);
setStatus('已生成小型示例,尚未保存。');
});
saveButton.addEventListener('click', async function () {
var json;
try {
json = parseEditor();
} catch (error) {
setStatus('JSON 无效,请修正后再保存:' + summarizeError(error));
return;
}
localStorage.setItem(LOCAL_KEY, json);
if (!cloudSave) {
setStatus('本地保存成功;当前环境无法写入 Toy 云端。');
return;
}
setBusy(true);
setStatus('本地保存成功,正在提交云端…');
try {
var result = await cloudSave.save(json);
setStatus('云保存成功:Bank ' + result.bank.toUpperCase() + '' + result.chunks + ' 片。');
} catch (error) {
setStatus('本地已保存,但云保存失败:' + summarizeError(error));
} finally {
setBusy(false);
}
});
loadButton.addEventListener('click', async function () {
var localFound = loadLocalFirst();
if (!cloudSave) {
setStatus(localFound ? '已读取本地档;当前环境无法读取 Toy 云端。' : '本地和云端均无可用存档。');
return;
}
setBusy(true);
setStatus(localFound ? '已应用本地档,正在校验云端…' : '正在读取云端…');
try {
var cloudJson = await cloudSave.load();
if (cloudJson === null) {
setStatus(localFound ? '云端为空,继续使用本地档。' : '云端和本地均为空。');
} else {
applyLoadedState(cloudJson);
localStorage.setItem(LOCAL_KEY, cloudJson);
setStatus('云档校验通过并已同步到本地。');
}
} catch (error) {
setStatus((localFound ? '继续使用本地档;' : '') + '云读取失败:' + summarizeError(error));
} finally {
setBusy(false);
}
});
});
})();
@@ -59,9 +59,38 @@ declare namespace ToySDK {
localPath: string
}
interface ReportActionReq {
/** UP 主自定义的动作标识。 */
userEventId: string
// ---------------------------------------------------------------------------
// 分享与二维码
// ---------------------------------------------------------------------------
interface ShareReq {
/**
* Toy `/toy/<slug>/` query `result.html?score=100`
*
* Toy URL Toy
* URL `../` Toy / `invalid_param`
*/
path: string
}
interface QrCodeReq {
/**
* Toy `/toy/<slug>/` query
* Toy `index.html`
*
* `ShareReq.path`
* Toy URL URL `../` Toy / `invalid_param`
*/
path?: string
/** 二维码边长(像素),取值区间 `[80, 1024]` 的整数。不传默认 `320`,越界或非整数抛 `invalid_param`。 */
size?: number
}
interface QrCodeResp {
/** PNG 图片的完整 data URL`data:image/png;base64,...`),可直接赋给 `img.src`。 */
base64: string
/** 二维码实际编码的完整链接,由平台生成。 */
url: string
}
// ---------------------------------------------------------------------------
@@ -73,6 +102,8 @@ declare namespace ToySDK {
avatar: string
/** 昵称。 */
nickname: string
/** 当前登录用户在当前 Toy 内的稳定假名标识,不是鉴权凭证;功能未启用时可能不返回。 */
toyOpenId?: string
}
// ---------------------------------------------------------------------------
@@ -87,7 +118,7 @@ declare namespace ToySDK {
* - `denied`: 使
* - `unsupported`: SDK B站 App
* - `toy_context_unavailable`: toy
* - `author_mismatch`: Toy
* - `author_mismatch`: Toy 稿稿
* - `video_not_found`:
* - `video_invisible`:
* - `unavailable`:
@@ -235,7 +266,7 @@ declare namespace ToySDK {
/** 回显本次请求传入的引用,用于与请求项对应。 */
ref: AuthorVideoRef
status: ToyItemStatus
/** `status` 非 `ok` 时缺省(如非当前作者、视频不可见)。 */
/** `status` 非 `ok` 时缺省(如作者未参与该视频创作、视频不可见)。 */
data?: AuthorVideo
}
@@ -359,11 +390,17 @@ declare namespace ToySDK {
}
// ---------------------------------------------------------------------------
// 媒体能力
// 媒体能力(摄像头 / 麦克风)
// ---------------------------------------------------------------------------
/**
* `requestCamera` 使
*
* `facingMode` / / null
* SDK `requestMicrophone`
*/
interface MediaRelayOptions {
/** 摄像头朝向,不传默认使用前置摄像头。 */
/** 摄像头朝向`'user'` 前置、`'environment'` 后置。不传默认前置。 */
facingMode?: 'user' | 'environment'
}
@@ -376,7 +413,8 @@ declare namespace ToySDK {
* `'saveImageToAlbum'`
* `toy.`
*
* Web `saveImageToAlbum` / `closeBrowser`
* Web `saveImageToAlbum` / `share` / `closeBrowser`
* `getQrCode`
*/
isSupport(ability: string): Promise<boolean>
@@ -384,7 +422,7 @@ declare namespace ToySDK {
*
*
* clickSDK `navigator.userActivation`
* JSB `window.open`
*
*/
navigate(req: NavigateReq): Promise<void>
@@ -395,20 +433,41 @@ declare namespace ToySDK {
*/
saveImageToAlbum(req: SaveImageReq): Promise<SaveImageResp>
/**
* B站 App ** B站 App **Web
*
* Toy `path`
* Toy `path` `invalid_param`
* `/toy/<slug>/` `unsupported`
*/
share(req: ShareReq): Promise<void>
/**
* Toy `img.src` PNG base64
* **App Web **
*
* PC 线
* `share` Toy
* `toy.getQrCode()` Toy
*
* Toy
* Toy URL Toy
* `path` `size` `invalid_param`
* `/toy/<slug>/` `unsupported`
*/
getQrCode(req?: QrCodeReq): Promise<QrCodeResp>
/** 关闭当前 WebView 容器。**仅 B站 App 内可用**,Web 端调用直接抛错。 */
closeBrowser(): Promise<void>
/**
*
* Toy
*
* Toy
* Promise reject B站 App
* OpenID profile v1/v2 Toy Toy v2 toyOpenId
* Promise reject B站 App使 B站昵称 Toy Toy Toy UID Toy v1/v2 `toyOpenId` Toy
*/
getUserProfile(): Promise<UserProfileResp>
/** 上报 UP 主自定义的用户动作。 */
reportAction(req: ReportActionReq): Promise<void>
/**
* Toy 稿
* Toy
@@ -416,8 +475,8 @@ declare namespace ToySDK {
getAuthorProfile(): Promise<AuthorProfileResp>
/**
* Toy
* item `status` `data`
* Toy 稿
* item `status` `data`
*/
getAuthorVideos(req: AuthorVideosReq): Promise<AuthorVideosResp>
@@ -431,7 +490,7 @@ declare namespace ToySDK {
getAuthorRelation(): Promise<AuthorRelationResp>
/**
* 访
* 访稿
*
*
* `status: 'unsupported'` `items`
@@ -476,18 +535,53 @@ declare namespace ToySDK {
getMyRank(req?: MyRankReq): Promise<MyRankResp>
/**
*
*
* `MediaStream`
* `<video>` `srcObject` canvas / WebGL
*
* ****SDK `navigator.userActivation`
*
* + Toy + App
*
*
* `options` `facingMode`
* Promise reject
* `error.name` `BusinessDenied` / `NotAllowedError` / `NotFoundError` /
* `NotReadableError` / `AbortError`
*
* 使 `toy.stopMedia(stream)`
*/
requestCamera(options?: MediaRelayOptions): Promise<MediaStream>
/**
*
*
* **** `MediaStream`
*
* ****
* Blob / ****
* API SDK
* - **** `MediaRecorder`
* - **** `AudioContext` `AnalyserNode`
* /
*
* ****SDK `navigator.userActivation`
*
* + Toy + App
*
*
* / `error.name`
* `requestCamera`使 `toy.stopMedia(stream)`
*/
requestMicrophone(): Promise<MediaStream>
/** 停止媒体中继并释放摄像头或麦克风设备。 */
/**
* /
*
*
* `requestCamera` / `requestMicrophone` `MediaStream`
* SDK
* SDK
*
*
*/
stopMedia(stream: MediaStream): Promise<void>
}
}
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowJs": true,
"lib": ["ESNext", "DOM"],
},
"include": ["**/*"]
+42
View File
@@ -0,0 +1,42 @@
const storage = {
get: async (keys: string[]) => {
var result = await toy.getCloudStorage(keys)
if (result && result.data && typeof result.data === 'object') {
return result.data
}
return result || {}
},
set: async (record: Record<string, string>) => {
await toy.setCloudStorage(record)
},
}
const cloudMap = new Map<string, ToyCloudSaveCoreSDK.IToyCloudSaveInstance>()
export async function init() {
try {
/* @ts-ignore @vite-ignore */
await import('//s1.hdslb.com/bfs/seed/toy/app/sdk/toy-sdk.js')
/* @ts-ignore */
const coreModule = await import('./lib/cloud-save-core.js')
window.ToyCloudSaveCore =
coreModule.ToyCloudSaveCore || coreModule.default || coreModule
} catch (error) {
console.error('Failed to load Bili Toy SDK:', error)
}
}
export async function get(key: string) {
if (!cloudMap.has(key)) {
const cloud = ToyCloudSaveCore.createCloudSave({ storage, prefix: key })
cloudMap.set(key, cloud)
}
return await cloudMap.get(key)!.load()
}
export async function set(key: string, value: string) {
if (!cloudMap.has(key)) {
const cloud = ToyCloudSaveCore.createCloudSave({ storage, prefix: key })
cloudMap.set(key, cloud)
}
await cloudMap.get(key)!.save(value)
return true
}