675 lines
19 KiB
JavaScript
675 lines
19 KiB
JavaScript
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,
|
|
}
|