const CACHE_INDEX_VERSION = 2 const CACHE_FOLDER = 'tang-detective-assets-v2' const CACHE_INDEX_FILE = 'index.json' const DEFAULT_IMAGE_BUDGET = 28 * 1024 * 1024 const DEFAULT_AUDIO_BUDGET = 32 * 1024 * 1024 const DEFAULT_AUDIO_MAX_ENTRIES = 32 const SHA256_ROUND_CONSTANTS = [ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, ] function utf8Bytes(value) { const encoded = encodeURIComponent(String(value || '')) const bytes = [] for (let index = 0; index < encoded.length; index += 1) { if (encoded[index] === '%') { bytes.push(parseInt(encoded.slice(index + 1, index + 3), 16)) index += 2 } else { bytes.push(encoded.charCodeAt(index)) } } return new Uint8Array(bytes) } function toBytes(value) { if (typeof value === 'string') return utf8Bytes(value) if (value instanceof ArrayBuffer) return new Uint8Array(value) if (ArrayBuffer.isView(value)) { return new Uint8Array(value.buffer, value.byteOffset, value.byteLength) } throw new Error('unsupported SHA-256 input') } function rotateRight(value, bits) { return (value >>> bits) | (value << (32 - bits)) } function sha256Hex(value) { const bytes = toBytes(value) const paddedLength = Math.ceil((bytes.length + 9) / 64) * 64 const message = new Uint8Array(paddedLength) message.set(bytes) message[bytes.length] = 0x80 const bitLength = bytes.length * 8 const highBits = Math.floor(bitLength / 0x100000000) const lowBits = bitLength >>> 0 const lengthOffset = paddedLength - 8 message[lengthOffset] = (highBits >>> 24) & 0xff message[lengthOffset + 1] = (highBits >>> 16) & 0xff message[lengthOffset + 2] = (highBits >>> 8) & 0xff message[lengthOffset + 3] = highBits & 0xff message[lengthOffset + 4] = (lowBits >>> 24) & 0xff message[lengthOffset + 5] = (lowBits >>> 16) & 0xff message[lengthOffset + 6] = (lowBits >>> 8) & 0xff message[lengthOffset + 7] = lowBits & 0xff const hash = [ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, ] const words = new Uint32Array(64) for (let offset = 0; offset < paddedLength; offset += 64) { for (let index = 0; index < 16; index += 1) { const start = offset + index * 4 words[index] = ( (message[start] << 24) | (message[start + 1] << 16) | (message[start + 2] << 8) | message[start + 3] ) >>> 0 } for (let index = 16; index < 64; index += 1) { const word15 = words[index - 15] const word2 = words[index - 2] const sigma0 = ( rotateRight(word15, 7) ^ rotateRight(word15, 18) ^ (word15 >>> 3) ) const sigma1 = ( rotateRight(word2, 17) ^ rotateRight(word2, 19) ^ (word2 >>> 10) ) words[index] = ( words[index - 16] + sigma0 + words[index - 7] + sigma1 ) >>> 0 } let a = hash[0] let b = hash[1] let c = hash[2] let d = hash[3] let e = hash[4] let f = hash[5] let g = hash[6] let h = hash[7] for (let index = 0; index < 64; index += 1) { const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25) const choice = (e & f) ^ (~e & g) const temp1 = ( h + sum1 + choice + SHA256_ROUND_CONSTANTS[index] + words[index] ) >>> 0 const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22) const majority = (a & b) ^ (a & c) ^ (b & c) const temp2 = (sum0 + majority) >>> 0 h = g g = f f = e e = (d + temp1) >>> 0 d = c c = b b = a a = (temp1 + temp2) >>> 0 } hash[0] = (hash[0] + a) >>> 0 hash[1] = (hash[1] + b) >>> 0 hash[2] = (hash[2] + c) >>> 0 hash[3] = (hash[3] + d) >>> 0 hash[4] = (hash[4] + e) >>> 0 hash[5] = (hash[5] + f) >>> 0 hash[6] = (hash[6] + g) >>> 0 hash[7] = (hash[7] + h) >>> 0 } return hash.map((word) => word.toString(16).padStart(8, '0')).join('') } function constantTimeEqualHex(left, right) { const leftValue = String(left || '').toLowerCase() const rightValue = String(right || '').toLowerCase() const length = Math.max(leftValue.length, rightValue.length) let difference = leftValue.length ^ rightValue.length for (let index = 0; index < length; index += 1) { const leftCode = index < leftValue.length ? leftValue.charCodeAt(index) : 0 const rightCode = index < rightValue.length ? rightValue.charCodeAt(index) : 0 difference |= leftCode ^ rightCode } return difference === 0 } function trimSlash(value) { return String(value || '').replace(/\/+$/, '') } function joinPath(left, right) { return `${trimSlash(left)}/${String(right || '').replace(/^\/+/, '')}` } function normalizeIndex(value) { if ( !value || value.version !== CACHE_INDEX_VERSION || !value.entries || typeof value.entries !== 'object' ) { return { version: CACHE_INDEX_VERSION, entries: {}, } } return value } function callFs(fs, method, options) { return new Promise((resolve, reject) => { if (!fs || typeof fs[method] !== 'function') { reject(new Error(`fs.${method} unavailable`)) return } fs[method]({ ...options, success: resolve, fail: reject, }) }) } function createAssetPlatformFacade() { if (typeof wx === 'undefined') return null const env = Object.freeze({ USER_DATA_PATH: String( wx.env && wx.env.USER_DATA_PATH || '', ), }) return Object.freeze({ downloadFile(options) { if (typeof wx.downloadFile !== 'function') { if (options && typeof options.fail === 'function') { options.fail(new Error('wx.downloadFile unavailable')) } return undefined } return wx.downloadFile(options) }, getFileSystemManager() { if (typeof wx.getFileSystemManager !== 'function') return null return wx.getFileSystemManager() }, env, }) } function download(assetPlatform, url) { return new Promise((resolve, reject) => { if (!assetPlatform || typeof assetPlatform.downloadFile !== 'function') { reject(new Error('wx.downloadFile unavailable')) return } assetPlatform.downloadFile({ url, success(result) { if ( result && result.statusCode >= 200 && result.statusCode < 300 && result.tempFilePath ) { resolve(result) return } reject(new Error(`download status ${result && result.statusCode}`)) }, fail: reject, }) }) } function safeCacheName(assetId, asset) { const extensionMatch = String(asset.remotePath || '').match(/(\.[a-z0-9]+)$/i) const extension = extensionMatch ? extensionMatch[1].toLowerCase() : '.bin' const safeId = assetId.replace(/[^a-z0-9._-]/gi, '_') return `${safeId}.${asset.sha256.slice(0, 12)}${extension}` } function createAssetManager(options = {}) { const assetPlatform = options.assetPlatform || createAssetPlatformFacade() const manifest = options.manifest || {} const cdnBaseUrl = trimSlash(options.cdnBaseUrl) const imageBudgetBytes = Number.isFinite(options.imageCacheBudgetBytes) ? Math.max(0, options.imageCacheBudgetBytes) : DEFAULT_IMAGE_BUDGET const audioBudgetBytes = Number.isFinite(options.audioCacheBudgetBytes) ? Math.max(0, options.audioCacheBudgetBytes) : DEFAULT_AUDIO_BUDGET const audioMaxEntries = Number.isFinite(options.audioCacheMaxEntries) ? Math.max(0, Math.floor(options.audioCacheMaxEntries)) : DEFAULT_AUDIO_MAX_ENTRIES const downloadConcurrency = Number.isFinite(options.downloadConcurrency) ? Math.max(1, Math.floor(options.downloadConcurrency)) : 2 const now = typeof options.now === 'function' ? options.now : Date.now const fs = assetPlatform && typeof assetPlatform.getFileSystemManager === 'function' ? assetPlatform.getFileSystemManager() : null const userDataPath = ( assetPlatform && assetPlatform.env && assetPlatform.env.USER_DATA_PATH ) || '' const cacheRoot = userDataPath ? joinPath(userDataPath, CACHE_FOLDER) : '' const indexPath = cacheRoot ? joinPath(cacheRoot, CACHE_INDEX_FILE) : '' let initialized = false let index = normalizeIndex(null) let activeDownloads = 0 const inflight = new Map() const downloadWaiters = [] function fallback(assetId, reason) { return { assetId, available: false, uri: '', source: 'text-fallback', reason, } } async function ensureCacheFolder() { if (!cacheRoot) return false try { await callFs(fs, 'mkdir', { dirPath: cacheRoot, recursive: true, }) } catch (error) { // EEXIST and older base-library mkdir failures are both safe to ignore. } return true } async function persistIndex() { if (!indexPath) return try { await ensureCacheFolder() await callFs(fs, 'writeFile', { filePath: indexPath, data: JSON.stringify(index), encoding: 'utf8', }) } catch (error) { // Cache metadata must never block the text-first game. } } async function fileExists(filePath) { if (!filePath) return false try { await callFs(fs, 'access', { path: filePath }) return true } catch (error) { return false } } async function fileMatchesSha256(filePath, expectedSha256) { try { const result = await callFs(fs, 'readFile', { filePath }) return constantTimeEqualHex( sha256Hex(result.data), expectedSha256, ) } catch (error) { return false } } async function init() { if (initialized) return initialized = true if (!indexPath) return try { const result = await callFs(fs, 'readFile', { filePath: indexPath, encoding: 'utf8', }) index = normalizeIndex(JSON.parse(result.data)) } catch (error) { index = normalizeIndex(null) } let changed = false for (const [assetId, entry] of Object.entries(index.entries)) { if (!manifest[assetId] || !(await fileExists(entry.filePath))) { delete index.entries[assetId] changed = true } } if (changed) await persistIndex() await enforceImageBudget() await enforceAudioBudget() } function cacheBytes(kind) { return Object.values(index.entries).reduce( (total, entry) => ( entry.kind === kind ? total + Math.max(0, Number(entry.size) || 0) : total ), 0, ) } function cacheEntries(kind) { return Object.values(index.entries) .filter((entry) => entry.kind === kind) .length } function imageCacheBytes() { return cacheBytes('image') } function audioCacheBytes() { return cacheBytes('audio') } async function unlinkQuietly(filePath) { try { await callFs(fs, 'unlink', { filePath }) } catch (error) { // A missing cache file is already evicted. } } async function enforceImageBudget(protectedAssetId = '') { let total = imageCacheBytes() if (total <= imageBudgetBytes) return const candidates = Object.entries(index.entries) .filter(([assetId, entry]) => ( entry.kind === 'image' && assetId !== protectedAssetId )) .sort((left, right) => ( (Number(left[1].lastAccessedAt) || 0) - (Number(right[1].lastAccessedAt) || 0) )) for (const [assetId, entry] of candidates) { if (total <= imageBudgetBytes) break await unlinkQuietly(entry.filePath) total -= Math.max(0, Number(entry.size) || 0) delete index.entries[assetId] } await persistIndex() } async function enforceAudioBudget(protectedAssetId = '') { let total = audioCacheBytes() let entries = cacheEntries('audio') if (total <= audioBudgetBytes && entries <= audioMaxEntries) return const candidates = Object.entries(index.entries) .filter(([assetId, entry]) => ( entry.kind === 'audio' && assetId !== protectedAssetId )) .sort((left, right) => ( (Number(left[1].lastAccessedAt) || 0) - (Number(right[1].lastAccessedAt) || 0) )) for (const [assetId, entry] of candidates) { if (total <= audioBudgetBytes && entries <= audioMaxEntries) break await unlinkQuietly(entry.filePath) total -= Math.max(0, Number(entry.size) || 0) entries -= 1 delete index.entries[assetId] } await persistIndex() } async function resolveCached(assetId, asset) { const entry = index.entries[assetId] if (!entry || entry.sha256 !== asset.sha256) return null if (!(await fileExists(entry.filePath))) { delete index.entries[assetId] await persistIndex() return null } if (!(await fileMatchesSha256(entry.filePath, asset.sha256))) { await unlinkQuietly(entry.filePath) delete index.entries[assetId] await persistIndex() return fallback(assetId, 'remote-integrity-failed') } entry.lastAccessedAt = now() await persistIndex() return { assetId, available: true, uri: entry.filePath, source: 'cache', kind: asset.kind, } } async function getTempFileSize(tempFilePath, response) { if (Number.isFinite(response.fileSize)) return response.fileSize try { const statResult = await callFs(fs, 'stat', { path: tempFilePath, }) const stat = statResult && statResult.stats return Math.max(0, Number(stat && stat.size) || 0) } catch (error) { return 0 } } async function fetchRemote(assetId, asset) { if (!cdnBaseUrl || !asset.remotePath) { return fallback(assetId, 'remote-disabled') } const response = await new Promise((resolve, reject) => { const run = () => { activeDownloads += 1 download(assetPlatform, joinPath(cdnBaseUrl, asset.remotePath)) .then(resolve, reject) .finally(() => { activeDownloads -= 1 const next = downloadWaiters.shift() if (next) next() }) } if (activeDownloads < downloadConcurrency) run() else downloadWaiters.push(run) }) if (!(await fileMatchesSha256(response.tempFilePath, asset.sha256))) { await unlinkQuietly(response.tempFilePath) return fallback(assetId, 'remote-integrity-failed') } const size = await getTempFileSize(response.tempFilePath, response) const exceedsCachePolicy = ( (asset.kind === 'image' && size > imageBudgetBytes) || ( asset.kind === 'audio' && (size > audioBudgetBytes || audioMaxEntries < 1) ) ) if (!cacheRoot || exceedsCachePolicy) { return { assetId, available: true, uri: response.tempFilePath, source: 'remote', kind: asset.kind, persistent: false, } } try { await ensureCacheFolder() const filePath = joinPath(cacheRoot, safeCacheName(assetId, asset)) await callFs(fs, 'saveFile', { tempFilePath: response.tempFilePath, filePath, }) index.entries[assetId] = { assetId, filePath, kind: asset.kind, sha256: asset.sha256, size, lastAccessedAt: now(), } await enforceImageBudget(assetId) await enforceAudioBudget(assetId) await persistIndex() return { assetId, available: true, uri: filePath, source: 'remote', kind: asset.kind, persistent: true, } } catch (error) { return { assetId, available: true, uri: response.tempFilePath, source: 'remote', kind: asset.kind, persistent: false, } } } async function resolve(assetId, resolveOptions = {}) { await init() const asset = manifest[assetId] if (!asset) return fallback(assetId, 'unknown-asset') if ( asset.kind === 'audio' && String(asset.reviewStatus || '').toLowerCase() !== 'approved' ) { return fallback(assetId, 'audio-unapproved') } // 包内种子永远优先:首屏无需等网络,离线也能继续读和玩。 if (asset.localSeed && resolveOptions.preferRemote !== true) { return { assetId, available: true, uri: asset.localSeed, source: 'local', kind: asset.kind, } } const cached = await resolveCached(assetId, asset) if (cached) return cached if (resolveOptions.allowRemote === false) { if (asset.localSeed) { return { assetId, available: true, uri: asset.localSeed, source: 'local', kind: asset.kind, } } return fallback(assetId, 'remote-disallowed') } if (inflight.has(assetId)) return inflight.get(assetId) const request = fetchRemote(assetId, asset) .catch(() => ( asset.localSeed ? { assetId, available: true, uri: asset.localSeed, source: 'local', kind: asset.kind, } : fallback(assetId, 'remote-failed') )) .finally(() => inflight.delete(assetId)) inflight.set(assetId, request) return request } async function prefetch(assetIds) { const ids = Array.isArray(assetIds) ? assetIds : [] return Promise.all(ids.map((assetId) => resolve(assetId))) } async function clearCache() { await init() const entries = Object.values(index.entries) for (const entry of entries) await unlinkQuietly(entry.filePath) index = normalizeIndex(null) await persistIndex() } function getCacheStats() { return { entries: Object.keys(index.entries).length, imageBytes: imageCacheBytes(), imageBudgetBytes, audioEntries: cacheEntries('audio'), audioBytes: audioCacheBytes(), audioBudgetBytes, audioMaxEntries, } } return { init, resolve, prefetch, clearCache, getCacheStats, getAsset(assetId) { return manifest[assetId] || null }, } } module.exports = { CACHE_INDEX_VERSION, DEFAULT_AUDIO_BUDGET, DEFAULT_AUDIO_MAX_ENTRIES, constantTimeEqualHex, createAssetPlatformFacade, createAssetManager, sha256Hex, }