Files
lifeRestart/thirdparty/bili-toy/lib/cloud-save-core.js
T
2026-08-16 18:26:59 +08:00

578 lines
19 KiB
JavaScript

(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,
});
}));